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
package/src/app.tsx ADDED
@@ -0,0 +1,917 @@
1
+ import { useRenderer, useTerminalDimensions } from "@opentui/react"
2
+ import { Profiler, useState, useEffect, useRef, useCallback, useMemo, useReducer } from "react"
3
+ import * as perf from "./utils/perf"
4
+ import * as spawnHistory from "./app/spawnHistory"
5
+ import { setBridge, enabled as controlEnabled } from "./utils/control"
6
+ import { hasInterp, interpolate } from "./utils/interpolate"
7
+ import { GatewayProvider, useGateway, useGatewayEvent, type Gateway } from "./app/gateway"
8
+ import type { GatewayEvent, SessionInfo, TranscriptMessage, ImageAttachResponse } from "./utils/gateway-types"
9
+ import type { Message } from "./types/message"
10
+ import { text as msgText } from "./types/message"
11
+ import { CLOUD_MIN } from "./components/chat/ThoughtCloud"
12
+ import type { AvatarState } from "./components/avatar/states"
13
+ import { TabBar } from "./components/tabs/TabBar"
14
+ import { Sidebar } from "./components/sidebar/Sidebar"
15
+ import { Chat } from "./tabs/Chat"
16
+ import { Context } from "./tabs/Context"
17
+ import { Sessions } from "./tabs/Sessions"
18
+ import { Agents } from "./tabs/Agents"
19
+ import { Analytics } from "./tabs/Analytics"
20
+ import { Memory } from "./tabs/Memory"
21
+ import { Skills } from "./tabs/Skills"
22
+ import { Config } from "./tabs/Config"
23
+ import { Cron } from "./tabs/Cron"
24
+ import { Toolsets } from "./tabs/Toolsets"
25
+ import { Env } from "./tabs/Env"
26
+ import { Kanban } from "./tabs/Kanban"
27
+ import type { Usage } from "./types/message"
28
+ import { copySelection, copy as clipCopy } from "./utils/clipboard"
29
+ import { ThemeProvider, useTheme } from "./theme"
30
+ import { DialogProvider, useDialog } from "./ui/dialog"
31
+ import { ToastProvider, useToast } from "./ui/toast"
32
+ import { CommandProvider, useCommand } from "./ui/command"
33
+ import { KeysProvider } from "./keys"
34
+ import { HelpDialog } from "./dialogs/help"
35
+ import { openKeys } from "./dialogs/keys"
36
+ import { openLogs } from "./dialogs/logs"
37
+ import { openThemePicker } from "./dialogs/theme-picker"
38
+ import { openModelPicker } from "./dialogs/model-picker"
39
+ import { openEikonPicker } from "./dialogs/eikon-picker"
40
+ import { openTextPrompt } from "./dialogs/text-prompt"
41
+ import { openConfirm } from "./dialogs/confirm"
42
+ import { openRollback } from "./dialogs/rollback"
43
+ import { openHistory } from "./dialogs/history"
44
+ import { openStatus, openUsage, openProfile } from "./dialogs/info"
45
+ import { openChafa } from "./dialogs/chafa"
46
+ import { Splash } from "./ui/Splash"
47
+ import { lastReal } from "./utils/sessions-db"
48
+ import { readChangelog } from "./utils/hermes-home"
49
+ import { openAlert } from "./dialogs/alert"
50
+ import { openMessage } from "./dialogs/message"
51
+ import { parseEikon, type ParsedEikon } from "./components/avatar/eikon"
52
+ import { pending as pendingPrompt, type PromptCardHandle } from "./components/chat/PromptCard"
53
+ import type { PromptWire } from "./components/chat/MessageItem"
54
+ import { resolve as resolveSlash, type SlashCommand } from "./commands/slash"
55
+ import { useSlashCommands } from "./app/useSlashCommands"
56
+ import { Composer, type ComposerHandle } from "./components/chat/Composer"
57
+ import * as preferences from "./utils/preferences"
58
+ import { turnReducer, initialTurn, transcriptToMessages } from "./app/turnReducer"
59
+ import { mapEvent } from "./app/gatewayEvents"
60
+ import { useSession } from "./app/useSession"
61
+ import { SkinProvider, deriveSkin, type SkinState } from "./app/skin"
62
+ import { useAppKeys, redraw } from "./app/useAppKeys"
63
+ import { TABS, TAB_MAX, CHAT_TAB, TAB_SLASH } from "./app/tabs"
64
+ import { activeProfileName } from "./utils/hermes-profiles"
65
+ import type { Launch } from "./app/launch"
66
+
67
+ type AppProps = { initialTheme?: string; gateway?: Gateway; launch?: Launch }
68
+
69
+ export const App = (props: AppProps) => (
70
+ <ThemeProvider initial={props.initialTheme}>
71
+ <GatewayProvider client={props.gateway}>
72
+ <ToastProvider>
73
+ <KeysProvider>
74
+ <DialogProvider>
75
+ <CommandProvider>
76
+ <AppInner launch={props.launch ?? { mode: "new" }} />
77
+ </CommandProvider>
78
+ </DialogProvider>
79
+ </KeysProvider>
80
+ </ToastProvider>
81
+ </GatewayProvider>
82
+ </ThemeProvider>
83
+ )
84
+
85
+ const AppInner = ({ launch }: { launch: Launch }) => {
86
+ const gw = useGateway()
87
+ const dialog = useDialog()
88
+ const themeCtx = useTheme()
89
+ const cmd = useCommand()
90
+ const toast = useToast()
91
+ const renderer = useRenderer()
92
+ const session = useSession()
93
+ const dims = useTerminalDimensions()
94
+
95
+ const [turn, dispatch] = useReducer(turnReducer, initialTurn)
96
+ const [ready, setReady] = useState(false)
97
+ const [sid, setSid] = useState("")
98
+ const [tab, setTab] = useState(CHAT_TAB)
99
+ const [hideSidebar, setHideSidebar] = useState(false)
100
+ const [usage, setUsage] = useState<Usage | undefined>(undefined)
101
+ const [info, setInfo] = useState<SessionInfo | null>(null)
102
+ const [title, setTitle] = useState("")
103
+ const [focusRegion, setFocusRegion] = useState<"input" | "content">("input")
104
+ const goToTab = useCallback((t: number) => {
105
+ setTab(t)
106
+ setFocusRegion(t === CHAT_TAB ? "input" : "content")
107
+ }, [])
108
+ const [status, setStatus] = useState("")
109
+ const [eikon, setEikon] = useState<ParsedEikon | undefined>(undefined)
110
+ const [queue, setQueue] = useState<string[]>([])
111
+ // ── Splash ────────────────────────────────────────────────────────
112
+ // Welcome-state chrome over an empty transcript. Composer stays live
113
+ // underneath; first send dismisses. `/splash` re-summons mid-session
114
+ // (Esc-dismissable in that case only).
115
+ const [splash, setSplash] = useState(launch.mode === "new" && launch.splash !== false)
116
+ const summoned = useRef(false)
117
+ const [composing, setComposing] = useState(false)
118
+ const splashLast = useMemo(
119
+ () => launch.mode === "new" ? lastReal() : undefined,
120
+ [launch.mode],
121
+ )
122
+ const news = useMemo(() => readChangelog()?.headline, [])
123
+ const [attachments, setAttachments] = useState<ImageAttachResponse[]>([])
124
+ const [cloudH, setCloudH] = useState(CLOUD_MIN)
125
+ const [pick, setPick] = useState<Message | undefined>(undefined)
126
+ const [skin, setSkin] = useState<SkinState>(() => deriveSkin(undefined))
127
+ const inflight = useRef(false)
128
+ // Client-side interrupt latch: flipped on Esc×2 before the gateway has
129
+ // confirmed the stop. Stream-mutation events still in the stdio pipe
130
+ // (already written by the agent thread before it saw the interrupt
131
+ // flag) are dropped until the terminal `message.complete` arrives.
132
+ const interrupted = useRef(false)
133
+ const sessionStart = useRef(Date.now())
134
+ const composer = useRef<ComposerHandle>(null)
135
+ const promptRef = useRef<PromptCardHandle>(null)
136
+ const { cmds } = useSlashCommands()
137
+ // Live ref so send() (stable for queue-drain) reads the current catalog
138
+ // without re-creating itself on every catalog refresh.
139
+ const cmdsRef = useRef(cmds); cmdsRef.current = cmds
140
+
141
+ const agentState: AvatarState = !ready
142
+ ? "error"
143
+ : turn.toolActive ? "working"
144
+ : turn.streaming && turn.hasContent ? "speaking"
145
+ : turn.streaming ? "thinking"
146
+ : "idle"
147
+
148
+ // ── Thought cloud ─────────────────────────────────────────────────
149
+ // Auto-follows the "non-text" phase of a turn: open while the model is
150
+ // reasoning or running tools (`streaming && !hasContent`), close once
151
+ // text is flowing (`hasContent`) or the turn ends. A manual force
152
+ // (avatar click, cloud click, message pin) overrides auto for the rest
153
+ // of THAT turn; the override clears on the next turn's rising edge.
154
+ // A pending inline prompt also suppresses the cloud — the overlay
155
+ // would occlude the card the user needs to answer.
156
+ const prompt = pendingPrompt(turn.messages)
157
+ const cloudAuto = turn.streaming && !turn.hasContent && !prompt
158
+ const [force, setForce] = useState<boolean | undefined>(undefined)
159
+ const cloud = !prompt && (force ?? cloudAuto)
160
+ const prevStream = useRef(turn.streaming)
161
+ useEffect(() => {
162
+ if (!prevStream.current && turn.streaming) { setForce(undefined); setPick(undefined) }
163
+ prevStream.current = turn.streaming
164
+ }, [turn.streaming])
165
+
166
+ const onPick = useCallback((m?: Message) => {
167
+ // Clicking the currently-pinned message toggles the cloud closed.
168
+ setPick(p => {
169
+ if (m && p && m.id === p.id) { setForce(false); return undefined }
170
+ setForce(!!m)
171
+ return m
172
+ })
173
+ }, [])
174
+ // Avatar click and cloud body click: toggle. Closing clears any pin so
175
+ // next open shows live state.
176
+ const onAvatar = useCallback(() => {
177
+ const next = !cloud
178
+ if (!next) setPick(undefined)
179
+ setForce(next)
180
+ }, [cloud])
181
+ const closeCloud = useCallback(() => { setForce(false); setPick(undefined) }, [])
182
+ const onEnqueue = useCallback((t: string) => setQueue(q => [...q, t]), [])
183
+ const onAttach = useCallback((r: ImageAttachResponse) => setAttachments(a => [...a, r]), [])
184
+
185
+ // ── Session reset / lifecycle ─────────────────────────────────────
186
+ const reset = useCallback(() => {
187
+ dispatch({ kind: "reset" })
188
+ setUsage(undefined)
189
+ setReady(false)
190
+ setStatus("")
191
+ setTitle("")
192
+ setAttachments([])
193
+ }, [])
194
+
195
+ const newSession = useCallback(async () => {
196
+ reset()
197
+ try { setSid(await session.create()); sessionStart.current = Date.now() }
198
+ catch {}
199
+ }, [reset, session])
200
+
201
+ const switchSession = useCallback(async (target: string) => {
202
+ reset()
203
+ setSplash(false)
204
+ goToTab(CHAT_TAB)
205
+ try {
206
+ const res = await session.resume(target)
207
+ setSid(res.id)
208
+ sessionStart.current = Date.now()
209
+ if (res.messages.length) dispatch({ kind: "load", messages: res.messages })
210
+ } catch (err) {
211
+ dispatch({ kind: "system", text: `Failed to resume: ${err instanceof Error ? err.message : String(err)}` })
212
+ }
213
+ }, [reset, session, goToTab])
214
+
215
+ // Compress wrapper — toasts on start, dispatches a transcript system
216
+ // message carrying the headline + token line from the gateway's
217
+ // summary payload on completion. Upstream emits intermediate
218
+ // status.update{kind:"compressing"} events that already feed the
219
+ // status bar via gatewayEvents.ts.
220
+ const runCompress = useCallback(async () => {
221
+ toast.show({ variant: "info", message: "Compressing session…" })
222
+ const r = await session.compress()
223
+ if (!r || !r.summary) return
224
+ const s = r.summary
225
+ if (s.noop) {
226
+ toast.show({ variant: "info",
227
+ message: s.headline ?? `No changes · ~${r.before_tokens ?? 0} tokens` })
228
+ return
229
+ }
230
+ const lines = [s.headline, s.token_line, s.note].filter(Boolean).join("\n")
231
+ if (lines) dispatch({ kind: "system", text: lines })
232
+ toast.show({ variant: "success",
233
+ message: s.headline ?? `Compressed ${r.before_messages ?? 0}→${r.after_messages ?? 0} messages` })
234
+ }, [session, toast, dispatch])
235
+
236
+ // ── Eikon avatar ──────────────────────────────────────────────────
237
+ const loadEikon = useCallback((path: string) => {
238
+ Bun.file(path).text()
239
+ .then(t => setEikon(parseEikon(t)))
240
+ .catch(() => {})
241
+ }, [])
242
+
243
+ useEffect(() => {
244
+ const path = preferences.get("eikonPath")
245
+ if (path) loadEikon(path)
246
+ }, [loadEikon])
247
+
248
+ const pickEikon = useCallback(() => {
249
+ openEikonPicker(dialog, (path) => {
250
+ preferences.set("eikonPath", path)
251
+ loadEikon(path)
252
+ })
253
+ }, [dialog, loadEikon])
254
+
255
+ // ── Title ─────────────────────────────────────────────────────────
256
+ const applyTitle = useCallback((t: string) => {
257
+ gw.request<{ title: string }>("session.title", { title: t })
258
+ .then(r => { setTitle(r.title); dispatch({ kind: "system", text: `Title: ${r.title}` }) })
259
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
260
+ }, [gw, toast])
261
+
262
+ const editTitle = useCallback(() => {
263
+ openTextPrompt(dialog, { title: "Session Title", initial: title })
264
+ .then(v => { if (v) applyTitle(v) })
265
+ }, [dialog, title, applyTitle])
266
+
267
+ // ── Message actions ───────────────────────────────────────────────
268
+ // turnsFrom counts user turns at-or-after m — each session.undo pops
269
+ // one user+assistant pair server-side.
270
+ const turnsFrom = (m: Message) => {
271
+ const at = turn.messages.findIndex(x => x.id === m.id)
272
+ return at < 0 ? 0 : turn.messages.slice(at).filter(x => x.role === "user").length
273
+ }
274
+
275
+ const rewind = useCallback(async (m: Message) => {
276
+ if (turn.streaming) return
277
+ const n = turnsFrom(m)
278
+ if (n === 0) return
279
+ const text = m.parts.filter(p => p.type === "text").map(p => p.content).join("")
280
+ for (let i = 0; i < n; i++) await gw.request("session.undo").catch(() => {})
281
+ const r = await gw.request<{ messages: TranscriptMessage[] }>("session.history").catch(() => null)
282
+ const at = turn.messages.findIndex(x => x.id === m.id)
283
+ dispatch({ kind: "load", messages: r ? transcriptToMessages(r.messages ?? []) : turn.messages.slice(0, at) })
284
+ composer.current?.set(text)
285
+ setFocusRegion("input")
286
+ }, [turn.streaming, turn.messages, gw])
287
+
288
+ // Non-destructive: session.branch clones full history into a new
289
+ // gateway session; undo N turns *in that session* to land at m;
290
+ // then switch. Original session is untouched.
291
+ const fork = useCallback(async (m: Message) => {
292
+ if (turn.streaming) return
293
+ const n = turnsFrom(m)
294
+ const text = m.parts.filter(p => p.type === "text").map(p => p.content).join("")
295
+ const res = await gw.request<{ session_id: string; title?: string }>("session.branch", {})
296
+ .catch((e: Error) => { toast.show({ variant: "error", message: `branch failed: ${e.message}` }); return null })
297
+ if (!res?.session_id) return
298
+ for (let i = 0; i < n; i++)
299
+ await gw.request("session.undo", { session_id: res.session_id }).catch(() => {})
300
+ await switchSession(res.session_id)
301
+ composer.current?.set(text)
302
+ setFocusRegion("input")
303
+ toast.show({ variant: "success", message: `forked → ${res.title ?? res.session_id}` })
304
+ }, [turn.streaming, turn.messages, gw, toast, switchSession])
305
+
306
+ const msgMenu = useCallback((m: Message) => {
307
+ if (turn.streaming) return
308
+ openMessage(dialog, m, { rewind, fork })
309
+ }, [turn.streaming, dialog, rewind, fork])
310
+
311
+ // ── Attachments ───────────────────────────────────────────────────
312
+ // Gateway owns the canonical list (session["attached_images"]); chips
313
+ // are a client-side mirror. prompt.submit drains server-side, so clear
314
+ // here too. No image.detach RPC yet — chips are display-only.
315
+ const attachClipboard = useCallback(() => {
316
+ gw.request<ImageAttachResponse>("clipboard.paste")
317
+ .then(r => r.attached
318
+ ? setAttachments(a => [...a, r])
319
+ : toast.show({ variant: "info", message: r.message ?? "No image in clipboard" }))
320
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
321
+ }, [gw, toast])
322
+
323
+ // ── Slash dispatch ────────────────────────────────────────────────
324
+ // `slash` and `send` reference each other (skill/alias dispatch needs
325
+ // to submit a turn; typed `/cmd` in send() resolves via slash). The
326
+ // cycle is broken with a forward ref — same shape as upstream Ink's
327
+ // slashRef/submitRef pair.
328
+ const sendRef = useRef<(raw: string) => void>(() => {})
329
+ const slash = useCallback((c: SlashCommand, arg = "") => {
330
+ if (c.target === "local") {
331
+ switch (c.name) {
332
+ case "clear": dispatch({ kind: "reset" }); return
333
+ case "new": newSession(); return
334
+ case "theme": openThemePicker(dialog, themeCtx); return
335
+ case "help": dialog.replace(<HelpDialog />); return
336
+ case "keys": openKeys(dialog); return
337
+ case "logs": openLogs(dialog); return
338
+ case "eikon": pickEikon(); return
339
+ case "title": arg ? applyTitle(arg) : editTitle(); return
340
+ case "rollback": openRollback(dialog, gw, toast); return
341
+ case "history": openHistory(dialog, gw); return
342
+ case "status": openStatus(dialog, info, sid); return
343
+ case "usage": openUsage(dialog, gw); return
344
+ case "profile": openProfile(dialog); return
345
+ case "chafa":
346
+ if (!arg.trim()) { toast.show({ variant: "info", message: "usage: /chafa <path>" }); return }
347
+ openChafa(dialog, arg.trim())
348
+ return
349
+ case "splash": summoned.current = true; setSplash(true); return
350
+ // ── parity: session-mutating (slash-worker can't service these) ──
351
+ case "resume":
352
+ if (arg) { void switchSession(arg); return }
353
+ goToTab(TAB_SLASH.sessions); return
354
+ case "branch":
355
+ session.branch(arg || undefined).then(id => id
356
+ ? void switchSession(id)
357
+ : toast.show({ variant: "error", message: "branch failed" }))
358
+ return
359
+ case "compress": void runCompress(); return
360
+ case "undo":
361
+ session.undo().then(() =>
362
+ gw.request<{ messages: TranscriptMessage[] }>("session.history")
363
+ .then(r => dispatch({ kind: "load", messages: transcriptToMessages(r.messages ?? []) }))
364
+ .catch(() => {}))
365
+ return
366
+ case "retry": {
367
+ const last = [...turn.messages].reverse().find(m => m.role === "user")
368
+ if (!last) { toast.show({ variant: "info", message: "nothing to retry" }); return }
369
+ void rewind(last).then(() => sendRef.current(msgText(last)))
370
+ return
371
+ }
372
+ case "model":
373
+ if (!arg) { openModelPicker(dialog, gw); return }
374
+ gw.request<{ value?: string; warning?: string }>("config.set",
375
+ { key: "model", value: arg })
376
+ .then(r => {
377
+ if (r.warning) toast.show({ variant: "warning", message: r.warning })
378
+ dispatch({ kind: "system", text: `model → ${r.value ?? arg}` })
379
+ })
380
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
381
+ return
382
+ case "quit": renderer.destroy(); process.exit(0); return
383
+ case "queue":
384
+ if (!arg) { dispatch({ kind: "system", text: `${queue.length} queued` }); return }
385
+ setQueue(q => [...q, arg]); return
386
+ case "copy": {
387
+ const all = turn.messages.filter(m => m.role === "assistant")
388
+ const n = arg ? Math.min(Math.max(1, parseInt(arg, 10) || 0), all.length) : all.length
389
+ const m = all[n - 1]
390
+ if (!m) { toast.show({ variant: "info", message: "nothing to copy" }); return }
391
+ const body = msgText(m)
392
+ void clipCopy(body)
393
+ toast.show({ variant: "success", message: `copied ${body.length} chars` })
394
+ return
395
+ }
396
+ case "paste": attachClipboard(); return
397
+ case "image":
398
+ if (!arg) { toast.show({ variant: "info", message: "usage: /image <path>" }); return }
399
+ gw.request<ImageAttachResponse>("image.attach", { path: arg })
400
+ .then(r => r.attached
401
+ ? setAttachments(a => [...a, r])
402
+ : toast.show({ variant: "warning", message: r.message ?? "attach failed" }))
403
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
404
+ return
405
+ case "background":
406
+ if (!arg) { toast.show({ variant: "info", message: "usage: /background <prompt>" }); return }
407
+ gw.request<{ task_id?: string }>("prompt.background", { text: arg })
408
+ .then(r => toast.show(r.task_id
409
+ ? { variant: "success", message: `background ${r.task_id} started` }
410
+ : { variant: "error", message: "background start failed" }))
411
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
412
+ return
413
+ case "voice":
414
+ gw.request<{ enabled?: boolean; tts?: boolean }>("voice.toggle",
415
+ { action: (arg || "status").toLowerCase() })
416
+ .then(r => dispatch({ kind: "system",
417
+ text: `voice ${r.enabled ? "on" : "off"}${r.tts ? " · tts on" : ""}` }))
418
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
419
+ return
420
+ case "mouse": {
421
+ const want = arg === "on" ? true : arg === "off" ? false : !renderer.useMouse
422
+ renderer.useMouse = want
423
+ preferences.set("mouse", want)
424
+ toast.show({ variant: "info", message: `mouse ${want ? "on" : "off"}` })
425
+ return
426
+ }
427
+ case "redraw": redraw(renderer); return
428
+ case "compact":
429
+ case "setup":
430
+ dispatch({ kind: "system",
431
+ text: `/${c.name} is an Ink-TUI command and has no effect in herm` })
432
+ return
433
+ case "steer": {
434
+ const fire = (text: string) =>
435
+ gw.request<{ accepted: boolean }>("session.steer", { text })
436
+ .then(r => toast.show(r.accepted
437
+ ? { variant: "success", message: "Queued — lands on next tool result" }
438
+ : { variant: "info", message: "No turn running; send as a normal message" }))
439
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
440
+ if (arg) { void fire(arg); return }
441
+ openTextPrompt(dialog, { title: "Steer", label: "Note to inject on next tool result" })
442
+ .then(text => { if (text) void fire(text) })
443
+ return
444
+ }
445
+ case "reload-mcp": {
446
+ // Reloading MCP invalidates prompt cache (tool schemas are baked into
447
+ // the system prompt), so the next turn re-sends full input tokens.
448
+ // `now`/`always` args skip our dialog for muscle-memory users.
449
+ // Gateway-side `status:confirm_required` is still handled for
450
+ // defense-in-depth — in practice we pre-empt it by passing confirm.
451
+ const a = arg.trim().toLowerCase()
452
+ const skip = a === "now" || a === "once" || a === "approve" || a === "yes" || a === "always"
453
+ const fire = (always: boolean) =>
454
+ gw.request<{ status?: string; message?: string }>("reload.mcp", { confirm: true, always })
455
+ .then(r => r.status === "confirm_required"
456
+ ? toast.show({ variant: "warning", message: r.message ?? "reload requires confirmation" })
457
+ : toast.show({ variant: "success", message: always
458
+ ? "MCP servers reloaded · future /reload-mcp runs silently"
459
+ : "MCP servers reloaded" }))
460
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
461
+ if (skip) { void fire(a === "always"); return }
462
+ void openConfirm(dialog, {
463
+ title: "Reload MCP servers?",
464
+ body: "Rebuilds the MCP tool set. Invalidates the prompt cache, so the next message re-sends full input tokens.",
465
+ yes: "reload", danger: true,
466
+ }).then(ok => { if (ok) void fire(false) })
467
+ return
468
+ }
469
+ case "reload":
470
+ gw.request<{ updated?: number }>("reload.env", {})
471
+ .then(r => {
472
+ const n = Number(r.updated ?? 0)
473
+ toast.show({ variant: "success",
474
+ message: `Reloaded .env (${n} var${n === 1 ? "" : "s"} updated) · /new to apply` })
475
+ })
476
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
477
+ return
478
+ case "save":
479
+ gw.request<{ file: string }>("session.save")
480
+ .then(r => toast.show({ variant: "success", message: `Saved → ${r.file}` }))
481
+ .catch((e: Error) => toast.show({ variant: "error", message: e.message }))
482
+ return
483
+ }
484
+ }
485
+ if (c.target !== "gateway" || !ready || turn.streaming) return
486
+ const jump = TAB_SLASH[c.name]
487
+ if (jump !== undefined && !arg) { goToTab(jump); return }
488
+ // slash.exec runs in a persistent HermesCLI subprocess; commands that
489
+ // it rejects (skills, quick_commands, plugins, pending-input cmds)
490
+ // fall through to command.dispatch, which returns a typed payload.
491
+ // Upstream Ink does the same (see createSlashHandler.ts).
492
+ const full = `/${c.name}${arg ? " " + arg : ""}`
493
+ dispatch({ kind: "user", text: full })
494
+ gw.request<{ output?: string; warning?: string }>("slash.exec", { command: full })
495
+ .then(res => {
496
+ if (res?.warning) dispatch({ kind: "system", text: `⚠ ${res.warning}` })
497
+ if (res?.output) dispatch({ kind: "system", text: res.output })
498
+ })
499
+ .catch(() => {
500
+ type Dispatch = { type?: string; output?: string; target?: string; message?: string; name?: string }
501
+ gw.request<Dispatch>("command.dispatch", { name: c.name, arg })
502
+ .then(d => {
503
+ if (d.type === "exec" || d.type === "plugin")
504
+ return dispatch({ kind: "system", text: d.output || "(no output)" })
505
+ if (d.type === "alias" && d.target)
506
+ return void sendRef.current(`/${d.target}${arg ? " " + arg : ""}`)
507
+ if ((d.type === "skill" || d.type === "send") && d.message) {
508
+ if (d.type === "skill")
509
+ dispatch({ kind: "system", text: `⚡ loading skill: ${d.name ?? c.name}` })
510
+ return void sendRef.current(d.message)
511
+ }
512
+ dispatch({ kind: "system", text: `/${c.name}: unknown` })
513
+ })
514
+ .catch((e: Error) => dispatch({ kind: "system", text: `error: ${e.message}` }))
515
+ })
516
+ }, [ready, turn.streaming, turn.messages, dialog, themeCtx, newSession, gw, pickEikon, editTitle,
517
+ applyTitle, toast, info, sid, switchSession, session, runCompress, rewind, renderer,
518
+ attachClipboard, goToTab, queue.length])
519
+
520
+ // ── Send ──────────────────────────────────────────────────────────
521
+ const send = useCallback(async (raw: string) => {
522
+ // Slash-shaped input resolves against the merged catalog: exact
523
+ // name/alias wins, else unique prefix. This covers the "typed with
524
+ // arg" path the popover can't — e.g. `/mod gpt-4`, `/q follow-up`.
525
+ // Unknown `/xxx` falls through to prompt.submit verbatim (lets the
526
+ // agent interpret paths like `/etc/hosts`).
527
+ const m = raw.match(/^\/(\S+)(?:\s+([\s\S]*))?$/)
528
+ if (m) {
529
+ const [, name, arg = ""] = m
530
+ const r = resolveSlash(cmdsRef.current, name)
531
+ if ("hit" in r) return slash(r.hit, arg.trim())
532
+ if ("ambiguous" in r) {
533
+ const head = r.ambiguous.slice(0, 6).join(", ")
534
+ return dispatch({
535
+ kind: "system",
536
+ text: `ambiguous: /${name} → ${head}${r.ambiguous.length > 6 ? ", …" : ""}`,
537
+ })
538
+ }
539
+ }
540
+ // {!cmd} spans resolve via shell.exec before submit so the
541
+ // transcript shows what was actually sent. The await is short
542
+ // (gateway-side 30s cap); status line signals the wait.
543
+ let text = raw
544
+ if (hasInterp(raw)) {
545
+ setStatus("interpolating…")
546
+ text = await interpolate(gw, raw)
547
+ setStatus("")
548
+ }
549
+ interrupted.current = false
550
+ // Echo attachments into the user's transcript message as MEDIA: lines
551
+ // so ChafaImage renders them inline. Gateway also tracks them in
552
+ // session["attached_images"] for the agent-side enrichment — these
553
+ // are display only, the path in the chip is what the agent sees.
554
+ // The wire stays `text` (not `withMedia`) so the gateway's text-mode
555
+ // image routing doesn't collide with an explicit MEDIA: duplicate
556
+ // and so the persisted user row doesn't drag the analysis block
557
+ // into view on resume. Parity with Ink: live preview is ours, the
558
+ // resume view falls back to whatever upstream persisted.
559
+ const withMedia = attachments.length
560
+ ? [...attachments.flatMap(a => a.path ? [`MEDIA:${a.path}`] : []), text].filter(Boolean).join("\n")
561
+ : text
562
+ dispatch({ kind: "user", text: withMedia })
563
+ setAttachments([])
564
+ gw.request("prompt.submit", { text }).catch(() => { inflight.current = false })
565
+ setTab(CHAT_TAB)
566
+ }, [gw, slash, attachments])
567
+ sendRef.current = send
568
+
569
+ // Dismiss-on-send wrapper. Also the single gate for the splash's
570
+ // "continue last?" prompt: empty-Enter while it's visible resumes
571
+ // lastReal via the existing switchSession path.
572
+ const onSend = useCallback((raw: string) => { setSplash(false); return send(raw) }, [send])
573
+ const onEmptyEnter = useCallback(() => {
574
+ if (!splash || summoned.current || !splashLast || composing) return false
575
+ setSplash(false)
576
+ void switchSession(splashLast.id)
577
+ return true
578
+ }, [splash, splashLast, composing, switchSession])
579
+
580
+ // ── Queue drain ───────────────────────────────────────────────────
581
+ // Purely client-side: prompts typed while streaming accumulate in
582
+ // `queue`; on idle the head auto-submits. turnReducer doesn't flip
583
+ // `streaming` until the gateway emits message.start (async), so a
584
+ // naive effect would fire repeatedly and drain the whole queue in
585
+ // one tick. `inflight` bridges the dispatch→message.start gap.
586
+ useEffect(() => { if (turn.streaming) inflight.current = false }, [turn.streaming])
587
+ useEffect(() => {
588
+ if (turn.streaming || inflight.current || !ready || queue.length === 0) return
589
+ const [head, ...rest] = queue
590
+ inflight.current = true
591
+ setQueue(rest)
592
+ send(head)
593
+ }, [turn.streaming, ready, queue, send])
594
+
595
+ const dequeue = useCallback((i: number) => {
596
+ const item = queue[i]
597
+ if (item === undefined) return
598
+ setQueue(q => q.filter((_, j) => j !== i))
599
+ composer.current?.set(item)
600
+ setFocusRegion("input")
601
+ }, [queue])
602
+
603
+ // ── Copy last assistant ───────────────────────────────────────────
604
+ const copyLast = useCallback(() => {
605
+ for (let i = turn.messages.length - 1; i >= 0; i--) {
606
+ const m = turn.messages[i]
607
+ if (m.role !== "assistant") continue
608
+ const text = m.parts.filter(p => p.type === "text").map(p => p.content).join("")
609
+ if (!text) continue
610
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`)
611
+ return true
612
+ }
613
+ return false
614
+ }, [turn.messages])
615
+
616
+ // ── Gateway events ────────────────────────────────────────────────
617
+ // Delta batching: streamed text/reasoning chunks are accumulated in
618
+ // a ref and flushed at most once per 16ms. Every delta otherwise
619
+ // triggers an O(messages) array spread + O(content) string concat +
620
+ // full markdown re-parse of the streaming block. Any non-delta
621
+ // action flushes synchronously first so part ordering is preserved.
622
+ const deltas = useRef({ text: "", think: "", timer: null as ReturnType<typeof setTimeout> | null })
623
+
624
+ const flush = useCallback(() => {
625
+ const d = deltas.current
626
+ if (d.timer) { clearTimeout(d.timer); d.timer = null }
627
+ if (d.think) { dispatch({ kind: "thinking", text: d.think, final: false }); d.think = "" }
628
+ if (d.text) { dispatch({ kind: "message.delta", chunk: d.text }); d.text = "" }
629
+ }, [])
630
+
631
+ // Events that mutate the in-progress assistant turn. Everything else
632
+ // (system messages, session.info, toasts, completion, side channels)
633
+ // is orthogonal to the stream and must pass the interrupt gate.
634
+ const STREAM_EVENTS = useRef(new Set<GatewayEvent["type"]>([
635
+ "message.delta", "reasoning.delta", "reasoning.available", "thinking.delta",
636
+ "tool.start", "tool.progress", "tool.generating",
637
+ ])).current
638
+
639
+ const handle = useCallback((ev: GatewayEvent) => {
640
+ if (interrupted.current && STREAM_EVENTS.has(ev.type)) return
641
+ const action = mapEvent(ev, {
642
+ onReady: () => {
643
+ session.boot(launch).then((r) => {
644
+ setSid(r.id)
645
+ sessionStart.current = Date.now()
646
+ if (r.messages.length) dispatch({ kind: "load", messages: r.messages })
647
+ if (r.note) toast.show({ variant: "info", message: r.note })
648
+ })
649
+ },
650
+ onSessionInfo: (si) => {
651
+ setInfo(si)
652
+ setReady(true)
653
+ if (si.session_id) setSid(si.session_id)
654
+ const bad = (si.mcp_servers ?? []).filter(s => !s.connected)
655
+ if (bad.length) dispatch({
656
+ kind: "system",
657
+ text: `MCP: ${bad.length} server(s) failed to connect — ${bad.map(s => s.name + (s.error ? ` (${s.error})` : "")).join(", ")}`,
658
+ })
659
+ gw.request<{ title: string; session_key?: string }>("session.title").then(r => {
660
+ setTitle(r.title ?? "")
661
+ if (r.session_key) preferences.set("lastSessionId", r.session_key)
662
+ }).catch(() => {})
663
+ },
664
+ onUsage: (u) => setUsage(u),
665
+ onTurnComplete: () => {
666
+ interrupted.current = false
667
+ setStatus("")
668
+ spawnHistory.flush(gw, sid)
669
+ },
670
+ onBackground: (tid, text) => {
671
+ const head = text.split("\n")[0].slice(0, 80)
672
+ dispatch({ kind: "system", text: `◷ background task ${tid} complete — ${head}` })
673
+ toast.show({
674
+ variant: "info", title: "Background task complete", message: head,
675
+ duration: 8000,
676
+ action: { label: "view", run: () => openAlert(dialog, `Background task ${tid}`, text) },
677
+ })
678
+ },
679
+ onBtw: (text) => {
680
+ const head = text.split("\n")[0].slice(0, 80)
681
+ dispatch({ kind: "system", text: `◈ btw — ${head}` })
682
+ toast.show({
683
+ variant: "info", title: "btw", message: head, duration: 8000,
684
+ action: { label: "view", run: () => openAlert(dialog, "btw", text) },
685
+ })
686
+ },
687
+ onStatus: (text) => setStatus(text),
688
+ onSkin: (s) => setSkin(deriveSkin(s)),
689
+ })
690
+ if (!action) return
691
+ const d = deltas.current
692
+ if (action.kind === "message.delta") {
693
+ if (d.think) flush()
694
+ d.text += action.chunk
695
+ d.timer ??= setTimeout(flush, 16)
696
+ return
697
+ }
698
+ if (action.kind === "thinking" && !action.final) {
699
+ if (d.text) flush()
700
+ d.think += action.text
701
+ d.timer ??= setTimeout(flush, 16)
702
+ return
703
+ }
704
+ flush()
705
+ dispatch(action)
706
+ }, [session, dialog, toast, gw, flush, launch])
707
+
708
+ useGatewayEvent(handle)
709
+
710
+ // ── Command palette ───────────────────────────────────────────────
711
+ useEffect(() => cmd.register([
712
+ { title: "Help", value: "help", action: "help.open", category: "General",
713
+ onSelect: () => dialog.replace(<HelpDialog />) },
714
+ { title: "Keybindings", value: "keys", description: "View & rebind shortcuts", category: "General",
715
+ onSelect: () => openKeys(dialog) },
716
+ { title: "Gateway Logs", value: "logs", description: "Show gateway stderr", category: "General",
717
+ onSelect: () => openLogs(dialog) },
718
+ { title: "Switch Theme", value: "theme", action: "theme.pick", category: "General",
719
+ onSelect: () => openThemePicker(dialog, themeCtx) },
720
+ { title: "Switch Model", value: "model", action: "model.pick", category: "General",
721
+ onSelect: () => openModelPicker(dialog, gw) },
722
+ { title: "Pick Avatar", value: "eikon", description: "Choose sidebar .eikon avatar", category: "General",
723
+ onSelect: () => pickEikon() },
724
+ { title: "Rollback", value: "rollback", description: "Browse & restore checkpoints", category: "Session",
725
+ onSelect: () => openRollback(dialog, gw, toast) },
726
+ { title: "History", value: "history", action: "session.timeline", category: "Session",
727
+ onSelect: () => openHistory(dialog, gw) },
728
+ { title: "Status", value: "status", action: "status.open", category: "Info",
729
+ onSelect: () => openStatus(dialog, info, sid) },
730
+ { title: "Usage", value: "usage", description: "Tokens · context · cost", category: "Info",
731
+ onSelect: () => openUsage(dialog, gw) },
732
+ { title: "Profile", value: "profile", description: "Active profile details", category: "Info",
733
+ onSelect: () => openProfile(dialog) },
734
+ { title: "New Session", value: "new-session", action: "session.new", category: "Session",
735
+ onSelect: () => newSession() },
736
+ { title: "Compress Session", value: "compress", action: "session.compress", category: "Session",
737
+ onSelect: () => runCompress() },
738
+ { title: "Undo Last Turn", value: "undo", action: "session.undo", category: "Session",
739
+ onSelect: () => session.undo() },
740
+ { title: "Branch Session", value: "branch", description: "Fork the current conversation", category: "Session",
741
+ onSelect: () => session.branch() },
742
+ ]), [cmd, dialog, themeCtx, session, gw, toast, newSession, pickEikon, info, sid, runCompress])
743
+
744
+ // ── Keyboard ──────────────────────────────────────────────────────
745
+ useAppKeys({
746
+ tab, tabMax: TAB_MAX, chatTab: CHAT_TAB, setTab, focusRegion, setFocusRegion,
747
+ streaming: turn.streaming,
748
+ dialogOpen: dialog.stack.length > 0,
749
+ composer,
750
+ // Route keys to the pending inline prompt card before anything
751
+ // else. Card returns true when the key was consumed; the shell
752
+ // then stopPropagates so the composer textarea doesn't see it.
753
+ // promptRef is null when no card is pending (Outcome rows don't
754
+ // take the ref), so feed short-circuits.
755
+ onPromptKey: (k) => promptRef.current?.feed(k) ?? false,
756
+ onEscape: () => {
757
+ if (!splash || !summoned.current) return false
758
+ setSplash(false); summoned.current = false
759
+ return true
760
+ },
761
+ onInterrupt: () => {
762
+ interrupted.current = true
763
+ // Drop any 16ms-batched deltas that haven't hit the reducer yet —
764
+ // flushing them would append post-interrupt text.
765
+ const d = deltas.current
766
+ if (d.timer) { clearTimeout(d.timer); d.timer = null }
767
+ d.text = ""; d.think = ""
768
+ session.interrupt()
769
+ },
770
+ onInterruptNotice: () => dispatch({ kind: "interrupt.notice", text: "Press Escape again to interrupt" }),
771
+ onCopyLast: () => { copyLast() },
772
+ onAttachClipboard: attachClipboard,
773
+ // Client-side drop only. Gateway's session["attached_images"] still
774
+ // has the orphaned path until the next prompt.submit drains it, or
775
+ // session reset clears it — the side channel is write-only from here.
776
+ onDetachLast: () => {
777
+ if (attachments.length === 0) return false
778
+ setAttachments(a => a.slice(0, -1))
779
+ return true
780
+ },
781
+ onNotice: (text) => dispatch({ kind: "system", text }),
782
+ onToggleSidebar: () => setHideSidebar(v => !v),
783
+ })
784
+
785
+ // ── Control bridge ────────────────────────────────────────────────
786
+ const state = useRef({ tab, ready, streaming: turn.streaming, messages: turn.messages, sid, focusRegion })
787
+ state.current = { tab, ready, streaming: turn.streaming, messages: turn.messages, sid, focusRegion }
788
+ useEffect(() => {
789
+ if (!controlEnabled) return
790
+ setBridge({
791
+ tab: () => state.current.tab,
792
+ setTab,
793
+ send: (msg: string) => {
794
+ if (!state.current.ready || state.current.streaming) return
795
+ dispatch({ kind: "user", text: msg })
796
+ gw.request("prompt.submit", { text: msg }).catch(() => {})
797
+ setTab(CHAT_TAB)
798
+ },
799
+ ready: () => state.current.ready,
800
+ streaming: () => state.current.streaming,
801
+ messages: () => state.current.messages.length,
802
+ session: () => state.current.sid,
803
+ input: () => composer.current?.value() ?? "",
804
+ setInput: (v: string) => composer.current?.set(v),
805
+ focusRegion: () => state.current.focusRegion,
806
+ setFocusRegion,
807
+ renderer: () => renderer,
808
+ logs: (n?: number) => gw.tail(n),
809
+ })
810
+ }, [gw, renderer])
811
+
812
+ const contentFocused = focusRegion === "content" && !turn.streaming
813
+
814
+ // ── Inline prompt wiring ──────────────────────────────────────────
815
+ // At most one pending prompt (gateway blocks on the answer). The
816
+ // card mounts inside MessageList; key routing and composer-defocus
817
+ // live here because the shell owns both. `prompt` is computed above
818
+ // (before `cloud`) because a pending prompt also suppresses the
819
+ // ThoughtCloud overlay.
820
+ const promptAnswer = useCallback((id: string, label: string, ok: boolean) =>
821
+ dispatch({ kind: "prompt.answered", id, label, ok }), [])
822
+ const promptWire: PromptWire = useMemo(
823
+ () => ({ ref: promptRef, onAnswer: promptAnswer }), [promptAnswer])
824
+ // Snap to Chat when a prompt arrives so it isn't answered blind.
825
+ useEffect(() => { if (prompt && tab !== CHAT_TAB) setTab(CHAT_TAB) }, [prompt?.id])
826
+
827
+ const content = () => {
828
+ const inner = (() => {
829
+ switch (tab) {
830
+ case 0: return <Chat messages={turn.messages} streaming={turn.streaming}
831
+ prompt={promptWire}
832
+ cloud={cloud} cloudH={cloudH} pick={pick}
833
+ onResize={setCloudH} onPick={onPick} onClose={closeCloud} onRewind={msgMenu} />
834
+ case 1: return <Context description={TABS[tab].description} messages={turn.messages}
835
+ sessionStart={sessionStart.current} info={info ?? undefined}
836
+ focused={contentFocused} />
837
+ case 2: return <Sessions onSwitch={switchSession} currentId={sid} focused={contentFocused} />
838
+ case 3: return <Agents focused={contentFocused} sessionId={sid} />
839
+ case 4: return <Analytics focused={contentFocused} />
840
+ case 5: return <Skills focused={contentFocused} />
841
+ case 6: return <Cron focused={contentFocused} />
842
+ case 7: return <Toolsets focused={contentFocused} />
843
+ case 8: return <Config focused={contentFocused} />
844
+ case 9: return <Env focused={contentFocused} />
845
+ case 10: return <Memory focused={contentFocused} />
846
+ case 11: return <Kanban focused={contentFocused} />
847
+ default: return null
848
+ }
849
+ })()
850
+ const name = TABS[tab]?.name ?? "unknown"
851
+ return <Profiler id={`tab:${name}`} onRender={perf.onRender}>{inner}</Profiler>
852
+ }
853
+
854
+ const theme = themeCtx.theme
855
+ const onMouseUp = useCallback(() => copySelection(renderer), [renderer])
856
+ // Composer defocuses while any prompt is pending. Approval/clarify
857
+ // list-mode don't need input, and this guarantees the textarea's
858
+ // `focused` prop flips false→true on answer so OpenTUI refocuses it
859
+ // (a card's own <input focused> would otherwise leave it blurred).
860
+ // Keys still reach the card via onPromptKey on the global bus.
861
+ const inputFocused = focusRegion === "input" && !prompt
862
+
863
+ return (
864
+ <Profiler id="shell" onRender={perf.onRender}>
865
+ <SkinProvider value={skin}>
866
+ <box width="100%" height="100%" flexDirection="column"
867
+ backgroundColor={theme.background} onMouseUp={onMouseUp}>
868
+ <TabBar tabs={TABS} activeTab={tab} onTabChange={goToTab} />
869
+ <box flexGrow={1} flexDirection="row">
870
+ <box flexGrow={1} flexDirection="column">
871
+ <box flexGrow={1} position="relative">
872
+ {content()}
873
+ {splash && tab === CHAT_TAB ? (
874
+ <Splash
875
+ info={info ? {
876
+ agentVersion: info.version,
877
+ behind: info.update_behind,
878
+ model: info.model,
879
+ } : undefined}
880
+ last={summoned.current ? undefined : splashLast
881
+ ? { id: splashLast.id, title: splashLast.title } : undefined}
882
+ composing={composing}
883
+ news={news}
884
+ />
885
+ ) : null}
886
+ </box>
887
+ <box flexShrink={0} zIndex={1}>
888
+ <Composer
889
+ ref={composer}
890
+ focused={inputFocused} ready={ready} streaming={turn.streaming}
891
+ status={status}
892
+ queue={queue}
893
+ attachments={attachments}
894
+ cmds={cmds}
895
+ onSend={onSend} onSlash={slash}
896
+ onAttach={onAttach}
897
+ onEnqueue={onEnqueue}
898
+ onDequeue={dequeue}
899
+ onDirty={setComposing}
900
+ onEmptyEnter={onEmptyEnter}
901
+ />
902
+ </box>
903
+ </box>
904
+ {dims.width >= (tab === CHAT_TAB ? 120 : 140) && !hideSidebar ? (
905
+ <Profiler id="sidebar" onRender={perf.onRender}>
906
+ <Sidebar agentState={agentState} info={info} usage={usage} eikon={eikon} profile={activeProfileName()}
907
+ title={title}
908
+ cloud={tab === 0 && cloud} pulse={turn.streaming}
909
+ onAvatar={onAvatar} />
910
+ </Profiler>
911
+ ) : null}
912
+ </box>
913
+ </box>
914
+ </SkinProvider>
915
+ </Profiler>
916
+ )
917
+ }