herm-tui 1.0.0-dev.1 → 1.0.0-dev.11

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.
Files changed (192) hide show
  1. package/README.md +8 -4
  2. package/assets/eikons/ares.eikon +367 -0
  3. package/assets/eikons/default.eikon +398 -0
  4. package/assets/eikons/mono.eikon +395 -0
  5. package/db.worker.js +81 -0
  6. package/highlights-eq9cgrbb.scm +604 -0
  7. package/highlights-ghv9g403.scm +205 -0
  8. package/highlights-hk7bwhj4.scm +284 -0
  9. package/highlights-r812a2qc.scm +150 -0
  10. package/highlights-x6tmsnaa.scm +115 -0
  11. package/index.js +4151 -0
  12. package/injections-73j83es3.scm +27 -0
  13. package/package.json +14 -64
  14. package/parser.worker.js +8 -0
  15. package/tree-sitter-3jzf13jk.wasm +0 -0
  16. package/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  17. package/tree-sitter-markdown-411r6y9b.wasm +0 -0
  18. package/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  19. package/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  20. package/tree-sitter-zig-e78zbjpm.wasm +0 -0
  21. package/scripts/postinstall.ts +0 -29
  22. package/src/app/gateway.tsx +0 -83
  23. package/src/app/gatewayEvents.ts +0 -203
  24. package/src/app/launch.ts +0 -41
  25. package/src/app/skin.tsx +0 -31
  26. package/src/app/spawnHistory.ts +0 -75
  27. package/src/app/tabs.ts +0 -23
  28. package/src/app/turnReducer.ts +0 -390
  29. package/src/app/useAppKeys.ts +0 -268
  30. package/src/app/useAtRefPopover.ts +0 -99
  31. package/src/app/useInputHistory.ts +0 -66
  32. package/src/app/useSession.ts +0 -102
  33. package/src/app/useSlashCommands.ts +0 -70
  34. package/src/app/useSlashPopover.ts +0 -48
  35. package/src/app.tsx +0 -917
  36. package/src/commands/slash.ts +0 -151
  37. package/src/components/avatar/AnimatedAvatar.tsx +0 -66
  38. package/src/components/avatar/eikon.ts +0 -144
  39. package/src/components/avatar/states/error.ts +0 -1155
  40. package/src/components/avatar/states/idle.ts +0 -1155
  41. package/src/components/avatar/states/index.ts +0 -30
  42. package/src/components/avatar/states/listening.ts +0 -1155
  43. package/src/components/avatar/states/speaking.ts +0 -1155
  44. package/src/components/avatar/states/thinking.ts +0 -1155
  45. package/src/components/avatar/states/working.ts +0 -1155
  46. package/src/components/chat/AtRefPopover.tsx +0 -54
  47. package/src/components/chat/CodeBlock.tsx +0 -67
  48. package/src/components/chat/Composer.tsx +0 -347
  49. package/src/components/chat/DiffBlock.tsx +0 -116
  50. package/src/components/chat/ErrorBlock.tsx +0 -70
  51. package/src/components/chat/MediaChip.tsx +0 -114
  52. package/src/components/chat/MessageItem.tsx +0 -282
  53. package/src/components/chat/MessageList.tsx +0 -114
  54. package/src/components/chat/PromptCard.tsx +0 -359
  55. package/src/components/chat/SlashPopover.tsx +0 -158
  56. package/src/components/chat/ThoughtCloud.tsx +0 -185
  57. package/src/components/chat/TypingIndicator.tsx +0 -25
  58. package/src/components/chat/tool/Subagent.tsx +0 -75
  59. package/src/components/chat/tool/frame.tsx +0 -69
  60. package/src/components/chat/tool/index.tsx +0 -65
  61. package/src/components/chat/tool/preview.ts +0 -57
  62. package/src/components/sidebar/ContextGauge.tsx +0 -102
  63. package/src/components/sidebar/Sidebar.tsx +0 -143
  64. package/src/components/tabs/TabBar.tsx +0 -50
  65. package/src/components/ui/FileLink.tsx +0 -52
  66. package/src/config/index.ts +0 -156
  67. package/src/config/lane.ts +0 -161
  68. package/src/config/models.ts +0 -95
  69. package/src/config/rules.ts +0 -80
  70. package/src/config/schema.ts +0 -308
  71. package/src/dialogs/alert.tsx +0 -52
  72. package/src/dialogs/chafa.tsx +0 -72
  73. package/src/dialogs/confirm.tsx +0 -58
  74. package/src/dialogs/curator.tsx +0 -153
  75. package/src/dialogs/eikon-picker.tsx +0 -95
  76. package/src/dialogs/help.tsx +0 -80
  77. package/src/dialogs/history.tsx +0 -92
  78. package/src/dialogs/info.tsx +0 -115
  79. package/src/dialogs/keys.tsx +0 -170
  80. package/src/dialogs/logs.tsx +0 -42
  81. package/src/dialogs/message.tsx +0 -38
  82. package/src/dialogs/model-picker.tsx +0 -123
  83. package/src/dialogs/new-profile.tsx +0 -69
  84. package/src/dialogs/new-task.tsx +0 -103
  85. package/src/dialogs/profile.tsx +0 -55
  86. package/src/dialogs/rollback.tsx +0 -190
  87. package/src/dialogs/spawn-history.tsx +0 -80
  88. package/src/dialogs/text-prompt.tsx +0 -68
  89. package/src/dialogs/theme-picker.tsx +0 -50
  90. package/src/home/index.ts +0 -23
  91. package/src/home/store.ts +0 -267
  92. package/src/index.tsx +0 -113
  93. package/src/keys/catalog.ts +0 -115
  94. package/src/keys/chord.ts +0 -125
  95. package/src/keys/conflicts.ts +0 -48
  96. package/src/keys/context.tsx +0 -112
  97. package/src/keys/index.ts +0 -5
  98. package/src/keys/list.ts +0 -94
  99. package/src/keys/oc-compat.ts +0 -87
  100. package/src/tabs/Agents.tsx +0 -607
  101. package/src/tabs/Analytics.tsx +0 -154
  102. package/src/tabs/Chat.tsx +0 -50
  103. package/src/tabs/Config.tsx +0 -605
  104. package/src/tabs/Context.tsx +0 -599
  105. package/src/tabs/Cron.tsx +0 -294
  106. package/src/tabs/Env.tsx +0 -227
  107. package/src/tabs/Kanban.tsx +0 -367
  108. package/src/tabs/Memory.tsx +0 -294
  109. package/src/tabs/Sessions.tsx +0 -786
  110. package/src/tabs/Skills.tsx +0 -507
  111. package/src/tabs/Toolsets.tsx +0 -266
  112. package/src/theme/builtin.ts +0 -78
  113. package/src/theme/context.tsx +0 -106
  114. package/src/theme/index.ts +0 -4
  115. package/src/theme/resolve.ts +0 -134
  116. package/src/theme/syntax.ts +0 -31
  117. package/src/theme/themes/aura.json +0 -69
  118. package/src/theme/themes/ayu.json +0 -80
  119. package/src/theme/themes/carbonfox.json +0 -248
  120. package/src/theme/themes/catppuccin-frappe.json +0 -233
  121. package/src/theme/themes/catppuccin-macchiato.json +0 -233
  122. package/src/theme/themes/catppuccin.json +0 -112
  123. package/src/theme/themes/cobalt2.json +0 -228
  124. package/src/theme/themes/cursor.json +0 -249
  125. package/src/theme/themes/dracula.json +0 -219
  126. package/src/theme/themes/everforest.json +0 -241
  127. package/src/theme/themes/flexoki.json +0 -237
  128. package/src/theme/themes/github.json +0 -233
  129. package/src/theme/themes/gruvbox.json +0 -242
  130. package/src/theme/themes/kanagawa.json +0 -77
  131. package/src/theme/themes/lucent-orng.json +0 -237
  132. package/src/theme/themes/material.json +0 -235
  133. package/src/theme/themes/matrix.json +0 -77
  134. package/src/theme/themes/mercury.json +0 -252
  135. package/src/theme/themes/monokai.json +0 -221
  136. package/src/theme/themes/nightowl.json +0 -221
  137. package/src/theme/themes/nord.json +0 -223
  138. package/src/theme/themes/one-dark.json +0 -84
  139. package/src/theme/themes/opencode.json +0 -245
  140. package/src/theme/themes/orng.json +0 -249
  141. package/src/theme/themes/osaka-jade.json +0 -93
  142. package/src/theme/themes/palenight.json +0 -222
  143. package/src/theme/themes/rosepine.json +0 -234
  144. package/src/theme/themes/solarized.json +0 -223
  145. package/src/theme/themes/synthwave84.json +0 -226
  146. package/src/theme/themes/tokyonight.json +0 -243
  147. package/src/theme/themes/vercel.json +0 -245
  148. package/src/theme/themes/vesper.json +0 -218
  149. package/src/theme/themes/zenburn.json +0 -223
  150. package/src/theme/types.ts +0 -119
  151. package/src/types/message.ts +0 -97
  152. package/src/ui/ChafaImage.tsx +0 -64
  153. package/src/ui/Splash.tsx +0 -118
  154. package/src/ui/borders.ts +0 -28
  155. package/src/ui/command.tsx +0 -104
  156. package/src/ui/dialog-select.tsx +0 -164
  157. package/src/ui/dialog.tsx +0 -102
  158. package/src/ui/fmt.ts +0 -82
  159. package/src/ui/kv.tsx +0 -28
  160. package/src/ui/shell.tsx +0 -45
  161. package/src/ui/spinner.tsx +0 -59
  162. package/src/ui/splash-art.ts +0 -123
  163. package/src/ui/table.tsx +0 -117
  164. package/src/ui/ticker.tsx +0 -90
  165. package/src/ui/toast.tsx +0 -130
  166. package/src/utils/categorical.ts +0 -77
  167. package/src/utils/chafa.ts +0 -173
  168. package/src/utils/clipboard.ts +0 -67
  169. package/src/utils/context-segments.ts +0 -317
  170. package/src/utils/control.ts +0 -495
  171. package/src/utils/drop.ts +0 -25
  172. package/src/utils/editor.ts +0 -33
  173. package/src/utils/fuzzy.ts +0 -45
  174. package/src/utils/gateway-client.ts +0 -253
  175. package/src/utils/gateway-types.ts +0 -282
  176. package/src/utils/git.ts +0 -57
  177. package/src/utils/hermes-analytics.ts +0 -134
  178. package/src/utils/hermes-home.ts +0 -821
  179. package/src/utils/hermes-kanban.ts +0 -154
  180. package/src/utils/hermes-profiles.ts +0 -217
  181. package/src/utils/interpolate.ts +0 -31
  182. package/src/utils/math-unicode.ts +0 -818
  183. package/src/utils/memory-activity.ts +0 -140
  184. package/src/utils/open-file.ts +0 -13
  185. package/src/utils/paths.ts +0 -52
  186. package/src/utils/perf.ts +0 -235
  187. package/src/utils/preferences.ts +0 -150
  188. package/src/utils/sessions-db.ts +0 -396
  189. package/src/utils/subagent-tree.ts +0 -146
  190. package/src/utils/terminal-reset.ts +0 -129
  191. package/src/utils/tips.ts +0 -67
  192. package/src/utils/tokens.ts +0 -87
