herm-tui 1.0.0-dev.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.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -0
  3. package/package.json +82 -0
  4. package/scripts/postinstall.ts +29 -0
  5. package/src/app/gateway.tsx +83 -0
  6. package/src/app/gatewayEvents.ts +203 -0
  7. package/src/app/launch.ts +41 -0
  8. package/src/app/skin.tsx +31 -0
  9. package/src/app/spawnHistory.ts +75 -0
  10. package/src/app/tabs.ts +23 -0
  11. package/src/app/turnReducer.ts +390 -0
  12. package/src/app/useAppKeys.ts +268 -0
  13. package/src/app/useAtRefPopover.ts +99 -0
  14. package/src/app/useInputHistory.ts +66 -0
  15. package/src/app/useSession.ts +102 -0
  16. package/src/app/useSlashCommands.ts +70 -0
  17. package/src/app/useSlashPopover.ts +48 -0
  18. package/src/app.tsx +917 -0
  19. package/src/commands/slash.ts +151 -0
  20. package/src/components/avatar/AnimatedAvatar.tsx +66 -0
  21. package/src/components/avatar/eikon.ts +144 -0
  22. package/src/components/avatar/states/error.ts +1155 -0
  23. package/src/components/avatar/states/idle.ts +1155 -0
  24. package/src/components/avatar/states/index.ts +30 -0
  25. package/src/components/avatar/states/listening.ts +1155 -0
  26. package/src/components/avatar/states/speaking.ts +1155 -0
  27. package/src/components/avatar/states/thinking.ts +1155 -0
  28. package/src/components/avatar/states/working.ts +1155 -0
  29. package/src/components/chat/AtRefPopover.tsx +54 -0
  30. package/src/components/chat/CodeBlock.tsx +67 -0
  31. package/src/components/chat/Composer.tsx +347 -0
  32. package/src/components/chat/DiffBlock.tsx +116 -0
  33. package/src/components/chat/ErrorBlock.tsx +70 -0
  34. package/src/components/chat/MediaChip.tsx +114 -0
  35. package/src/components/chat/MessageItem.tsx +282 -0
  36. package/src/components/chat/MessageList.tsx +114 -0
  37. package/src/components/chat/PromptCard.tsx +359 -0
  38. package/src/components/chat/SlashPopover.tsx +158 -0
  39. package/src/components/chat/ThoughtCloud.tsx +185 -0
  40. package/src/components/chat/TypingIndicator.tsx +25 -0
  41. package/src/components/chat/tool/Subagent.tsx +75 -0
  42. package/src/components/chat/tool/frame.tsx +69 -0
  43. package/src/components/chat/tool/index.tsx +65 -0
  44. package/src/components/chat/tool/preview.ts +57 -0
  45. package/src/components/sidebar/ContextGauge.tsx +102 -0
  46. package/src/components/sidebar/Sidebar.tsx +143 -0
  47. package/src/components/tabs/TabBar.tsx +50 -0
  48. package/src/components/ui/FileLink.tsx +52 -0
  49. package/src/config/index.ts +156 -0
  50. package/src/config/lane.ts +161 -0
  51. package/src/config/models.ts +95 -0
  52. package/src/config/rules.ts +80 -0
  53. package/src/config/schema.ts +308 -0
  54. package/src/dialogs/alert.tsx +52 -0
  55. package/src/dialogs/chafa.tsx +72 -0
  56. package/src/dialogs/confirm.tsx +58 -0
  57. package/src/dialogs/curator.tsx +153 -0
  58. package/src/dialogs/eikon-picker.tsx +95 -0
  59. package/src/dialogs/help.tsx +80 -0
  60. package/src/dialogs/history.tsx +92 -0
  61. package/src/dialogs/info.tsx +115 -0
  62. package/src/dialogs/keys.tsx +170 -0
  63. package/src/dialogs/logs.tsx +42 -0
  64. package/src/dialogs/message.tsx +38 -0
  65. package/src/dialogs/model-picker.tsx +123 -0
  66. package/src/dialogs/new-profile.tsx +69 -0
  67. package/src/dialogs/new-task.tsx +103 -0
  68. package/src/dialogs/profile.tsx +55 -0
  69. package/src/dialogs/rollback.tsx +190 -0
  70. package/src/dialogs/spawn-history.tsx +80 -0
  71. package/src/dialogs/text-prompt.tsx +68 -0
  72. package/src/dialogs/theme-picker.tsx +50 -0
  73. package/src/home/index.ts +23 -0
  74. package/src/home/store.ts +267 -0
  75. package/src/index.tsx +113 -0
  76. package/src/keys/catalog.ts +115 -0
  77. package/src/keys/chord.ts +125 -0
  78. package/src/keys/conflicts.ts +48 -0
  79. package/src/keys/context.tsx +112 -0
  80. package/src/keys/index.ts +5 -0
  81. package/src/keys/list.ts +94 -0
  82. package/src/keys/oc-compat.ts +87 -0
  83. package/src/tabs/Agents.tsx +607 -0
  84. package/src/tabs/Analytics.tsx +154 -0
  85. package/src/tabs/Chat.tsx +50 -0
  86. package/src/tabs/Config.tsx +605 -0
  87. package/src/tabs/Context.tsx +599 -0
  88. package/src/tabs/Cron.tsx +294 -0
  89. package/src/tabs/Env.tsx +227 -0
  90. package/src/tabs/Kanban.tsx +367 -0
  91. package/src/tabs/Memory.tsx +294 -0
  92. package/src/tabs/Sessions.tsx +786 -0
  93. package/src/tabs/Skills.tsx +507 -0
  94. package/src/tabs/Toolsets.tsx +266 -0
  95. package/src/theme/builtin.ts +78 -0
  96. package/src/theme/context.tsx +106 -0
  97. package/src/theme/index.ts +4 -0
  98. package/src/theme/resolve.ts +134 -0
  99. package/src/theme/syntax.ts +31 -0
  100. package/src/theme/themes/aura.json +69 -0
  101. package/src/theme/themes/ayu.json +80 -0
  102. package/src/theme/themes/carbonfox.json +248 -0
  103. package/src/theme/themes/catppuccin-frappe.json +233 -0
  104. package/src/theme/themes/catppuccin-macchiato.json +233 -0
  105. package/src/theme/themes/catppuccin.json +112 -0
  106. package/src/theme/themes/cobalt2.json +228 -0
  107. package/src/theme/themes/cursor.json +249 -0
  108. package/src/theme/themes/dracula.json +219 -0
  109. package/src/theme/themes/everforest.json +241 -0
  110. package/src/theme/themes/flexoki.json +237 -0
  111. package/src/theme/themes/github.json +233 -0
  112. package/src/theme/themes/gruvbox.json +242 -0
  113. package/src/theme/themes/kanagawa.json +77 -0
  114. package/src/theme/themes/lucent-orng.json +237 -0
  115. package/src/theme/themes/material.json +235 -0
  116. package/src/theme/themes/matrix.json +77 -0
  117. package/src/theme/themes/mercury.json +252 -0
  118. package/src/theme/themes/monokai.json +221 -0
  119. package/src/theme/themes/nightowl.json +221 -0
  120. package/src/theme/themes/nord.json +223 -0
  121. package/src/theme/themes/one-dark.json +84 -0
  122. package/src/theme/themes/opencode.json +245 -0
  123. package/src/theme/themes/orng.json +249 -0
  124. package/src/theme/themes/osaka-jade.json +93 -0
  125. package/src/theme/themes/palenight.json +222 -0
  126. package/src/theme/themes/rosepine.json +234 -0
  127. package/src/theme/themes/solarized.json +223 -0
  128. package/src/theme/themes/synthwave84.json +226 -0
  129. package/src/theme/themes/tokyonight.json +243 -0
  130. package/src/theme/themes/vercel.json +245 -0
  131. package/src/theme/themes/vesper.json +218 -0
  132. package/src/theme/themes/zenburn.json +223 -0
  133. package/src/theme/types.ts +119 -0
  134. package/src/types/message.ts +97 -0
  135. package/src/ui/ChafaImage.tsx +64 -0
  136. package/src/ui/Splash.tsx +118 -0
  137. package/src/ui/borders.ts +28 -0
  138. package/src/ui/command.tsx +104 -0
  139. package/src/ui/dialog-select.tsx +164 -0
  140. package/src/ui/dialog.tsx +102 -0
  141. package/src/ui/fmt.ts +82 -0
  142. package/src/ui/kv.tsx +28 -0
  143. package/src/ui/shell.tsx +45 -0
  144. package/src/ui/spinner.tsx +59 -0
  145. package/src/ui/splash-art.ts +123 -0
  146. package/src/ui/table.tsx +117 -0
  147. package/src/ui/ticker.tsx +90 -0
  148. package/src/ui/toast.tsx +130 -0
  149. package/src/utils/categorical.ts +77 -0
  150. package/src/utils/chafa.ts +173 -0
  151. package/src/utils/clipboard.ts +67 -0
  152. package/src/utils/context-segments.ts +317 -0
  153. package/src/utils/control.ts +495 -0
  154. package/src/utils/drop.ts +25 -0
  155. package/src/utils/editor.ts +33 -0
  156. package/src/utils/fuzzy.ts +45 -0
  157. package/src/utils/gateway-client.ts +253 -0
  158. package/src/utils/gateway-types.ts +282 -0
  159. package/src/utils/git.ts +57 -0
  160. package/src/utils/hermes-analytics.ts +134 -0
  161. package/src/utils/hermes-home.ts +821 -0
  162. package/src/utils/hermes-kanban.ts +154 -0
  163. package/src/utils/hermes-profiles.ts +217 -0
  164. package/src/utils/interpolate.ts +31 -0
  165. package/src/utils/math-unicode.ts +818 -0
  166. package/src/utils/memory-activity.ts +140 -0
  167. package/src/utils/open-file.ts +13 -0
  168. package/src/utils/paths.ts +52 -0
  169. package/src/utils/perf.ts +235 -0
  170. package/src/utils/preferences.ts +150 -0
  171. package/src/utils/sessions-db.ts +396 -0
  172. package/src/utils/subagent-tree.ts +146 -0
  173. package/src/utils/terminal-reset.ts +129 -0
  174. package/src/utils/tips.ts +67 -0
  175. package/src/utils/tokens.ts +87 -0
