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,253 @@
1
+ // Stdio JSON-RPC 2.0 client for tui_gateway. Spawns the gateway as a child
2
+ // process and speaks newline-delimited JSON on stdin/stdout.
3
+
4
+ import { EventEmitter } from "events"
5
+ import { homedir } from "os"
6
+ import { resolve, delimiter } from "path"
7
+ import { existsSync } from "fs"
8
+ import type { GatewayEvent } from "./gateway-types"
9
+
10
+ const LOG_MAX = 200
11
+ const LOG_PREVIEW = 240
12
+ const STARTUP_MS = 15_000
13
+ const REQUEST_MS = 120_000
14
+
15
+ /** Locate the hermes-agent source tree (gateway + hermes_cli live here).
16
+ * Default: ~/.hermes/hermes-agent (where `hermes update` installs it).
17
+ * Override with HERMES_AGENT_ROOT for dev clones / exotic layouts. */
18
+ export function hermesAgentRoot(): string {
19
+ if (process.env.HERMES_AGENT_ROOT) return process.env.HERMES_AGENT_ROOT
20
+ const home = process.env.HOME || homedir()
21
+ return `${home}/.hermes/hermes-agent`
22
+ }
23
+
24
+ type Pending = {
25
+ resolve: (v: unknown) => void
26
+ reject: (e: Error) => void
27
+ }
28
+
29
+ function python(root: string): string {
30
+ const env = process.env.HERMES_PYTHON?.trim()
31
+ if (env) return env
32
+
33
+ const venv = process.env.VIRTUAL_ENV?.trim()
34
+ const paths = [
35
+ venv && resolve(venv, "bin/python"),
36
+ resolve(root, "venv/bin/python"),
37
+ resolve(root, "venv/bin/python3"),
38
+ resolve(root, ".venv/bin/python"),
39
+ resolve(root, ".venv/bin/python3"),
40
+ ]
41
+ return paths.find(p => p && existsSync(p)) || "python3"
42
+ }
43
+
44
+ function asEvent(v: unknown): GatewayEvent | null {
45
+ if (v && typeof v === "object" && !Array.isArray(v) && typeof (v as { type?: unknown }).type === "string")
46
+ return v as GatewayEvent
47
+ return null
48
+ }
49
+
50
+ // Read lines from a ReadableStream (Bun subprocess stdout/stderr)
51
+ async function lines(stream: ReadableStream<Uint8Array>, cb: (line: string) => void) {
52
+ const reader = stream.getReader()
53
+ const decoder = new TextDecoder()
54
+ let buf = ""
55
+ try {
56
+ while (true) {
57
+ const { done, value } = await reader.read()
58
+ if (done) break
59
+ buf += decoder.decode(value, { stream: true })
60
+ const parts = buf.split("\n")
61
+ buf = parts.pop() || ""
62
+ for (const line of parts) {
63
+ if (line) cb(line)
64
+ }
65
+ }
66
+ // Flush remaining
67
+ if (buf.trim()) cb(buf)
68
+ } catch {
69
+ // Stream closed
70
+ }
71
+ }
72
+
73
+ export class GatewayClient extends EventEmitter {
74
+ private proc: ReturnType<typeof Bun.spawn> | null = null
75
+ private id = 0
76
+ private logs: string[] = []
77
+ private pending = new Map<string, Pending>()
78
+ private buf: GatewayEvent[] = []
79
+ private exit: number | null | undefined
80
+ private ok = false
81
+ private timer: ReturnType<typeof setTimeout> | null = null
82
+ private sub = false
83
+
84
+ private root(): string { return hermesAgentRoot() }
85
+
86
+ private push(ev: GatewayEvent) {
87
+ if (ev.type === "gateway.ready") {
88
+ this.ok = true
89
+ if (this.timer) { clearTimeout(this.timer); this.timer = null }
90
+ }
91
+ if (this.sub) return void this.emit("event", ev)
92
+ this.buf.push(ev)
93
+ }
94
+
95
+ private log(line: string) {
96
+ if (this.logs.push(line) > LOG_MAX) this.logs.splice(0, this.logs.length - LOG_MAX)
97
+ }
98
+
99
+ private dispatch(msg: Record<string, unknown>) {
100
+ const id = msg.id as string | undefined
101
+ const p = id ? this.pending.get(id) : undefined
102
+
103
+ if (p) {
104
+ this.pending.delete(id!)
105
+ if (msg.error) {
106
+ const err = msg.error as { message?: unknown }
107
+ p.reject(new Error(typeof err?.message === "string" ? err.message : "request failed"))
108
+ } else {
109
+ p.resolve(msg.result)
110
+ }
111
+ return
112
+ }
113
+
114
+ if (msg.method === "event") {
115
+ const ev = asEvent(msg.params)
116
+ if (ev) this.push(ev)
117
+ }
118
+ }
119
+
120
+ private fail(err: Error) {
121
+ for (const p of this.pending.values()) p.reject(err)
122
+ this.pending.clear()
123
+ }
124
+
125
+ start() {
126
+ const root = this.root()
127
+ const bin = python(root)
128
+ const cwd = process.env.HERMES_CWD || root
129
+ const env = { ...process.env } as Record<string, string>
130
+ const pp = env.PYTHONPATH?.trim()
131
+ env.PYTHONPATH = pp ? `${root}${delimiter}${pp}` : root
132
+
133
+ // Reset state
134
+ this.ok = false
135
+ this.buf = []
136
+ this.exit = undefined
137
+
138
+ if (this.proc) {
139
+ try { this.proc.kill() } catch {}
140
+ }
141
+
142
+ if (this.timer) clearTimeout(this.timer)
143
+ this.timer = setTimeout(() => {
144
+ if (this.ok) return
145
+ this.log(`[startup] timed out (python=${bin}, cwd=${cwd})`)
146
+ this.push({ type: "gateway.start_timeout", payload: { cwd, python: bin } })
147
+ }, STARTUP_MS)
148
+
149
+ const proc = Bun.spawn(["sh", "-c", `exec ${bin} -m tui_gateway.entry`], {
150
+ cwd,
151
+ env,
152
+ stdin: "pipe",
153
+ stdout: "pipe",
154
+ stderr: "pipe",
155
+ })
156
+ this.proc = proc
157
+
158
+ // Read stdout lines — Bun returns ReadableStream
159
+ if (this.proc.stdout) {
160
+ lines(this.proc.stdout as ReadableStream<Uint8Array>, raw => {
161
+ try {
162
+ this.dispatch(JSON.parse(raw))
163
+ } catch {
164
+ const preview = raw.trim().slice(0, LOG_PREVIEW) || "(empty)"
165
+ this.log(`[protocol] malformed: ${preview}`)
166
+ this.push({ type: "gateway.protocol_error", payload: { preview } })
167
+ }
168
+ })
169
+ }
170
+
171
+ // Read stderr lines
172
+ if (this.proc.stderr) {
173
+ lines(this.proc.stderr as ReadableStream<Uint8Array>, raw => {
174
+ const line = raw.trim()
175
+ if (!line) return
176
+ this.log(line)
177
+ this.push({ type: "gateway.stderr", payload: { line } })
178
+ })
179
+ }
180
+
181
+ // Handle exit — guard against a superseded proc (restart kills the
182
+ // old one, whose exit handler must not touch the new proc's state).
183
+ proc.exited.then(code => {
184
+ if (this.proc !== proc) return
185
+ if (this.timer) { clearTimeout(this.timer); this.timer = null }
186
+ this.fail(new Error(`gateway exited${code === null ? "" : ` (${code})`}`))
187
+ if (this.sub) this.emit("exit", code)
188
+ else this.exit = code
189
+ })
190
+ }
191
+
192
+ drain() {
193
+ if (this.sub) return
194
+ this.sub = true
195
+ for (const ev of this.buf.splice(0)) this.emit("event", ev)
196
+ if (this.exit !== undefined) {
197
+ const code = this.exit
198
+ this.exit = undefined
199
+ this.emit("exit", code)
200
+ }
201
+ }
202
+
203
+ tail(n = 20): string {
204
+ return this.logs.slice(-Math.max(1, n)).join("\n")
205
+ }
206
+
207
+ private sid = ""
208
+
209
+ /** Set the active session id; auto-injected into subsequent requests. */
210
+ setSession(sid: string) {
211
+ this.sid = sid
212
+ }
213
+
214
+ request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
215
+ if (!this.proc || this.proc.exitCode !== null) this.start()
216
+
217
+ const stdin = this.proc?.stdin
218
+ if (!stdin || typeof stdin === "number") return Promise.reject(new Error("gateway not running"))
219
+
220
+ const rid = `r${++this.id}`
221
+ const writer = stdin as { write(data: string | Uint8Array): number }
222
+ const merged = this.sid && params.session_id === undefined
223
+ ? { session_id: this.sid, ...params }
224
+ : params
225
+
226
+ return new Promise((resolve, reject) => {
227
+ const timeout = setTimeout(() => {
228
+ if (this.pending.delete(rid)) reject(new Error(`timeout: ${method}`))
229
+ }, REQUEST_MS)
230
+
231
+ this.pending.set(rid, {
232
+ reject: e => { clearTimeout(timeout); reject(e) },
233
+ resolve: v => { clearTimeout(timeout); resolve(v as T) },
234
+ })
235
+
236
+ try {
237
+ writer.write(JSON.stringify({ jsonrpc: "2.0", id: rid, method, params: merged }) + "\n")
238
+ } catch (e) {
239
+ clearTimeout(timeout)
240
+ this.pending.delete(rid)
241
+ reject(e instanceof Error ? e : new Error(String(e)))
242
+ }
243
+ })
244
+ }
245
+
246
+ kill() {
247
+ this.proc?.kill()
248
+ }
249
+
250
+ get ready(): boolean {
251
+ return this.ok
252
+ }
253
+ }
@@ -0,0 +1,282 @@
1
+ // Typed events and RPC responses for the tui_gateway JSON-RPC protocol.
2
+
3
+ import type { Usage } from "../types/message"
4
+
5
+ // ── Events (server → client) ────────────────────────────────────────
6
+
7
+ export type GatewayEvent =
8
+ | { type: "gateway.ready"; payload?: { skin?: GatewaySkin } }
9
+ | { type: "gateway.stderr"; payload: { line: string } }
10
+ | { type: "gateway.start_timeout"; payload?: { cwd?: string; python?: string } }
11
+ | { type: "gateway.protocol_error"; payload?: { preview?: string } }
12
+ | { type: "session.info"; payload: SessionInfo }
13
+ | { type: "skin.changed"; payload?: GatewaySkin }
14
+ | { type: "message.start"; payload?: undefined }
15
+ | { type: "message.delta"; payload?: { text?: string; rendered?: string } }
16
+ | { type: "message.complete"; payload?: { text?: string | null; rendered?: string; reasoning?: string; status?: "complete" | "error" | "interrupted"; usage?: Usage } }
17
+ | { type: "thinking.delta"; payload?: { text?: string } }
18
+ | { type: "reasoning.delta"; payload?: { text?: string } }
19
+ | { type: "reasoning.available"; payload?: { text?: string } }
20
+ | { type: "status.update"; payload?: { text?: string; kind?: string } }
21
+ | { type: "tool.start"; payload: { tool_id: string; name?: string; context?: string } }
22
+ | { type: "tool.progress"; payload: { name?: string; preview?: string } }
23
+ | { type: "tool.generating"; payload: { name?: string } }
24
+ | { type: "tool.complete"; payload: { tool_id: string; name?: string; summary?: string; error?: string; inline_diff?: string } }
25
+ | { type: "clarify.request"; payload: { request_id: string; question: string; choices: string[] | null } }
26
+ | { type: "approval.request"; payload: { command: string; description: string } }
27
+ | { type: "sudo.request"; payload: { request_id: string } }
28
+ | { type: "secret.request"; payload: { request_id: string; prompt: string; env_var: string } }
29
+ | { type: "background.complete"; payload: { task_id: string; text: string } }
30
+ | { type: "review.summary"; payload?: { text?: string } }
31
+ | { type: "btw.complete"; payload: { text: string } }
32
+ | { type: "browser.progress"; payload?: { message?: string; level?: "info" | "error" } }
33
+ | { type: "subagent.start"; payload: SubagentPayload }
34
+ | { type: "subagent.thinking"; payload: SubagentPayload }
35
+ | { type: "subagent.tool"; payload: SubagentPayload }
36
+ | { type: "subagent.progress"; payload: SubagentPayload }
37
+ | { type: "subagent.complete"; payload: SubagentPayload }
38
+ | { type: "error"; payload?: { message?: string } }
39
+
40
+ export type SubagentPayload = {
41
+ task_index: number
42
+ goal: string
43
+ task_count?: number
44
+ status?: "running" | "completed" | "failed" | "interrupted"
45
+ text?: string
46
+ tool_name?: string
47
+ tool_preview?: string
48
+ summary?: string
49
+ duration_seconds?: number
50
+ // Spawn-tree identity (upstream delegate_tool threads these through
51
+ // every subagent.* event). All optional — absence falls back to flat
52
+ // task_index keying.
53
+ subagent_id?: string
54
+ parent_id?: string
55
+ depth?: number
56
+ model?: string
57
+ tool_count?: number
58
+ toolsets?: string[]
59
+ // Rollups on subagent.complete
60
+ input_tokens?: number
61
+ output_tokens?: number
62
+ reasoning_tokens?: number
63
+ api_calls?: number
64
+ cost_usd?: number
65
+ files_read?: string[]
66
+ files_written?: string[]
67
+ output_tail?: Array<{ tool: string; preview: string; is_error?: boolean }>
68
+ }
69
+
70
+ // delegation.status response — list_active_subagents() snapshot plus
71
+ // scheduler flags. Records are a copy of the live registry minus the
72
+ // agent handle.
73
+ export type DelegationRecord = {
74
+ subagent_id: string
75
+ parent_id?: string | null
76
+ depth: number
77
+ goal: string
78
+ model?: string
79
+ started_at?: number
80
+ tool_count?: number
81
+ status?: string
82
+ }
83
+
84
+ export type DelegationStatus = {
85
+ active: DelegationRecord[]
86
+ paused: boolean
87
+ max_spawn_depth: number
88
+ max_concurrent_children: number
89
+ }
90
+
91
+ // spawn_tree.list index entries + spawn_tree.load payload
92
+ export type SpawnTreeEntry = {
93
+ path: string
94
+ session_id: string
95
+ label: string
96
+ count: number
97
+ started_at?: number | null
98
+ finished_at: number
99
+ }
100
+
101
+ export type SpawnTreeSnapshot = {
102
+ session_id?: string
103
+ label?: string
104
+ started_at?: number | null
105
+ finished_at?: number
106
+ subagents: SpawnSubagent[]
107
+ }
108
+
109
+ // Persisted per-subagent record — the shape we save, and the shape
110
+ // spawn_tree.load round-trips. A completed SubagentPayload superset.
111
+ export type SpawnSubagent = {
112
+ subagent_id: string
113
+ parent_id?: string | null
114
+ depth: number
115
+ goal: string
116
+ model?: string
117
+ started_at: number
118
+ finished_at?: number
119
+ tool_count: number
120
+ status: "running" | "completed" | "failed" | "interrupted"
121
+ input_tokens?: number
122
+ output_tokens?: number
123
+ cost_usd?: number
124
+ trail?: Array<{ name: string; preview?: string }>
125
+ }
126
+
127
+ export type GatewaySkin = {
128
+ name?: string
129
+ colors?: Record<string, string>
130
+ branding?: Record<string, string>
131
+ banner_hero?: string
132
+ banner_logo?: string
133
+ tool_prefix?: string
134
+ help_header?: string
135
+ }
136
+
137
+ export type McpServer = {
138
+ name: string
139
+ transport: string
140
+ tools: number
141
+ connected: boolean
142
+ error?: string
143
+ }
144
+
145
+ export type SessionInfo = {
146
+ model?: string
147
+ cwd?: string
148
+ session_id?: string
149
+ tools?: Record<string, string[]>
150
+ skills?: Record<string, string[]>
151
+ version?: string
152
+ /**
153
+ * Wire usage payload for the current session. Server builds this via
154
+ * `_get_usage(agent)` (tui_gateway/server.py:826), which extends the
155
+ * base Usage with ctx/compression fields when a ContextCompressor is
156
+ * attached — so `compressions`/`context_used`/`context_max`/
157
+ * `context_percent` may be present. Intersection type keeps both
158
+ * shapes satisfied.
159
+ */
160
+ usage?: Usage & {
161
+ context_used?: number
162
+ context_max?: number
163
+ context_percent?: number
164
+ compressions?: number
165
+ }
166
+ context_max?: number
167
+ context_used?: number
168
+ credential_warning?: string
169
+ mcp_servers?: McpServer[]
170
+ /** hermes-agent version string (e.g. "1.14.2-dev+abc123") */
171
+ release_date?: string
172
+ /** commits behind origin/main; null = unknown, 0 = up to date */
173
+ update_behind?: number | null
174
+ /** platform-appropriate update invocation */
175
+ update_command?: string
176
+ }
177
+
178
+ // ── RPC responses ───────────────────────────────────────────────────
179
+
180
+ export type SessionCreateResponse = {
181
+ session_id: string
182
+ info?: SessionInfo & { credential_warning?: string }
183
+ }
184
+
185
+ export type SessionResumeResponse = {
186
+ session_id: string
187
+ resumed?: string
188
+ messages: TranscriptMessage[]
189
+ message_count?: number
190
+ info?: SessionInfo
191
+ }
192
+
193
+ export type SessionListItem = {
194
+ id: string
195
+ title: string
196
+ preview: string
197
+ message_count: number
198
+ started_at: number
199
+ source?: string
200
+ }
201
+
202
+ export type SessionListResponse = {
203
+ sessions?: SessionListItem[]
204
+ }
205
+
206
+ export type SessionUsageResponse = {
207
+ model?: string
208
+ calls?: number
209
+ input?: number
210
+ output?: number
211
+ total?: number
212
+ cache_read?: number
213
+ cache_write?: number
214
+ cost_usd?: number
215
+ cost_status?: "estimated" | "exact"
216
+ context_used?: number
217
+ context_max?: number
218
+ context_percent?: number
219
+ compressions?: number
220
+ }
221
+
222
+ /** Content part inside a multimodal user turn — upstream stores the raw
223
+ * OpenAI content list for native-mode image routing. We only care about
224
+ * flattening the text fragments back into a string for render. */
225
+ export type ContentPart =
226
+ | { type: "text"; text: string }
227
+ | { type: "image_url"; image_url: { url: string } }
228
+ | { type: string }
229
+
230
+ export type TranscriptMessage = {
231
+ role: "user" | "assistant" | "system" | "tool"
232
+ /** Either a plain string (text-mode, assistant, system) or a list of
233
+ * OpenAI content parts (native-mode user turns with attached images). */
234
+ text?: string | ContentPart[]
235
+ name?: string
236
+ context?: string
237
+ }
238
+
239
+ export type CommandsCatalogResponse = {
240
+ categories?: Array<{ name: string; pairs?: [string, string][] }>
241
+ pairs?: [string, string][]
242
+ canon?: Record<string, string>
243
+ sub?: Record<string, string[]>
244
+ skill_count?: number
245
+ warning?: string
246
+ }
247
+
248
+ export type ConfigSetResponse = {
249
+ value?: string
250
+ info?: SessionInfo
251
+ warning?: string
252
+ history_reset?: boolean
253
+ }
254
+
255
+ export type ModelOptionsResponse = {
256
+ provider?: string
257
+ model?: string
258
+ providers?: {
259
+ slug: string
260
+ name: string
261
+ models?: string[]
262
+ total_models?: number
263
+ is_current?: boolean
264
+ warning?: string
265
+ }[]
266
+ }
267
+
268
+ export type ImageAttachResponse = {
269
+ attached: boolean
270
+ path?: string
271
+ count?: number
272
+ name?: string
273
+ width?: number
274
+ height?: number
275
+ token_estimate?: number
276
+ message?: string
277
+ }
278
+
279
+ export type DropDetectResponse =
280
+ | { matched: false }
281
+ | ({ matched: true; is_image: true; text: string } & Omit<ImageAttachResponse, "attached" | "message">)
282
+ | { matched: true; is_image: false; path: string; name: string; text: string }
@@ -0,0 +1,57 @@
1
+ // Git branch for the sidebar. One-shot resolve + fs.watch on
2
+ // `<gitdir>/HEAD` so checkout/switch is picked up without polling.
3
+ // Ink's equivalent polls every 15s; the watcher is strictly cheaper
4
+ // and fires exactly on the event that matters.
5
+
6
+ import { useEffect, useState } from "react"
7
+ import { watch, type FSWatcher } from "node:fs"
8
+
9
+ const TIMEOUT = 500
10
+
11
+ async function git(cwd: string, ...args: string[]): Promise<string | null> {
12
+ const p = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore" })
13
+ const t = setTimeout(() => p.kill(), TIMEOUT)
14
+ const out = await new Response(p.stdout).text()
15
+ clearTimeout(t)
16
+ return (await p.exited) === 0 ? out.trim() : null
17
+ }
18
+
19
+ /** Branch name for `cwd`, or null when not in a repo / detached HEAD. */
20
+ export async function branch(cwd: string): Promise<string | null> {
21
+ const b = await git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
22
+ return !b || b === "HEAD" ? null : b
23
+ }
24
+
25
+ /** Absolute .git dir for `cwd` (handles worktrees via git's own resolver). */
26
+ export async function gitdir(cwd: string): Promise<string | null> {
27
+ return git(cwd, "rev-parse", "--absolute-git-dir")
28
+ }
29
+
30
+ export function useGitBranch(cwd: string | undefined): string | null {
31
+ const [val, set] = useState<string | null>(null)
32
+
33
+ useEffect(() => {
34
+ if (!cwd) { set(null); return }
35
+ let dead = false
36
+ let w: FSWatcher | undefined
37
+ const read = () => branch(cwd).then(b => { if (!dead) set(b) })
38
+ void read()
39
+ // HEAD is rewritten (not edited in-place) on checkout — watch the
40
+ // parent dir and filter on basename so rename-into-place fires.
41
+ gitdir(cwd).then(dir => {
42
+ if (dead || !dir) return
43
+ try {
44
+ w = watch(dir, { persistent: false }, (_ev, f) => {
45
+ if (f === "HEAD") void read()
46
+ })
47
+ } catch { /* unwatchable fs */ }
48
+ })
49
+ return () => { dead = true; w?.close() }
50
+ }, [cwd])
51
+
52
+ return val
53
+ }
54
+
55
+ /** Right-ellipsise keeping the discriminating tail (feature/foo → …e/foo). */
56
+ export const rtrunc = (s: string, max: number) =>
57
+ s.length <= max ? s : "…" + s.slice(-(max - 1))