@@ -1,786 +0,0 @@
1
- import { useState, useEffect, useCallback, useRef, memo } from "react"
2
- import { SIDE_PIPE } from "../ui/borders"
3
- import { useKeyboard, useTerminalDimensions } from "@opentui/react"
4
- import type { ScrollBoxRenderable } from "@opentui/core"
5
- import { useKeys, handleListKey } from "../keys"
6
- import * as sdb from "../utils/sessions-db"
7
- import type { SessionRow, SessionHit, LineageInfo, PeekMsg } from "../utils/sessions-db"
8
- import type {
9
- SessionListItem, SessionListResponse,
10
- } from "../utils/gateway-types"
11
- import { useGateway } from "../app/gateway"
12
- import { useTheme } from "../theme"
13
- import { useDialog } from "../ui/dialog"
14
- import { useToast } from "../ui/toast"
15
- import { TabShell } from "../ui/shell"
16
- import { KVBlock } from "../ui/kv"
17
- import { Col, Hdr, Marquee, VBAR } from "../ui/table"
18
- import { Spinner } from "../ui/spinner"
19
- import { Ticker, inline } from "../ui/ticker"
20
- import { openConfirm } from "../dialogs/confirm"
21
- import { openTextPrompt } from "../dialogs/text-prompt"
22
- import { fmt, cost, trunc, ago, when, span, stamp } from "../ui/fmt"
23
- import { home } from "../home"
24
-
25
- // Architecture: herm's Sessions tab is a **local state.db reader**.
26
- // Stock tui_gateway exposes only ~30% of what the tab needs via RPC
27
- // (see sessions-db.ts header). The gateway is authoritative for
28
- // exactly one thing — *which session ids it can resume* — so we join
29
- // session.list against the local roots() by id. Enrichment (tokens,
30
- // cost, model, lineage, subagents, last_active) all comes from
31
- // state.db. When state.db isn't the gateway's state.db (remote
32
- // gateway, separate profile), enrichment is absent and rows render
33
- // un-enriched; the tab keeps working at session.list fidelity.
34
-
35
- type Row = SessionListItem & { detail?: SessionRow }
36
-
37
- // ─── Formatting ──────────────────────────────────────────────────────
38
-
39
- const badge = (src: string): string => ({
40
- cli: "CLI", tui: "TUI", api_server: "API", discord: "Discord",
41
- telegram: "Telegram", slack: "Slack", whatsapp: "WhatsApp", signal: "Signal",
42
- } as Record<string, string>)[src] ?? src
43
-
44
- // ─── Transcript Peek ─────────────────────────────────────────────────
45
- //
46
- // Purpose: decide whether to load a session without replacing the
47
- // current chat. So: conversation only. Tool chatter is collapsed to
48
- // a single count in the footer — scanning "what was said" matters;
49
- // which tools ran doesn't, at peek granularity.
50
- //
51
- // Row style mirrors the chat transcript (MessageItem's Gutter): user
52
- // = left bar in theme.primary, assistant = right bar in theme.accent.
53
- // One line each; hover a row to fast-marquee the clipped text.
54
-
55
- type Folded = { role: "user" | "assistant"; text: string }
56
-
57
- const line = (s: string | null) =>
58
- (s ?? "").replace(/\s+/g, " ").trim()
59
-
60
- /** Reduce raw PeekMsg[] to { turns, tools }. Exported for tests. */
61
- export const fold = (msgs: PeekMsg[]): { turns: Folded[]; tools: number } => {
62
- const turns: Folded[] = []
63
- let tools = 0
64
- for (const m of msgs) {
65
- if (m.role === "tool") { tools++; continue }
66
- if (m.role !== "user" && m.role !== "assistant") continue
67
- const text = line(m.content)
68
- // Assistant rows with tool_calls but no content are pure tool-
69
- // invocation turns — nothing to read, counted in `tools` via
70
- // their result rows.
71
- if (!text) continue
72
- turns.push({ role: m.role, text })
73
- }
74
- return { turns, tools }
75
- }
76
-
77
- const PeekRow = memo((props: { row: Folded }) => {
78
- const theme = useTheme().theme
79
- const [hot, setHot] = useState(false)
80
- const left = props.row.role === "user"
81
- const color = left ? theme.primary : theme.accent
82
- const fg = left ? theme.text : theme.markdownText
83
- // Width-2 box with a single-side border draws exactly "│ " / " │",
84
- // matching components/chat/MessageItem Gutter at height=1.
85
- const bar = (side: "left" | "right") => (
86
- <box width={2} flexShrink={0} height={1}
87
- border={[side]} borderColor={color}
88
- customBorderChars={SIDE_PIPE} />
89
- )
90
- return (
91
- <box height={1} flexDirection="row"
92
- backgroundColor={hot ? theme.backgroundElement : undefined}
93
- onMouseOver={() => setHot(true)}
94
- onMouseOut={() => setHot(false)}>
95
- {left ? bar("left") : null}
96
- <Ticker active={hot} speed={35} hold={150} fg={fg}>
97
- {inline(props.row.text).map((s, i) =>
98
- s.c ? <span key={i} fg={theme.warning}>{s.t}</span>
99
- : s.b ? <span key={i} fg={fg}><strong>{s.t}</strong></span>
100
- : s.i ? <span key={i} fg={fg}><u>{s.t}</u></span>
101
- : <span key={i} fg={fg}>{s.t}</span>)}
102
- </Ticker>
103
- {left ? null : bar("right")}
104
- </box>
105
- )
106
- })
107
-
108
- const Peek = memo((props: { sid: string; total: number; peek: typeof sdb.peek }) => {
109
- const theme = useTheme().theme
110
- const [data, setData] = useState<{ turns: Folded[]; tools: number } | null>(null)
111
- const sb = useRef<ScrollBoxRenderable | null>(null)
112
-
113
- useEffect(() => {
114
- setData(fold(props.peek(props.sid, 60)))
115
- }, [props.sid, props.peek])
116
- // Pin to bottom on load — "where did this end up", not "how did
117
- // it start".
118
- useEffect(() => {
119
- if (data && sb.current) sb.current.scrollTop = sb.current.scrollHeight
120
- }, [data])
121
-
122
- if (data === null) return null
123
- if (data.turns.length === 0 && data.tools === 0) return (
124
- <box height={1}><text fg={theme.textMuted}>(no local transcript)</text></box>
125
- )
126
- const more = Math.max(0, props.total - 60)
127
-
128
- return (
129
- <box flexDirection="column" flexGrow={1} minHeight={5}
130
- border borderStyle="single" borderColor={theme.border}
131
- title={` Transcript${more > 0 ? ` · ${more} earlier` : ""} `}
132
- titleAlignment="left">
133
- <scrollbox ref={sb} scrollY flexGrow={1} minHeight={3}>
134
- <box flexDirection="column" width="100%">
135
- {data.turns.map((r, i) => <PeekRow key={i} row={r} />)}
136
- </box>
137
- </scrollbox>
138
- <box height={1}>
139
- <text fg={theme.textMuted}>
140
- {`${data.turns.length} turn${data.turns.length === 1 ? "" : "s"} · ${data.tools} tool call${data.tools === 1 ? "" : "s"}`}
141
- </text>
142
- </box>
143
- </box>
144
- )
145
- })
146
-
147
- // ─── Detail Panel ────────────────────────────────────────────────────
148
- //
149
- // Data provenance:
150
- // RPC (session.list): id, title, preview (first user msg), source,
151
- // started_at, message_count
152
- // state.db enrich: model, last_active, ended_at, end_reason,
153
- // tokens, cost, tool_call_count, lastMessage
154
- //
155
- // `ended_at` is NULL for ~80% of rows — hermes-agent only sets it on
156
- // clean CLI exit / compression / explicit reset, never on process
157
- // kill or abandoned TUI connects. So we derive Duration and "Last
158
- // active" from MAX(messages.timestamp) instead, which is always
159
- // accurate, and only show the ended_at/end_reason pair when it's
160
- // actually populated.
161
-
162
- const Detail = memo((props: {
163
- row: Row
164
- onSwitch?: (sid: string) => void
165
- lineage?: (sid: string) => LineageInfo
166
- peek: (sid: string, n?: number) => PeekMsg[]
167
- }) => {
168
- const theme = useTheme().theme
169
- const r = props.row
170
- const d = r.detail
171
- const lastActive = d?.last_active ?? d?.ended_at ?? null
172
- const subs = d?.subagent_count ?? 0
173
- // Lineage is pulled fresh on row change — query is in-process and
174
- // sub-ms. Cached by row.id so the lookup doesn't fire on every render.
175
- const [info, setInfo] = useState<LineageInfo>({})
176
- useEffect(() => {
177
- setInfo((props.lineage ?? sdb.lineage)(r.id))
178
- }, [r.id, props.lineage])
179
- const hasLineage = info.continuesFrom || info.compressedTo || subs > 0
180
- const go = (sid: string) => () => props.onSwitch?.(sid)
181
-
182
- return (
183
- <TabShell title="Session Detail" hint="" grow={2}>
184
- <box flexDirection="column" width="100%" flexGrow={1} overflow="hidden">
185
- <box flexDirection="column" flexShrink={0}>
186
- <box minHeight={1}>
187
- <text wrapMode="word"><span fg={theme.accent}><strong>{r.title || "Untitled"}</strong></span></text>
188
- </box>
189
- <box height={1} />
190
- <KVBlock rows={[
191
- ["ID", r.id],
192
- ["Source", badge(r.source ?? "")],
193
- ["Model", d?.model ?? "—"],
194
- ["Started", when(r.started_at)],
195
- ["Last active", lastActive ? `${when(lastActive)} (${ago(lastActive)})` : "—"],
196
- ["Duration", lastActive ? span(r.started_at, lastActive) : "—"],
197
- ["Ended", d?.ended_at ? `${when(d.ended_at)} · ${d.end_reason ?? "—"}` : undefined],
198
- ]} />
199
- <box height={1} />
200
- <KVBlock rows={[
201
- ["Messages", String(r.message_count)],
202
- ["Tool calls", d ? String(d.tool_call_count) : undefined],
203
- ["Input", d ? `${fmt(d.input_tokens)} tok` : undefined],
204
- ["Output", d ? `${fmt(d.output_tokens)} tok` : undefined],
205
- ["Cache", d ? `${fmt(d.cache_read_tokens)} r / ${fmt(d.cache_write_tokens)} w` : undefined],
206
- ["Reasoning", d ? `${fmt(d.reasoning_tokens)} tok` : undefined],
207
- ["Cost", d ? cost(d.estimated_cost_usd) : undefined, theme.success],
208
- ]} />
209
- {hasLineage ? <>
210
- <box height={1} />
211
- <box minHeight={1}><text fg={theme.textMuted}>Lineage</text></box>
212
- {info.continuesFrom ? (
213
- <box height={1} onMouseDown={go(info.continuesFrom.id)}>
214
- <text>
215
- <span fg={theme.textMuted}>{" ← continues from "}</span>
216
- <span fg={theme.accent}>{info.continuesFrom.title || info.continuesFrom.id}</span>
217
- </text>
218
- </box>
219
- ) : null}
220
- {info.compressedTo ? (
221
- <box height={1} onMouseDown={go(info.compressedTo.id)}>
222
- <text>
223
- <span fg={theme.textMuted}>{" → compressed to "}</span>
224
- <span fg={theme.accent}>{info.compressedTo.title || info.compressedTo.id}</span>
225
- </text>
226
- </box>
227
- ) : null}
228
- {subs > 0 ? (
229
- <box height={1}>
230
- <text>
231
- <span fg={theme.textMuted}>{" ⎇ spawned "}</span>
232
- <span fg={theme.text}>{String(subs)}</span>
233
- <span fg={theme.textMuted}>{` subagent${subs === 1 ? "" : "s"}`}</span>
234
- </text>
235
- </box>
236
- ) : null}
237
- </> : null}
238
- {!d ? <>
239
- <box height={1} />
240
- <box height={1}><text fg={theme.textMuted}>(no local detail — state.db mismatch)</text></box>
241
- </> : null}
242
- <box height={1} />
243
- </box>
244
- <Peek sid={r.id} total={r.message_count} peek={props.peek} />
245
- </box>
246
- </TabShell>
247
- )
248
- })
249
-
250
- // ─── Search Detail Panel ─────────────────────────────────────────────
251
-
252
- const SearchDetail = memo((props: { result: SessionHit }) => {
253
- const theme = useTheme().theme
254
- const r = props.result
255
-
256
- // Render snippet with >>> <<< markers as highlights.
257
- const parts: Array<{ text: string; hi: boolean }> = []
258
- let rest = r.snippet
259
- while (rest.length) {
260
- const start = rest.indexOf(">>>")
261
- if (start < 0) { parts.push({ text: rest, hi: false }); break }
262
- if (start > 0) parts.push({ text: rest.slice(0, start), hi: false })
263
- const end = rest.indexOf("<<<", start + 3)
264
- if (end < 0) { parts.push({ text: rest.slice(start + 3), hi: true }); break }
265
- parts.push({ text: rest.slice(start + 3, end), hi: true })
266
- rest = rest.slice(end + 3)
267
- }
268
-
269
- return (
270
- <TabShell title="Search Match" hint="" grow={2}>
271
- <scrollbox scrollY flexGrow={1}>
272
- <box flexDirection="column" width="100%">
273
- <box minHeight={1}>
274
- <text wrapMode="word"><span fg={theme.accent}><strong>{r.title ?? "Untitled"}</strong></span></text>
275
- </box>
276
- <box height={1} />
277
- <KVBlock rows={[
278
- ["Source", badge(r.source)],
279
- ["Model", r.model ?? "—"],
280
- ["Time", when(r.started_at)],
281
- ]} />
282
- <box height={1} />
283
- <box height={1}><text fg={theme.textMuted}>Snippet</text></box>
284
- <box minHeight={1}>
285
- <text wrapMode="word">
286
- {parts.map((p, i) => p.hi
287
- ? <span key={i} fg={theme.accent}><strong>{p.text}</strong></span>
288
- : <span key={i} fg={theme.text}>{p.text}</span>
289
- )}
290
- </text>
291
- </box>
292
- </box>
293
- </scrollbox>
294
- </TabShell>
295
- )
296
- })
297
- // ─── Rows ────────────────────────────────────────────────────────────
298
- // Col/Hdr live in ui/table; header pads by VBAR_W so its grow column
299
- // matches body rows inside the forced-visible v-bar scrollbox.
300
-
301
- const HeaderRow = memo(() => {
302
- const theme = useTheme().theme
303
- const fg = theme.textMuted
304
- return (
305
- <Hdr>
306
- <Col w={2} fg={fg}>{" "}</Col>
307
- <Col grow fg={fg} bold>Title</Col>
308
- <Col w={9} fg={fg} bold>Source</Col>
309
- <Col w={8} fg={fg} bold>Start</Col>
310
- <Col w={10} fg={fg} bold right>Active</Col>
311
- <Col w={7} fg={fg} bold right>Msgs</Col>
312
- <box width={3} />
313
- </Hdr>
314
- )
315
- })
316
-
317
- // Row callbacks take the index so the *functions* are stable across
318
- // renders — otherwise every row gets a fresh closure every parent
319
- // render and memo() never bails (O(N) React work per keystroke).
320
- type RowCbs = {
321
- onActivate: (i: number) => void
322
- onHover: (i: number) => void
323
- onDelete: (i: number) => void
324
- }
325
-
326
- const Item = memo((props: {
327
- id: string; row: Row; idx: number; selected: boolean; indent?: boolean
328
- } & RowCbs) => {
329
- const theme = useTheme().theme
330
- const { row: r, idx: i } = props
331
- const [x, setX] = useState(false)
332
- const active = r.detail?.last_active ?? r.detail?.ended_at ?? null
333
- // Parent rows get "▸ "/" " leaders; child rows get "└─" as the tree
334
- // marker. Selected children still highlight via backgroundColor +
335
- // text color — indent is the only hierarchy signal.
336
- const leader = props.indent ? "└─" : (props.selected ? "▸ " : " ")
337
- const muted = props.indent && !props.selected ? theme.textMuted : undefined
338
-
339
- return (
340
- <box id={props.id} flexDirection="row" height={1}
341
- backgroundColor={props.selected ? theme.backgroundElement : undefined}
342
- onMouseDown={() => props.onActivate(i)} onMouseMove={() => props.onHover(i)}>
343
- <Col w={2} fg={props.selected ? theme.primary : (muted ?? theme.text)}>{leader}</Col>
344
- <Marquee grow active={props.selected}
345
- fg={props.selected ? theme.accent : (muted ?? theme.text)}
346
- bold={props.selected}>
347
- {r.title || "Untitled"}
348
- </Marquee>
349
- <Col w={9} fg={muted ?? theme.info}>{badge(r.source ?? "")}</Col>
350
- <Col w={8} fg={theme.textMuted}>{stamp(r.started_at)}</Col>
351
- <Col w={10} fg={theme.textMuted} right>{active ? ago(active) : "—"}</Col>
352
- <Col w={7} fg={theme.textMuted} right>{String(r.message_count)}</Col>
353
- {props.indent ? <box width={3} /> : (
354
- <box width={3}
355
- onMouseDown={(e) => { e.stopPropagation(); props.onDelete(i) }}
356
- onMouseOver={() => setX(true)} onMouseOut={() => setX(false)}>
357
- <text><span fg={x ? theme.error : theme.textMuted}>{" ✕"}</span></text>
358
- </box>
359
- )}
360
- </box>
361
- )
362
- })
363
-
364
- const SearchHeaderRow = memo(() => {
365
- const theme = useTheme().theme
366
- const fg = theme.textMuted
367
- return (
368
- <Hdr>
369
- <Col w={2} fg={fg}>{" "}</Col>
370
- <Col grow fg={fg} bold>Title</Col>
371
- <Col w={9} fg={fg} bold>Source</Col>
372
- <Col w={10} fg={fg} bold>When</Col>
373
- <Col w={20} fg={fg} bold>Model</Col>
374
- </Hdr>
375
- )
376
- })
377
-
378
- const SearchItem = memo((props: {
379
- id: string; result: SessionHit; idx: number; selected: boolean
380
- onActivate: (i: number) => void; onHover: (i: number) => void
381
- }) => {
382
- const theme = useTheme().theme
383
- const { result: r, idx: i } = props
384
- return (
385
- <box id={props.id} flexDirection="row" height={1}
386
- backgroundColor={props.selected ? theme.backgroundElement : undefined}
387
- onMouseDown={() => props.onActivate(i)} onMouseMove={() => props.onHover(i)}>
388
- <Col w={2} fg={props.selected ? theme.primary : theme.text}>{props.selected ? "▸ " : " "}</Col>
389
- <Col grow fg={props.selected ? theme.accent : theme.text} bold={props.selected}>
390
- {r.title ?? "Untitled"}
391
- </Col>
392
- <Col w={9} fg={theme.info}>{badge(r.source)}</Col>
393
- <Col w={10} fg={theme.textMuted}>{ago(r.started_at)}</Col>
394
- <Col w={20} fg={theme.textMuted}>{r.model ?? "—"}</Col>
395
- </box>
396
- )
397
- })
398
-
399
- // ─── Main ────────────────────────────────────────────────────────────
400
-
401
- // Data-layer ops are injectable so tests don't fight analytics.test
402
- // for the shared sandbox state.db. Defaults are the real functions.
403
- type IO = {
404
- list: typeof sdb.roots
405
- search: typeof sdb.search
406
- remove: typeof sdb.remove
407
- rename: typeof sdb.rename
408
- subagents: typeof sdb.children
409
- lineage: typeof sdb.lineage
410
- peek: typeof sdb.peek
411
- }
412
-
413
- type Props = { focused?: boolean; currentId?: string; onSwitch?: (sid: string) => void; io?: Partial<IO> }
414
-
415
- export const Sessions = memo((props: Props) => {
416
- const theme = useTheme().theme
417
- const gw = useGateway()
418
- const dialog = useDialog()
419
- const toast = useToast()
420
- const dims = useTerminalDimensions()
421
-
422
- const io: IO = {
423
- list: props.io?.list ?? sdb.roots,
424
- search: props.io?.search ?? sdb.search,
425
- remove: props.io?.remove ?? sdb.remove,
426
- rename: props.io?.rename ?? sdb.rename,
427
- subagents: props.io?.subagents ?? sdb.children,
428
- lineage: props.io?.lineage ?? sdb.lineage,
429
- peek: props.io?.peek ?? sdb.peek,
430
- }
431
-
432
- const [rows, setRows] = useState<Row[]>([])
433
- const [warn, setWarn] = useState("")
434
- const [pending, setPending] = useState(true)
435
- // Selection is tracked by row identity so that collapsing children
436
- // (which changes the flat index of every row below) never lands sel
437
- // on the wrong row. The numeric index consumers use (handleListKey,
438
- // rowActivate, etc.) is derived from visible[] each render.
439
- const [anchor, setAnchor] = useState<{ id: string; indent: boolean } | null>(null)
440
- const [searching, setSearching] = useState(false)
441
- const [query, setQuery] = useState("")
442
- const [results, setResults] = useState<SessionHit[]>([])
443
- const [searchSel, setSearchSel] = useState(0)
444
- // parent_id → children, populated at load() time for every row
445
- // with subagent_count > 0 so render stays pure (no sync fetch).
446
- const [kids, setKids] = useState<Map<string, Row[]>>(new Map())
447
- const debounce = useRef<ReturnType<typeof setTimeout> | null>(null)
448
- const vscroll = useRef<ScrollBoxRenderable | null>(null)
449
-
450
- // Expansion is derived from the anchor: if the anchor is a parent
451
- // row with subagents, that parent is expanded; if the anchor is a
452
- // child, the child's owning parent is expanded. Anything else = no
453
- // expansion. This makes collapse/expand atomic with sel changes —
454
- // no lagging effect, no clamp pass.
455
- const anchored = anchor && rows.find(r => r.id === anchor.id)
456
- const owner =
457
- anchor?.indent
458
- ? rows.find(r => kids.get(r.id)?.some(c => c.id === anchor.id))
459
- : (anchored?.detail?.subagent_count ?? 0) > 0 ? anchored : undefined
460
-
461
- // Flat visible sequence = parents with `owner`'s children inlined.
462
- const visible = rows.flatMap((r, i) =>
463
- r.id === owner?.id
464
- ? [{ row: r, indent: false, parentIdx: i },
465
- ...(kids.get(r.id) ?? []).map(c =>
466
- ({ row: c, indent: true, parentIdx: i }))]
467
- : [{ row: r, indent: false, parentIdx: i }])
468
-
469
- // Resolve anchor → numeric index into visible. Fallback to 0 when
470
- // the anchor row is gone (reload dropped it) or never set.
471
- const sel = anchor
472
- ? Math.max(0, visible.findIndex(v => v.row.id === anchor.id && v.indent === anchor.indent))
473
- : 0
474
-
475
- // Latest-value refs so the stable row callbacks below don't close
476
- // over stale arrays (and therefore don't need to be in their deps,
477
- // which would defeat the memo).
478
- const live = useRef({ rows, visible, anchor, results, searching, onSwitch: props.onSwitch, currentId: props.currentId })
479
- live.current = { rows, visible, anchor, results, searching, onSwitch: props.onSwitch, currentId: props.currentId }
480
-
481
- // Adapter for handleListKey, which speaks numeric sel. Translating
482
- // through the anchor means the target row is resolved against the
483
- // CURRENT visible layout at call time — collapse/expand re-renders
484
- // later and sel follows the row, not the stale index.
485
- const setSel: typeof setSearchSel = useCallback((arg) => {
486
- const cur = live.current
487
- const prev = cur.visible.findIndex(v =>
488
- v.row.id === cur.anchor?.id && v.indent === cur.anchor.indent)
489
- const n = typeof arg === "function" ? arg(Math.max(0, prev)) : arg
490
- const v = cur.visible[Math.max(0, Math.min(cur.visible.length - 1, n))]
491
- if (v) setAnchor({ id: v.row.id, indent: v.indent })
492
- }, [])
493
-
494
- const LIMIT = 2000
495
-
496
- const toRow = (d: SessionRow): Row => ({
497
- id: d.id, title: d.title ?? "", preview: d.lastMessage ?? "",
498
- message_count: d.message_count, started_at: d.started_at,
499
- source: d.sessionSource, detail: d,
500
- })
501
-
502
- // Paint fs rows synchronously (io.list is an in-process sqlite read),
503
- // then reconcile with the RPC result. session.list is the slow path
504
- // (2000-row limit over stdio) and is what made the tab sit empty on
505
- // mount. Enrichment comes from fs either way, so the optimistic paint
506
- // is visually identical when state.db == gateway's state.db — which
507
- // is the common local case. Remote gateways paint [] first, then RPC;
508
- // `pending` covers that gap with a spinner.
509
- const fill = (list: Row[]) => {
510
- setKids(new Map(list
511
- .filter(r => (r.detail?.subagent_count ?? 0) > 0)
512
- .map(r => [r.id, io.subagents(r.id).map(toRow)])))
513
- setRows(list)
514
- }
515
-
516
- const load = useCallback(async () => {
517
- const fs = Promise.resolve().then(() => io.list(LIMIT)).catch(() => [])
518
- const rpc = gw.request<SessionListResponse>("session.list", { limit: LIMIT })
519
- .then(r => ({ ok: true as const, v: r }))
520
- .catch((e: Error) => ({ ok: false as const, e }))
521
-
522
- const disk = await fs
523
- const local = new Map(disk.map(r => [r.id, r]))
524
- fill(disk.filter(d => d.message_count > 0).map(toRow))
525
- setPending(true)
526
-
527
- // Stock session.list doesn't drop 0-msg stubs — every abandoned
528
- // connect leaves one, and they're never useful to resume.
529
- const r = await rpc
530
- if (r.ok && r.v.sessions?.length)
531
- fill(r.v.sessions
532
- .filter(s => (s.message_count ?? 0) > 0)
533
- .map(s => ({ ...s, detail: local.get(s.id) })))
534
- setPending(false)
535
- setWarn(!r.ok
536
- ? local.size
537
- ? `gateway session.list failed (${r.e.message}) — listing state.db directly; rows may not resume`
538
- : r.e.message
539
- : "")
540
- }, [gw])
541
-
542
- useEffect(() => { load() }, [load])
543
-
544
- // Seed anchor once rows arrive (first row, unexpanded).
545
- useEffect(() => {
546
- if (!anchor && rows.length) setAnchor({ id: rows[0].id, indent: false })
547
- }, [rows, anchor])
548
-
549
- // Search is a synchronous FTS5 query on state.db, so debounce —
550
- // running it on every keystroke blocks the render thread. The
551
- // cleanup clears the pending timer, which also drops superseded
552
- // queries for free (only the most recent query value ever runs).
553
- useEffect(() => {
554
- if (!searching || !query.trim()) { setResults([]); return }
555
- debounce.current = setTimeout(() => {
556
- setResults(io.search(query, 30))
557
- setSearchSel(0)
558
- }, 150)
559
- return () => { if (debounce.current) clearTimeout(debounce.current) }
560
- }, [query, searching])
561
-
562
- // ── Stable row callbacks (identity never changes) ────────────────
563
- // Hover-to-select is onMouseMove, not onMouseOver — the latter fires
564
- // when scrollChildIntoView moves rows under a stationary cursor and
565
- // would snap sel back during ↓-repeat (the "stutter"). Mouse motion
566
- // events only arrive on real pointer movement.
567
- const rowHover = useCallback((i: number) => {
568
- live.current.searching ? setSearchSel(i) : setSel(i)
569
- }, [setSel])
570
- // Switching sessions reset()s the current chat; confirm unless it's
571
- // a no-op (same id) or there's nothing to switch to.
572
- const rowActivate = useCallback((i: number) => {
573
- const l = live.current
574
- l.searching ? setSearchSel(i) : setSel(i)
575
- const hit = l.searching ? l.results[i] : l.visible[i]?.row
576
- const id = l.searching ? (hit as SessionHit | undefined)?.session_id : (hit as Row | undefined)?.id
577
- if (!id || !l.onSwitch) return
578
- if (id === l.currentId) return l.onSwitch(id)
579
- const title = (hit as { title?: string } | undefined)?.title || "Untitled"
580
- const n = l.searching ? undefined : (hit as Row).message_count
581
- void openConfirm(dialog, {
582
- title: "Load session?",
583
- body: `${trunc(title, 60)}${n != null ? ` · ${n} msg${n === 1 ? "" : "s"}` : ""}\n\nCurrent chat will be replaced.`,
584
- yes: "load",
585
- }).then(ok => { if (ok) l.onSwitch?.(id) })
586
- }, [dialog])
587
- // Delete on a child row is a no-op (only parents can be deleted from
588
- // the list). The ✕ glyph is hidden for indented rows anyway; this
589
- // guard covers the keyboard shortcut path.
590
- const rowDelete = useCallback((i: number) => {
591
- const v = live.current.visible[i]
592
- if (v && !v.indent) confirmDeleteRef.current(v.row)
593
- }, [])
594
-
595
- // Lineage-click switches target a SPECIFIC session (the predecessor
596
- // or successor), not the projected tip. Confirm to match list click.
597
- const lineageSwitch = useCallback((sid: string) => {
598
- const l = live.current
599
- if (!l.onSwitch) return
600
- if (sid === l.currentId) return l.onSwitch(sid)
601
- void openConfirm(dialog, {
602
- title: "Load session?",
603
- body: `Switch to ${trunc(sid, 24)}?\n\nCurrent chat will be replaced.`,
604
- yes: "load",
605
- }).then(ok => { if (ok) l.onSwitch?.(sid) })
606
- }, [dialog])
607
-
608
- const confirmDeleteRef = useRef<(r: Row) => void>(() => {})
609
- const confirmDelete = useCallback((r: Row) => {
610
- openConfirm(dialog, {
611
- title: "Delete Session?",
612
- body: trunc(r.title || "Untitled", 46),
613
- yes: "Delete",
614
- danger: true,
615
- }).then(async ok => {
616
- if (!ok) return
617
- // session.delete RPC first — it refuses to remove the active
618
- // session and also unlinks transcript files. Fall back to the
619
- // direct DELETE only when the gateway rejects/is down.
620
- const done = await gw.request<{ deleted: string }>("session.delete", { session_id: r.id })
621
- .then(() => true)
622
- .catch((e: Error) => {
623
- if (/active session/i.test(e.message)) {
624
- toast.show({ variant: "error", message: "Can't delete the active session" })
625
- return false
626
- }
627
- return io.remove(r.id)
628
- })
629
- if (!done) return
630
- home.invalidate("recentSessions")
631
- toast.show({ variant: "success", message: "Session deleted" })
632
- void load()
633
- })
634
- }, [gw, dialog, toast, load])
635
- confirmDeleteRef.current = confirmDelete
636
-
637
- const rename = useCallback(async () => {
638
- const v = live.current.visible[sel]
639
- // Rename only operates on parent rows — subagent children don't
640
- // have stable titles (usually a single-line delegate prompt) and
641
- // the ✕ affordance is already hidden for them.
642
- if (!v || v.indent) return
643
- const r = v.row
644
- const title = await openTextPrompt(dialog, {
645
- title: `Rename: ${trunc(r.title || "Untitled", 42)}`, label: "Title", initial: r.title || "",
646
- })
647
- if (title === null) return
648
- Promise.resolve()
649
- .then(() => {
650
- if (!io.rename(r.id, title)) throw new Error("not found")
651
- home.invalidate("recentSessions")
652
- // Patch in place so the row updates without a full RPC reload
653
- // (session.list is the slow path). reload still happens next r.
654
- setRows(prev => prev.map(row => row.id === r.id ? { ...row, title } : row))
655
- toast.show({ variant: "success", message: "Renamed" })
656
- })
657
- .catch((e: Error) =>
658
- toast.show({ variant: "error", message: `Rename failed: ${e.message}` }))
659
- }, [dialog, toast, sel])
660
-
661
- const count = searching ? results.length : visible.length
662
- // Stable ids — include row.id + indent flag so a row moving between
663
- // indices (because a sibling expanded above it) doesn't collide with
664
- // the previous occupant. OpenTUI's reconciler keys on this; reused
665
- // ids after a layout shift log "Anchor is the same as the node X
666
- // being inserted, skipping insertBefore" and drop rows.
667
- const rowId = (i: number) => {
668
- if (searching) return `sess-s-${results[i]?.session_id ?? i}`
669
- const v = visible[i]
670
- return v ? `sess-${v.indent ? "c" : "p"}-${v.row.id}` : `sess-empty-${i}`
671
- }
672
-
673
- const keys = useKeys()
674
- useKeyboard((key) => {
675
- if (!props.focused || dialog.stack.length > 0) return
676
- if (searching) {
677
- if (key.name === "escape") { setSearching(false); setQuery(""); setResults([]); setSearchSel(0); return }
678
- if (key.name === "backspace") return setQuery(p => p.slice(0, -1))
679
- if (key.name === "return") return rowActivate(searchSel)
680
- if (key.name === "up") return setSearchSel(p => Math.max(0, p - 1))
681
- if (key.name === "down") return setSearchSel(p => Math.min(count - 1, p + 1))
682
- if (key.raw && key.raw.length === 1 && key.raw >= " ") return setQuery(p => p + key.raw)
683
- return
684
- }
685
- const matched = handleListKey(keys, key, {
686
- count, setSel,
687
- page: Math.max(1, (vscroll.current?.viewport.height ?? 10) - 1),
688
- scrollTo: n => vscroll.current?.scrollChildIntoView(rowId(n)),
689
- onActivate: () => rowActivate(sel),
690
- onRefresh: () => { void load(); toast.show({ variant: "info", message: "Reloaded", duration: 1000 }) },
691
- onDelete: () => {
692
- const v = visible[sel]
693
- if (v && !v.indent) confirmDelete(v.row)
694
- },
695
- onSearch: () => { setSearching(true); setQuery(""); setResults([]); setSearchSel(0) },
696
- })
697
- if (matched) return
698
- if (keys.match("sessions.rename", key)) return void rename()
699
- if (keys.match("sessions.prev", key) || keys.match("sessions.next", key)) {
700
- // Walk the compression chain. continuesFrom is the ancestor
701
- // (older session this was resumed from), compressedTo is the
702
- // descendant (newer session this compressed into). Look up
703
- // lineage on demand — query is in-process and sub-ms, no
704
- // reason to cache across the small number of ←/→ presses.
705
- const v = visible[sel]
706
- if (!v) return
707
- const ln = io.lineage(v.row.id)
708
- const target = keys.match("sessions.prev", key)
709
- ? ln.continuesFrom?.id
710
- : ln.compressedTo?.id
711
- if (!target) return
712
- // Match lineage-click semantics: confirm switching (unless it's
713
- // the current session, which lineageSwitch short-circuits).
714
- lineageSwitch(target)
715
- return
716
- }
717
- })
718
-
719
- const empty = searching ? results.length === 0 && query.length > 0 : rows.length === 0
720
- // Sidebar yields at <140 on non-Chat tabs (app.tsx), so detail can
721
- // stay mounted down to the shell's own floor.
722
- const showDetailPanel = dims.width >= 120
723
-
724
- return (
725
- <box flexDirection="row" flexGrow={1}>
726
- <TabShell
727
- title={searching
728
- ? `Search Results (${results.length})`
729
- : `Sessions (${rows.length}${pending ? "…" : ""})`}
730
- hint={searching
731
- ? "↑↓ navigate Enter/click switch Esc cancel"
732
- : `↑↓ navigate ←→ lineage ${keys.print("list.activate")}/click switch ${keys.print("list.search")} search ${keys.print("sessions.rename")} rename ${keys.print("list.delete")} delete ${keys.print("list.refresh")} refresh`}
733
- error={warn || null}
734
- grow={3}
735
- >
736
- {searching ? (
737
- <box height={1} marginBottom={1}>
738
- <text>
739
- <span fg={theme.accent}>/ </span>
740
- <span fg={theme.text}>{query}</span>
741
- <span fg={theme.accent}>█</span>
742
- </text>
743
- </box>
744
- ) : null}
745
-
746
- {empty ? (
747
- // key prevents OpenTUI reconciler reusing this <box> for the
748
- // table wrapper below — it doesn't unset padding when the new
749
- // vnode omits it, so padding={2} would leak into the table.
750
- <box key="empty" flexGrow={1} padding={2}>
751
- {pending && !searching
752
- ? <Spinner color={theme.textMuted} label="loading sessions…" />
753
- : <text fg={theme.textMuted}>
754
- {searching ? "No matching sessions found" : "No sessions found"}
755
- </text>}
756
- </box>
757
- ) : (
758
- <box key="table" flexDirection="column" flexGrow={1} minWidth={0}>
759
- {searching ? <SearchHeaderRow /> : <HeaderRow />}
760
- <box height={1} />
761
- <scrollbox ref={vscroll} scrollY viewportCulling flexGrow={1}
762
- verticalScrollbarOptions={VBAR}>
763
- {searching
764
- ? results.map((r, i) => (
765
- <SearchItem key={r.session_id} id={rowId(i)} idx={i}
766
- result={r} selected={i === searchSel}
767
- onActivate={rowActivate} onHover={rowHover} />
768
- ))
769
- : visible.map((v, i) => (
770
- <Item key={`${v.row.id}-${v.indent ? "c" : "p"}`} id={rowId(i)} idx={i}
771
- row={v.row} selected={i === sel} indent={v.indent}
772
- onActivate={rowActivate} onHover={rowHover} onDelete={rowDelete} />
773
- ))}
774
- </scrollbox>
775
- </box>
776
- )}
777
- </TabShell>
778
-
779
- {showDetailPanel && searching && results[searchSel]
780
- ? <SearchDetail result={results[searchSel]} />
781
- : showDetailPanel && !searching && visible[sel]?.row
782
- ? <Detail row={visible[sel].row} lineage={io.lineage} peek={io.peek} onSwitch={lineageSwitch} />
783
- : null}
784
- </box>
785
- )
786
- })