@@ -0,0 +1,396 @@
1
+ /**
2
+ * sessions-db.ts — herm's window onto the Hermes session store.
3
+ *
4
+ * Architectural line: the Sessions tab is a **local state.db reader**.
5
+ * Stock tui_gateway covers ≈30% of what the tab needs — session.list
6
+ * returns {id, title, preview, started_at, message_count, source} and
7
+ * nothing else. There is no session.search, no lineage/children RPC,
8
+ * no arbitrary-id session.history, and session.title only retitles the
9
+ * *current* gateway session. Per herm policy we don't patch upstream,
10
+ * so everything richer than "which ids can the gateway resume" reads
11
+ * state.db directly. The gateway RPCs herm *does* use:
12
+ *
13
+ * session.list — source of truth for "resumable" (row.id is known
14
+ * to the connected gateway process)
15
+ * session.delete — preferred over direct DELETE because it refuses
16
+ * to remove the active session and cleans transcript
17
+ * files; local remove() is the fallback
18
+ *
19
+ * All query functions here share ONE readonly connection and ONE
20
+ * parent→child classification rule. Upstream owns that semantic
21
+ * (hermes_state.py:893-970); if it changes, `kind()` is the only line
22
+ * that moves.
23
+ */
24
+
25
+ import { Database, type Statement } from "bun:sqlite"
26
+ import { homedir } from "os"
27
+ import * as perf from "./perf"
28
+
29
+ const HERMES = process.env.HERMES_HOME || `${process.env.HOME || homedir()}/.hermes`
30
+ const PATH = `${HERMES}/state.db`
31
+ // Source provenance mirrors hermes-home.ts makeSource("state.db") —
32
+ // inlined to keep this module leaf (hermes-home re-exports from here).
33
+ export type Source = { file: string; relative: string; label: string }
34
+ const SRC: Source = { file: PATH, relative: "state.db", label: "state.db" }
35
+
36
+ // ─── Connection ──────────────────────────────────────────────────────
37
+ // One readonly handle, opened on first use. SQLite readonly connections
38
+ // see writes from other processes (WAL or rollback), so the gateway
39
+ // appending messages while herm holds this open is fine. Writes
40
+ // (rename/remove) open a short-lived RW handle — rare enough that
41
+ // pooling isn't worth it.
42
+
43
+ let ro: Database | null = null
44
+
45
+ /** Shared readonly handle. Null when state.db doesn't exist yet. */
46
+ const stateDb = (): Database | null => {
47
+ if (ro) return ro
48
+ try { return (ro = new Database(PATH, { readonly: true })) }
49
+ catch { return null }
50
+ }
51
+
52
+ /** Test hook — drop the cached handle so the next call reopens. */
53
+ export const resetDb = () => { ro?.close(); ro = null; stmts.clear() }
54
+
55
+ // Prepared-statement cache keyed by SQL text. db.query() already
56
+ // memoises internally, but holding our own map lets stats()/perf
57
+ // count distinct statements and makes the no-db path trivially cheap.
58
+ const stmts = new Map<string, Statement>()
59
+ const q = (sql: string): Statement | null => {
60
+ const db = stateDb()
61
+ if (!db) return null
62
+ let s = stmts.get(sql)
63
+ if (!s) stmts.set(sql, (s = db.query(sql)))
64
+ return s
65
+ }
66
+
67
+ // ─── Types ───────────────────────────────────────────────────────────
68
+
69
+ /** A row from the sessions table enriched for the list/detail view. */
70
+ export interface SessionRow {
71
+ source: Source
72
+ id: string
73
+ sessionSource: string
74
+ model: string | null
75
+ started_at: number
76
+ ended_at: number | null
77
+ end_reason: string | null
78
+ message_count: number
79
+ tool_call_count: number
80
+ input_tokens: number
81
+ output_tokens: number
82
+ cache_read_tokens: number
83
+ cache_write_tokens: number
84
+ reasoning_tokens: number
85
+ estimated_cost_usd: number | null
86
+ title: string | null
87
+ lastMessage: string | null
88
+ last_active: number | null
89
+ parent_session_id: string | null
90
+ /** Count of subagent children — see kind() === 'subagent'. */
91
+ subagent_count: number
92
+ /** Original root id when this row was tip-projected from a
93
+ * compression chain; null otherwise. */
94
+ lineage_root_id: string | null
95
+ }
96
+
97
+ export interface LineageInfo {
98
+ continuesFrom?: { id: string; title: string | null }
99
+ compressedTo?: { id: string; title: string | null }
100
+ }
101
+
102
+ export interface SessionHit {
103
+ session_id: string
104
+ snippet: string
105
+ role: string
106
+ source: string
107
+ model: string | null
108
+ started_at: number
109
+ title: string | null
110
+ }
111
+
112
+ /** One raw message row for transcript peek. content is SUBSTR-capped
113
+ * in SQL so multi-MB tool outputs don't allocate on read. */
114
+ export interface PeekMsg {
115
+ role: "user" | "assistant" | "tool" | "system"
116
+ content: string | null
117
+ tool_name: string | null
118
+ /** JSON string of tool_calls when role='assistant' and the model
119
+ * invoked tools instead of / as well as emitting content. */
120
+ tool_calls: string | null
121
+ at: number
122
+ }
123
+
124
+ // ─── parent→child classification ─────────────────────────────────────
125
+ //
126
+ // parent_session_id is overloaded across three unrelated relationships
127
+ // in hermes-agent. The ONLY discriminator is (parent.end_reason,
128
+ // child.started_at vs parent.ended_at):
129
+ //
130
+ // subagent — child started while parent was still live
131
+ // (parent.ended_at NULL OR child.started_at < it)
132
+ // continuation — parent.end_reason='compression' AND child started
133
+ // at/after parent.ended_at
134
+ // branch — parent.end_reason='branched' AND child started
135
+ // at/after parent.ended_at
136
+ //
137
+ // This mirrors hermes_state.py compression-tip walker (:893-926) and
138
+ // list_sessions_rich root filter (:956-971). Every query below derives
139
+ // its WHERE from these three predicates — change the rule here, not
140
+ // per-call. They take the child-table alias because queries variously
141
+ // see the child as the outer `s` or an inner `c`.
142
+
143
+ export type Kind = "root" | "subagent" | "continuation" | "branch"
144
+
145
+ const SUB = (c: string) => `(p.ended_at IS NULL OR ${c}.started_at < p.ended_at)`
146
+ const CONT = (c: string) => `(p.end_reason = 'compression' AND ${c}.started_at >= p.ended_at)`
147
+ const BR = (c: string) => `(p.end_reason = 'branched' AND ${c}.started_at >= p.ended_at)`
148
+
149
+ /** Classify a child session given its parent. Pure — for tests and
150
+ * any caller that already has both rows in hand. */
151
+ export const kind = (
152
+ parent: { ended_at: number | null; end_reason: string | null } | null,
153
+ child: { started_at: number },
154
+ ): Kind => {
155
+ if (!parent) return "root"
156
+ if (parent.ended_at == null || child.started_at < parent.ended_at) return "subagent"
157
+ if (parent.end_reason === "compression") return "continuation"
158
+ if (parent.end_reason === "branched") return "branch"
159
+ return "subagent"
160
+ }
161
+
162
+ // ─── Shared SQL ──────────────────────────────────────────────────────
163
+
164
+ // Column projection shared by roots()/children()/one(). Aliased `s`.
165
+ // First-user-msg, last-user-msg, last-active, and subagent_count are
166
+ // correlated subqueries — cheap at herm's DB sizes (thousands of rows)
167
+ // and keeps the outer query a plain single-table scan.
168
+ const COLS = `
169
+ s.id, s.source, s.model, s.started_at, s.ended_at, s.end_reason,
170
+ s.message_count, s.tool_call_count,
171
+ s.input_tokens, s.output_tokens,
172
+ s.cache_read_tokens, s.cache_write_tokens, s.reasoning_tokens,
173
+ s.estimated_cost_usd, s.parent_session_id,
174
+ COALESCE(s.title,
175
+ (SELECT SUBSTR(content,1,120) FROM messages
176
+ WHERE session_id = s.id AND role = 'user' ORDER BY id LIMIT 1)) AS title,
177
+ (SELECT SUBSTR(content,1,120) FROM messages
178
+ WHERE session_id = s.id AND role = 'user' ORDER BY id DESC LIMIT 1) AS lastMessage,
179
+ (SELECT MAX(timestamp) FROM messages WHERE session_id = s.id) AS last_active,
180
+ (SELECT COUNT(*) FROM sessions c
181
+ WHERE c.parent_session_id = s.id
182
+ AND (s.ended_at IS NULL OR c.started_at < s.ended_at)) AS subagent_count`
183
+
184
+ type Raw = {
185
+ id: string; source: string; model: string | null
186
+ started_at: number; ended_at: number | null; end_reason: string | null
187
+ message_count: number; tool_call_count: number
188
+ input_tokens: number; output_tokens: number
189
+ cache_read_tokens: number; cache_write_tokens: number; reasoning_tokens: number
190
+ estimated_cost_usd: number | null; parent_session_id: string | null
191
+ title: string | null; lastMessage: string | null
192
+ last_active: number | null; subagent_count: number
193
+ }
194
+
195
+ const toRow = (r: Raw, lineage: string | null = null): SessionRow => ({
196
+ source: SRC,
197
+ id: r.id,
198
+ sessionSource: r.source,
199
+ model: r.model,
200
+ started_at: r.started_at,
201
+ ended_at: r.ended_at,
202
+ end_reason: r.end_reason,
203
+ message_count: r.message_count,
204
+ tool_call_count: r.tool_call_count,
205
+ input_tokens: r.input_tokens,
206
+ output_tokens: r.output_tokens,
207
+ cache_read_tokens: r.cache_read_tokens,
208
+ cache_write_tokens: r.cache_write_tokens,
209
+ reasoning_tokens: r.reasoning_tokens,
210
+ estimated_cost_usd: r.estimated_cost_usd,
211
+ title: r.title,
212
+ lastMessage: r.lastMessage,
213
+ last_active: r.last_active,
214
+ parent_session_id: r.parent_session_id,
215
+ subagent_count: r.subagent_count,
216
+ lineage_root_id: lineage,
217
+ })
218
+
219
+ /** Fetch one session by id with the full column projection. */
220
+ const one = (id: string): Raw | null =>
221
+ (q(`SELECT ${COLS} FROM sessions s WHERE s.id = ?`)?.get(id) as Raw | undefined) ?? null
222
+
223
+ /** Single session by id, or null if missing / db unavailable. */
224
+ export const byId = (id: string): SessionRow | null => {
225
+ const r = one(id)
226
+ return r ? toRow(r) : null
227
+ }
228
+
229
+ /** Newest root TUI session that actually has messages. Target of `-c`
230
+ * and source of the splash continue-prompt title. */
231
+ export const lastReal = (): SessionRow | undefined =>
232
+ roots().find(r => r.message_count > 0 && r.sessionSource === "tui")
233
+
234
+ // ─── Readers ─────────────────────────────────────────────────────────
235
+
236
+ /** Root-level sessions, newest first, compression chains projected to
237
+ * their tip (the resumable end), with lineage_root_id recording the
238
+ * original root when projection happened. Mirrors list_sessions_rich. */
239
+ export function roots(limit = 30): SessionRow[] {
240
+ const end = perf.mark("io:sessions.roots")
241
+ try {
242
+ // Root filter: no parent, OR parent link is a branch. Subagents
243
+ // and continuations are hidden — they surface via children()/
244
+ // lineage() instead. `p`/`c` aliases satisfy SUB/CONT/BR above.
245
+ const raw = (q(
246
+ `SELECT ${COLS} FROM sessions s
247
+ WHERE s.parent_session_id IS NULL
248
+ OR EXISTS (SELECT 1 FROM sessions p
249
+ WHERE p.id = s.parent_session_id
250
+ AND ${BR("s")})
251
+ ORDER BY s.started_at DESC
252
+ LIMIT ?`,
253
+ )?.all(limit) ?? []) as Raw[]
254
+
255
+ return raw.map((r) => {
256
+ if (r.end_reason !== "compression") return toRow(r)
257
+ const tid = tip(r.id)
258
+ if (tid === r.id) return toRow(r)
259
+ const t = one(tid)
260
+ // Tip stats replace the root's, but started_at stays the root's
261
+ // so chronological list order is preserved.
262
+ return t ? { ...toRow(t, r.id), started_at: r.started_at } : toRow(r)
263
+ })
264
+ } finally { end() }
265
+ }
266
+
267
+ /** Subagent children of a session, spawn-order. Each child carries its
268
+ * own subagent_count so the tree view can recurse to N levels. */
269
+ export function children(pid: string): SessionRow[] {
270
+ const end = perf.mark("io:sessions.children")
271
+ try {
272
+ return ((q(
273
+ `SELECT ${COLS} FROM sessions s
274
+ JOIN sessions p ON p.id = s.parent_session_id
275
+ WHERE s.parent_session_id = ? AND ${SUB("s")}
276
+ ORDER BY s.started_at ASC`,
277
+ )?.all(pid) ?? []) as Raw[]).map(r => toRow(r))
278
+ } finally { end() }
279
+ }
280
+
281
+ /** Compression-chain neighbours of a session. */
282
+ export function lineage(sid: string): LineageInfo {
283
+ const end = perf.mark("io:sessions.lineage")
284
+ try {
285
+ const pred = q(
286
+ `SELECT p.id, p.title FROM sessions c
287
+ JOIN sessions p ON p.id = c.parent_session_id
288
+ WHERE c.id = ? AND ${CONT("c")}`,
289
+ )?.get(sid) as { id: string; title: string | null } | undefined
290
+ const succ = q(
291
+ `SELECT c.id, c.title FROM sessions c
292
+ JOIN sessions p ON p.id = c.parent_session_id
293
+ WHERE p.id = ? AND ${CONT("c")}
294
+ ORDER BY c.started_at DESC LIMIT 1`,
295
+ )?.get(sid) as { id: string; title: string | null } | undefined
296
+ return {
297
+ ...(pred && { continuesFrom: pred }),
298
+ ...(succ && { compressedTo: succ }),
299
+ }
300
+ } finally { end() }
301
+ }
302
+
303
+ /** Walk the compression chain forward to its live tip. Bounded at 100
304
+ * links (upstream's defensive cap). */
305
+ function tip(sid: string): string {
306
+ const step = q(
307
+ `SELECT c.id FROM sessions c
308
+ JOIN sessions p ON p.id = c.parent_session_id
309
+ WHERE p.id = ? AND ${CONT("c")}
310
+ ORDER BY c.started_at DESC LIMIT 1`,
311
+ )
312
+ let cur = sid
313
+ for (let i = 0; i < 100; i++) {
314
+ const next = step?.get(cur) as { id: string } | undefined
315
+ if (!next) return cur
316
+ cur = next.id
317
+ }
318
+ return cur
319
+ }
320
+
321
+ /** Last `n` raw message rows for a session, chronological. Content
322
+ * is SUBSTR(…,400)'d in SQL — the peek view renders one line per
323
+ * row, so anything past the first ~200 chars is wasted. Uses the
324
+ * (session_id, timestamp) index; sub-ms for any realistic n. */
325
+ export function peek(sid: string, n = 60): PeekMsg[] {
326
+ const end = perf.mark("io:sessions.peek")
327
+ try {
328
+ return ((q(
329
+ `SELECT role, SUBSTR(content,1,400) AS content, tool_name,
330
+ SUBSTR(tool_calls,1,400) AS tool_calls, timestamp AS at
331
+ FROM (SELECT * FROM messages WHERE session_id = ?
332
+ ORDER BY id DESC LIMIT ?)
333
+ ORDER BY id ASC`,
334
+ )?.all(sid, n) ?? []) as PeekMsg[])
335
+ } finally { end() }
336
+ }
337
+
338
+ // ─── Search ──────────────────────────────────────────────────────────
339
+ // FTS5 over messages_fts — same table/triggers SessionDB builds, so
340
+ // results match `hermes sessions search` and the session_search tool.
341
+
342
+ // FTS5 treats - . ( ) " as syntax. Quote non-alnum tokens as phrases;
343
+ // bare words get a * suffix so incremental typing narrows live.
344
+ const fts = (s: string): string =>
345
+ s.trim().split(/\s+/).filter(Boolean)
346
+ .map(w => /^\w+$/.test(w) ? `${w}*` : `"${w.replace(/"/g, '""')}"`)
347
+ .join(" ")
348
+
349
+ export function search(query: string, limit = 30): SessionHit[] {
350
+ const m = fts(query)
351
+ if (!m) return []
352
+ const end = perf.mark("io:sessions.search")
353
+ try {
354
+ const raw = (q(
355
+ `SELECT m.session_id, m.role,
356
+ snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet,
357
+ s.source, s.model, s.started_at,
358
+ COALESCE(s.title, SUBSTR(m.content, 1, 120)) AS title
359
+ FROM messages_fts
360
+ JOIN messages m ON m.id = messages_fts.rowid
361
+ JOIN sessions s ON s.id = m.session_id
362
+ WHERE messages_fts MATCH ?
363
+ ORDER BY rank LIMIT ?`,
364
+ )?.all(m, limit * 4) ?? []) as Array<SessionHit & { session_id: string }>
365
+ const seen = new Set<string>()
366
+ return raw.filter(r =>
367
+ !seen.has(r.session_id) && (seen.add(r.session_id), true),
368
+ ).slice(0, limit)
369
+ } finally { end() }
370
+ }
371
+
372
+ // ─── Writes ──────────────────────────────────────────────────────────
373
+ // Fresh RW handle per call — writes are rare (user-initiated) and a
374
+ // long-lived writer would hold locks the gateway's own connection
375
+ // wants. Callers should prefer the session.delete RPC and fall back
376
+ // here only when the gateway is down.
377
+
378
+ export function rename(sid: string, title: string): boolean {
379
+ const db = new Database(PATH)
380
+ try {
381
+ db.run("UPDATE sessions SET title = ? WHERE id = ?", [title, sid])
382
+ return (db.query("SELECT changes() AS c").get() as { c: number }).c > 0
383
+ } finally { db.close() }
384
+ }
385
+
386
+ /** Delete a session. Orphans children (matches upstream delete_session). */
387
+ export function remove(sid: string): boolean {
388
+ const db = new Database(PATH)
389
+ try {
390
+ if (!db.query("SELECT 1 FROM sessions WHERE id = ?").get(sid)) return false
391
+ db.run("UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?", [sid])
392
+ db.run("DELETE FROM messages WHERE session_id = ?", [sid])
393
+ db.run("DELETE FROM sessions WHERE id = ?", [sid])
394
+ return true
395
+ } finally { db.close() }
396
+ }
@@ -0,0 +1,146 @@
1
+ // Subagent tree aggregation for the Agents tab header + detail rollups.
2
+ // Ink reference: ui-tui/src/lib/subagentTree.ts. Herm's wire data is
3
+ // thinner (DelegationRecord has no tokens/cost; those arrive via
4
+ // subagent.complete into `live`), so aggregate() takes both.
5
+
6
+ import type { DelegationRecord } from "./gateway-types"
7
+
8
+ // Per-node enrichment accumulated from subagent.* push events between
9
+ // registry polls. Mirrors the `Live` shape in Agents.tsx.
10
+ export type Live = {
11
+ tool_count?: number
12
+ input_tokens?: number
13
+ output_tokens?: number
14
+ cost_usd?: number
15
+ status?: string
16
+ }
17
+
18
+ export type Agg = {
19
+ agents: number
20
+ tools: number
21
+ dur: number
22
+ tok: number
23
+ cost: number
24
+ active: number
25
+ depth: number
26
+ hot: number
27
+ }
28
+
29
+ type Node = { rec: DelegationRecord; agg: Agg; kids: Node[] }
30
+
31
+ const running = (s: string | undefined) => !s || s === "running" || s === "queued"
32
+
33
+ export function tree(
34
+ recs: readonly DelegationRecord[],
35
+ live: ReadonlyMap<string, Live>,
36
+ now: number,
37
+ ): Node[] {
38
+ const ids = new Set(recs.map(r => r.subagent_id))
39
+ const by = new Map<string, DelegationRecord[]>()
40
+ for (const r of recs) {
41
+ const k = r.parent_id && ids.has(r.parent_id) ? r.parent_id : ""
42
+ ;(by.get(k) ?? by.set(k, []).get(k)!).push(r)
43
+ }
44
+ const build = (r: DelegationRecord): Node => {
45
+ const kids = (by.get(r.subagent_id) ?? []).map(build)
46
+ const lv = live.get(r.subagent_id) ?? {}
47
+ const dur = r.started_at != null ? Math.max(0, now - r.started_at) : 0
48
+ let a: Agg = {
49
+ agents: 1,
50
+ tools: lv.tool_count ?? r.tool_count ?? 0,
51
+ dur,
52
+ tok: (lv.input_tokens ?? 0) + (lv.output_tokens ?? 0),
53
+ cost: lv.cost_usd ?? 0,
54
+ active: running(lv.status ?? r.status) ? 1 : 0,
55
+ depth: 0,
56
+ hot: 0,
57
+ }
58
+ for (const k of kids) {
59
+ a = {
60
+ agents: a.agents + k.agg.agents,
61
+ tools: a.tools + k.agg.tools,
62
+ dur: a.dur + k.agg.dur,
63
+ tok: a.tok + k.agg.tok,
64
+ cost: a.cost + k.agg.cost,
65
+ active: a.active + k.agg.active,
66
+ depth: Math.max(a.depth, k.agg.depth + 1),
67
+ hot: 0,
68
+ }
69
+ }
70
+ a.hot = a.dur > 0 ? a.tools / a.dur : 0
71
+ return { rec: r, agg: a, kids }
72
+ }
73
+ return (by.get("") ?? []).map(build)
74
+ }
75
+
76
+ export function totals(nodes: readonly Node[]): Agg {
77
+ const z: Agg = { agents: 0, tools: 0, dur: 0, tok: 0, cost: 0, active: 0, depth: 0, hot: 0 }
78
+ for (const n of nodes) {
79
+ z.agents += n.agg.agents
80
+ z.tools += n.agg.tools
81
+ z.dur += n.agg.dur
82
+ z.tok += n.agg.tok
83
+ z.cost += n.agg.cost
84
+ z.active += n.agg.active
85
+ z.depth = Math.max(z.depth, n.agg.depth + 1)
86
+ }
87
+ z.hot = z.dur > 0 ? z.tools / z.dur : 0
88
+ return z
89
+ }
90
+
91
+ const SPARK = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const
92
+
93
+ export function spark(nodes: readonly Node[]): string {
94
+ const w: number[] = []
95
+ const walk = (ns: readonly Node[], d: number) => {
96
+ if (ns.length === 0) return
97
+ w[d] = (w[d] ?? 0) + ns.length
98
+ for (const n of ns) walk(n.kids, d + 1)
99
+ }
100
+ walk(nodes, 0)
101
+ if (w.length === 0) return ""
102
+ const max = Math.max(...w)
103
+ return w.map(v => v <= 0 ? " "
104
+ : SPARK[Math.min(7, Math.ceil((v / max) * 7))]).join("")
105
+ }
106
+
107
+ const tk = (n: number) =>
108
+ n < 1000 ? String(Math.round(n))
109
+ : n < 10_000 ? `${(n / 1000).toFixed(1)}k`
110
+ : `${Math.round(n / 1000)}k`
111
+
112
+ const $$ = (n: number) =>
113
+ n <= 0 ? "" : n < 0.01 ? "<$0.01" : n < 10 ? `$${n.toFixed(2)}` : `$${n.toFixed(1)}`
114
+
115
+ const sec = (s: number) => {
116
+ if (s < 60) return `${Math.round(s)}s`
117
+ const m = Math.floor(s / 60)
118
+ const r = Math.round(s - m * 60)
119
+ return r === 0 ? `${m}m` : `${m}m${r}s`
120
+ }
121
+
122
+ /** `d2 · 7 agents · 124 tools · 2m14s · 10k tok · $0.42 · ⚡3` */
123
+ export function summary(a: Agg): string {
124
+ const p = [`d${a.depth}`, `${a.agents} agent${a.agents === 1 ? "" : "s"}`]
125
+ if (a.tools > 0) p.push(`${a.tools} tools`)
126
+ if (a.dur > 0) p.push(sec(a.dur))
127
+ if (a.tok > 0) p.push(`${tk(a.tok)} tok`)
128
+ if (a.cost > 0) p.push($$(a.cost))
129
+ if (a.active > 0) p.push(`⚡${a.active}`)
130
+ return p.join(" · ")
131
+ }
132
+
133
+ /** 0..(buckets-1) normalized against peak across all nodes. */
134
+ export function heat(hot: number, peak: number, buckets: number): number {
135
+ if (hot <= 0 || peak <= 0 || buckets <= 1) return 0
136
+ return Math.min(buckets - 1, Math.round((Math.min(1, hot / peak)) * (buckets - 1)))
137
+ }
138
+
139
+ export function peak(nodes: readonly Node[]): number {
140
+ let p = 0
141
+ const walk = (ns: readonly Node[]) => {
142
+ for (const n of ns) { p = Math.max(p, n.agg.hot); walk(n.kids) }
143
+ }
144
+ walk(nodes)
145
+ return p
146
+ }
@@ -0,0 +1,129 @@
1
+ // Reset sticky terminal modes. Ported from hermes-agent
2
+ // ui-tui/src/lib/terminalModes.ts (v0.12.0, commits d05497f81 +
3
+ // cad7944b9).
4
+ //
5
+ // Why this exists: @opentui/core only resets ?1049 (alt-screen) on
6
+ // shutdown. If a prior TUI crashed with any of the modes below still
7
+ // enabled — mouse reporting, focus events, bracketed paste, kitty
8
+ // keyboard protocol — the terminal tab poisons the next process.
9
+ // Symptoms: raw `[<35;12;8M` cursor-motion escapes dumped into the
10
+ // composer, phantom focus events, paste wrapped in [200~...[201~,
11
+ // kitty CSI-u sequences interpreted as literal input.
12
+ //
13
+ // We emit this on startup (self-heal a poisoned tab from a prior
14
+ // crashed process) and on every exit path (so our own crash doesn't
15
+ // poison the next shell prompt).
16
+
17
+ import { writeSync } from "node:fs"
18
+
19
+ export const TERMINAL_MODE_RESET =
20
+ "\x1b[0'z" + // DEC locator reporting
21
+ "\x1b[0'{" + // selectable locator events
22
+ "\x1b[?2029l" + // passive mouse
23
+ "\x1b[?1016l" + // SGR-pixels mouse
24
+ "\x1b[?1015l" + // urxvt decimal mouse
25
+ "\x1b[?1006l" + // SGR mouse
26
+ "\x1b[?1005l" + // UTF-8 extended mouse
27
+ "\x1b[?1003l" + // any-motion mouse
28
+ "\x1b[?1002l" + // button-motion mouse
29
+ "\x1b[?1001l" + // highlight mouse
30
+ "\x1b[?1000l" + // click mouse
31
+ "\x1b[?9l" + // X10 mouse
32
+ "\x1b[?1004l" + // focus events
33
+ "\x1b[?2004l" + // bracketed paste
34
+ "\x1b[?1049l" + // alternate screen
35
+ "\x1b[<u" + // kitty keyboard (pop stack)
36
+ "\x1b[>4;0m" + // modifyOtherKeys → level 0
37
+ "\x1b[0m" + // SGR attributes
38
+ "\x1b[?25h" // cursor visible
39
+
40
+ type ResettableStream = Pick<NodeJS.WriteStream, "isTTY" | "write"> & {
41
+ fd?: number
42
+ }
43
+
44
+ /**
45
+ * Synchronously emit the reset blob to `stream`.
46
+ *
47
+ * Returns true if the write succeeded. Skips non-TTY streams (piped
48
+ * stdout, test mocks) — ANSI reset on a plain pipe would corrupt
49
+ * downstream consumers.
50
+ *
51
+ * Prefers `fs.writeSync(fd, …)` over `stream.write(…)` because async
52
+ * writes don't flush when `process.exit()` terminates the loop.
53
+ * `stream.write` is the fallback for mocked streams where `fd` isn't
54
+ * a real kernel descriptor.
55
+ */
56
+ export function resetTerminalModes(stream: ResettableStream = process.stdout): boolean {
57
+ if (!stream.isTTY) return false
58
+
59
+ const fd = typeof stream.fd === "number"
60
+ ? stream.fd
61
+ : stream === process.stdout ? 1 : undefined
62
+
63
+ if (fd !== undefined) {
64
+ try {
65
+ writeSync(fd, TERMINAL_MODE_RESET)
66
+ return true
67
+ } catch {
68
+ // Fall through to stream.write for mocked or unusual TTY streams.
69
+ }
70
+ }
71
+
72
+ try {
73
+ stream.write(TERMINAL_MODE_RESET)
74
+ return true
75
+ } catch {
76
+ return false
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Wire exit-path hooks so the reset blob fires on every shutdown
82
+ * route: clean exit, signals (SIGINT/SIGTERM/SIGHUP), and uncaught
83
+ * throws. Idempotent — calling more than once is a no-op.
84
+ *
85
+ * On signals we let the process continue to exit naturally after the
86
+ * reset; we don't `process.exit(code)` ourselves because that would
87
+ * race with OpenTUI's own signal handler (which has its own
88
+ * alt-screen cleanup). The reset is synchronous stdout writeSync, so
89
+ * it always lands before the process reaps.
90
+ */
91
+ let wired = false
92
+ export function installExitResetHooks(): void {
93
+ if (wired) return
94
+ wired = true
95
+
96
+ // Normal exit — fires on process.exit() and on main() falling off.
97
+ // `exit` handler must be synchronous (node discards async work).
98
+ process.on("exit", () => { resetTerminalModes() })
99
+
100
+ // Signals. Attaching a listener suppresses node's default terminate,
101
+ // so we must exit ourselves. OpenTUI's exitHandler was registered
102
+ // after ours (createCliRenderer runs later) and also listens here —
103
+ // but all it does is destroy(), whose terminal writes our reset blob
104
+ // already covers. writeSync flushes before exit() tears the fd.
105
+ const codes = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 } as const
106
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
107
+ process.on(sig, () => {
108
+ resetTerminalModes()
109
+ process.exit(codes[sig])
110
+ })
111
+ }
112
+
113
+ // Uncaught throws. Emit reset before node prints the stack, so the
114
+ // traceback renders on a clean primary screen.
115
+ process.on("uncaughtException", err => {
116
+ resetTerminalModes()
117
+ // Re-throw via default behavior by writing + exiting after a tick.
118
+ // We print here instead of letting it reach the default handler
119
+ // because OpenTUI may have the alt-screen active and the default
120
+ // traceback would land there and disappear on exit.
121
+ console.error(err)
122
+ process.exit(1)
123
+ })
124
+ process.on("unhandledRejection", reason => {
125
+ resetTerminalModes()
126
+ console.error(reason)
127
+ process.exit(1)
128
+ })
129
+ }