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,359 @@
1
+ // Inline agent prompts — approval / clarify / sudo / secret.
2
+ //
3
+ // These render *in the transcript* as a Part of the in-progress
4
+ // assistant message, not in a modal. The composer stays focused for
5
+ // approval/clarify; the shell's global key handler routes keys to
6
+ // the pending card via the imperative handle so number/arrow/Enter
7
+ // work without the textarea eating them. Sudo/secret own a masked
8
+ // <input> and take focus explicitly (the value must never echo into
9
+ // the composer).
10
+ //
11
+ // Responding is exactly-once per card but NOT unmount-triggered — the
12
+ // card can scroll out of the viewport (culling) without auto-denying.
13
+ // Esc is the only cancel path.
14
+
15
+ import {
16
+ memo, useRef, useState, forwardRef, useImperativeHandle,
17
+ } from "react"
18
+ import { LEFT_BAR } from "../../ui/borders"
19
+ import type { ParsedKey, SubmitEvent } from "@opentui/core"
20
+ import { useTheme } from "../../theme"
21
+ import { useGateway } from "../../app/gateway"
22
+ import type { PromptPart, PromptReq, Part } from "../../types/message"
23
+
24
+ // ── Shared ───────────────────────────────────────────────────────────
25
+
26
+ export type PromptCardHandle = {
27
+ /** Offer a key to the pending card. Returns true if consumed. */
28
+ feed: (key: ParsedKey) => boolean
29
+ /** True if this card owns a focused <input> (sudo/secret). */
30
+ masked: boolean
31
+ }
32
+
33
+ type Answer = (label: string, ok: boolean) => void
34
+
35
+ function digit(name: string): number | null {
36
+ const n = parseInt(name, 10)
37
+ return Number.isFinite(n) ? n : null
38
+ }
39
+
40
+ // ┃-bar panel frame — matches the oc permission grammar that prompts
41
+ // already used inside the modal, minus the fixed width.
42
+ const Frame = (p: { tint: import("@opentui/core").RGBA; children: React.ReactNode }) => {
43
+ const theme = useTheme().theme
44
+ return (
45
+ <box
46
+ flexDirection="column"
47
+ border={["left"]}
48
+ borderColor={p.tint}
49
+ customBorderChars={LEFT_BAR}
50
+ backgroundColor={theme.backgroundPanel}
51
+ marginBottom={1}
52
+ >
53
+ {p.children}
54
+ </box>
55
+ )
56
+ }
57
+
58
+ const Pill = (p: { on: boolean; hot: string; label: string; onPick: () => void }) => {
59
+ const theme = useTheme().theme
60
+ return (
61
+ <box height={1} paddingX={1}
62
+ backgroundColor={p.on ? theme.primary : undefined}
63
+ onMouseDown={p.onPick}>
64
+ <text>
65
+ <span fg={p.on ? theme.background : theme.textMuted}>{p.hot} </span>
66
+ <span fg={p.on ? theme.background : theme.text}>{p.label}</span>
67
+ </text>
68
+ </box>
69
+ )
70
+ }
71
+
72
+ // ── Approval ─────────────────────────────────────────────────────────
73
+
74
+ const CHOICES = ["once", "session", "always", "deny"] as const
75
+ type Choice = typeof CHOICES[number]
76
+ const LABELS: Record<Choice, string> = {
77
+ once: "Allow once",
78
+ session: "Allow this session",
79
+ always: "Always allow",
80
+ deny: "Deny",
81
+ }
82
+
83
+ const Approval = forwardRef<PromptCardHandle, {
84
+ req: Extract<PromptReq, { variant: "approval" }>
85
+ onAnswer: Answer
86
+ }>((p, ref) => {
87
+ const theme = useTheme().theme
88
+ const gw = useGateway()
89
+ const [sel, setSel] = useState(0)
90
+ const done = useRef(false)
91
+
92
+ const send = (c: Choice) => {
93
+ if (done.current) return
94
+ done.current = true
95
+ void gw.request("approval.respond", { choice: c }).catch(() => {})
96
+ p.onAnswer(LABELS[c], c !== "deny")
97
+ }
98
+
99
+ useImperativeHandle(ref, () => ({
100
+ masked: false,
101
+ feed: (key) => {
102
+ if (key.name === "left" || key.name === "h") {
103
+ setSel(s => (s + CHOICES.length - 1) % CHOICES.length); return true
104
+ }
105
+ if (key.name === "right" || key.name === "l") {
106
+ setSel(s => (s + 1) % CHOICES.length); return true
107
+ }
108
+ if (key.name === "return") { send(CHOICES[sel]); return true }
109
+ if (key.name === "escape") { send("deny"); return true }
110
+ const n = digit(key.name)
111
+ if (n !== null && n >= 1 && n <= CHOICES.length) { send(CHOICES[n - 1]); return true }
112
+ return false
113
+ },
114
+ }), [sel])
115
+
116
+ return (
117
+ <Frame tint={theme.warning}>
118
+ <box flexDirection="column" gap={1} paddingLeft={1} paddingRight={2} paddingY={1}>
119
+ <box flexDirection="row" gap={1} height={1}>
120
+ <text fg={theme.warning}>△</text>
121
+ <text fg={theme.text}>Permission required</text>
122
+ </box>
123
+ <box flexDirection="row" gap={1} paddingLeft={2} minHeight={1}>
124
+ <text fg={theme.textMuted}>#</text>
125
+ <text fg={theme.text} wrapMode="word">{p.req.description || "Shell command"}</text>
126
+ </box>
127
+ <box paddingLeft={2} minHeight={1}>
128
+ <text fg={theme.text} wrapMode="word">$ {p.req.command}</text>
129
+ </box>
130
+ </box>
131
+ <box flexDirection="row" gap={2} flexShrink={0}
132
+ paddingX={2} paddingY={1} backgroundColor={theme.backgroundElement}>
133
+ {CHOICES.map((c, i) => (
134
+ <Pill key={c} on={sel === i} hot={String(i + 1)} label={LABELS[c]}
135
+ onPick={() => send(c)} />
136
+ ))}
137
+ <box flexGrow={1} />
138
+ <box height={1}>
139
+ <text fg={theme.textMuted}>←/→ · enter · esc deny</text>
140
+ </box>
141
+ </box>
142
+ </Frame>
143
+ )
144
+ })
145
+
146
+ // ── Clarify ──────────────────────────────────────────────────────────
147
+
148
+ const Clarify = forwardRef<PromptCardHandle, {
149
+ req: Extract<PromptReq, { variant: "clarify" }>
150
+ onAnswer: Answer
151
+ }>((p, ref) => {
152
+ const theme = useTheme().theme
153
+ const gw = useGateway()
154
+ const choices = p.req.choices ?? []
155
+ const [sel, setSel] = useState(0)
156
+ const [typing, setTyping] = useState(choices.length === 0)
157
+ const [custom, setCustom] = useState("")
158
+ const done = useRef(false)
159
+
160
+ const send = (answer: string) => {
161
+ if (done.current) return
162
+ done.current = true
163
+ void gw.request("clarify.respond", {
164
+ request_id: p.req.request_id, answer,
165
+ }).catch(() => {})
166
+ p.onAnswer(answer || "(cancelled)", answer !== "")
167
+ }
168
+
169
+ useImperativeHandle(ref, () => ({
170
+ // Freeform mode owns a focused <input>; list mode doesn't.
171
+ masked: typing,
172
+ feed: (key) => {
173
+ if (typing) {
174
+ // <input> handles text; we only intercept cancel-back.
175
+ if (key.name === "escape") {
176
+ if (choices.length) { setTyping(false); return true }
177
+ send(""); return true
178
+ }
179
+ return false
180
+ }
181
+ if (key.name === "escape") { send(""); return true }
182
+ if (key.name === "up") { setSel(s => Math.max(0, s - 1)); return true }
183
+ if (key.name === "down") { setSel(s => Math.min(choices.length, s + 1)); return true }
184
+ if (key.name === "return") {
185
+ if (sel === choices.length) { setTyping(true); return true }
186
+ const c = choices[sel]
187
+ if (c) send(c)
188
+ return true
189
+ }
190
+ const n = digit(key.name)
191
+ if (n !== null && n >= 1 && n <= choices.length) { send(choices[n - 1]); return true }
192
+ return false
193
+ },
194
+ }), [typing, sel, choices])
195
+
196
+ const head = (
197
+ <box minHeight={1}>
198
+ <text wrapMode="word">
199
+ <span fg={theme.accent}><strong>ask </strong></span>
200
+ <span fg={theme.text}><strong>{p.req.question}</strong></span>
201
+ </text>
202
+ </box>
203
+ )
204
+
205
+ return (
206
+ <Frame tint={theme.accent}>
207
+ <box flexDirection="column" paddingLeft={1} paddingRight={2} paddingY={1}>
208
+ {head}
209
+ <box height={1} />
210
+ {typing ? (
211
+ <>
212
+ <box flexDirection="row" height={1}>
213
+ <text fg={theme.textMuted}>{"> "}</text>
214
+ <input
215
+ value={custom} onInput={setCustom}
216
+ onSubmit={(() => send(custom)) as unknown as (e: SubmitEvent) => void}
217
+ focused flexGrow={1}
218
+ textColor={theme.text}
219
+ backgroundColor={theme.backgroundElement}
220
+ focusedBackgroundColor={theme.backgroundElement}
221
+ />
222
+ </box>
223
+ <text fg={theme.textMuted}>Enter send · Esc {choices.length ? "back" : "cancel"}</text>
224
+ </>
225
+ ) : (
226
+ <>
227
+ {[...choices, "Other (type your answer)"].map((c, i) => (
228
+ <box key={i} height={1} onMouseDown={() =>
229
+ i === choices.length ? setTyping(true) : send(choices[i])}>
230
+ <text fg={sel === i ? theme.text : theme.textMuted}>
231
+ {sel === i ? "▸ " : " "}{i + 1}. {c}
232
+ </text>
233
+ </box>
234
+ ))}
235
+ <box height={1} />
236
+ <text fg={theme.textMuted}>↑/↓ · Enter · 1-{choices.length} · Esc cancel</text>
237
+ </>
238
+ )}
239
+ </box>
240
+ </Frame>
241
+ )
242
+ })
243
+
244
+ // ── Masked (sudo / secret) ───────────────────────────────────────────
245
+
246
+ const Masked = forwardRef<PromptCardHandle, {
247
+ title: string
248
+ note: string
249
+ onSubmit: (v: string) => void
250
+ onAnswer: Answer
251
+ }>((p, ref) => {
252
+ const theme = useTheme().theme
253
+ const [value, setValue] = useState("")
254
+ const done = useRef(false)
255
+
256
+ const go = (v: string) => {
257
+ if (done.current) return
258
+ done.current = true
259
+ p.onSubmit(v)
260
+ p.onAnswer(v ? "(provided)" : "(cancelled)", v !== "")
261
+ }
262
+
263
+ useImperativeHandle(ref, () => ({
264
+ masked: true,
265
+ feed: (key) => {
266
+ if (key.name === "escape") { go(""); return true }
267
+ return false
268
+ },
269
+ }), [])
270
+
271
+ return (
272
+ <Frame tint={theme.warning}>
273
+ <box flexDirection="column" paddingLeft={1} paddingRight={2} paddingY={1}>
274
+ <text fg={theme.warning}><strong>{p.title}</strong></text>
275
+ <text fg={theme.text}>{p.note}</text>
276
+ <box height={1} />
277
+ <box flexDirection="row" height={1} position="relative">
278
+ <text fg={theme.textMuted}>{"> "}</text>
279
+ <input
280
+ value={value} onInput={setValue}
281
+ onSubmit={(() => go(value)) as unknown as (e: SubmitEvent) => void}
282
+ focused flexGrow={1}
283
+ textColor={theme.backgroundElement}
284
+ cursorColor={theme.accent}
285
+ backgroundColor={theme.backgroundElement}
286
+ focusedBackgroundColor={theme.backgroundElement}
287
+ />
288
+ <box position="absolute" left={2} top={0} height={1}>
289
+ <text fg={theme.text} bg={theme.backgroundElement}>{"•".repeat(value.length)}</text>
290
+ </box>
291
+ </box>
292
+ <text fg={theme.textMuted}>Enter submit · Esc cancel</text>
293
+ </box>
294
+ </Frame>
295
+ )
296
+ })
297
+
298
+ // ── Answered (collapsed) ─────────────────────────────────────────────
299
+
300
+ const Outcome = memo(({ part }: { part: PromptPart }) => {
301
+ const theme = useTheme().theme
302
+ const a = part.answered!
303
+ const glyph = a.ok ? "✓" : "✗"
304
+ const fg = a.ok ? theme.success : theme.error
305
+ const what =
306
+ part.variant === "approval" ? a.label
307
+ : part.variant === "clarify" ? `chose: ${a.label}`
308
+ : part.variant === "sudo" ? `sudo ${a.label}`
309
+ : `${(part.req as { env_var?: string }).env_var ?? "secret"} ${a.label}`
310
+ return (
311
+ <box height={1} paddingLeft={3} marginBottom={1}>
312
+ <text>
313
+ <span fg={fg}>{glyph} </span>
314
+ <span fg={theme.textMuted}>{what}</span>
315
+ </text>
316
+ </box>
317
+ )
318
+ })
319
+
320
+ // ── Dispatch ─────────────────────────────────────────────────────────
321
+
322
+ export const PromptCard = memo(forwardRef<PromptCardHandle, {
323
+ part: PromptPart
324
+ onAnswer: (id: string, label: string, ok: boolean) => void
325
+ }>((p, ref) => {
326
+ const gw = useGateway()
327
+ if (p.part.answered) return <Outcome part={p.part} />
328
+ const answer: Answer = (label, ok) => p.onAnswer(p.part.id, label, ok)
329
+ const req = p.part.req
330
+ if (req.variant === "approval")
331
+ return <Approval ref={ref} req={req} onAnswer={answer} />
332
+ if (req.variant === "clarify")
333
+ return <Clarify ref={ref} req={req} onAnswer={answer} />
334
+ if (req.variant === "sudo")
335
+ return <Masked ref={ref} title="🔒 Sudo required"
336
+ note="Enter your password to elevate privileges."
337
+ onSubmit={v => void gw.request("sudo.respond",
338
+ { request_id: req.request_id, password: v }).catch(() => {})}
339
+ onAnswer={answer} />
340
+ return <Masked ref={ref} title={`🔑 Secret: ${req.env_var}`}
341
+ note={req.prompt}
342
+ onSubmit={v => void gw.request("secret.respond",
343
+ { request_id: req.request_id, value: v }).catch(() => {})}
344
+ onAnswer={answer} />
345
+ }))
346
+
347
+ /** Find the single pending prompt across all messages. The gateway
348
+ * blocks on the answer, so there's at most one. */
349
+ export function pending(messages: ReadonlyArray<{ role: string; parts: ReadonlyArray<Part> }>): PromptPart | null {
350
+ for (let i = messages.length - 1; i >= 0; i--) {
351
+ const m = messages[i]
352
+ if (m.role !== "assistant") continue
353
+ for (let j = m.parts.length - 1; j >= 0; j--) {
354
+ const part = m.parts[j]
355
+ if (part.type === "prompt" && !part.answered) return part
356
+ }
357
+ }
358
+ return null
359
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Slash command popover — OpenCode-inspired visual style.
3
+ *
4
+ * Purely presentational. Keyboard navigation lives in the parent (app.tsx
5
+ * useKeyboard) to avoid OpenTUI's global keyboard event conflicts.
6
+ *
7
+ * Uses a sliding window that follows the cursor rather than scrollbox
8
+ * (scrollbox requires focus to scroll, which would conflict with the input).
9
+ */
10
+
11
+ import { useMemo, memo } from "react"
12
+ import type { RGBA } from "@opentui/core"
13
+ import { useTheme } from "../../theme"
14
+ import type { Theme } from "../../theme"
15
+ import type { SlashCommand, SlashSource } from "../../commands/slash"
16
+ import { sort } from "../../commands/slash"
17
+
18
+ type Props = {
19
+ readonly commands: ReadonlyArray<SlashCommand>
20
+ readonly cursor: number
21
+ readonly onCursor: (idx: number) => void
22
+ readonly onSelect: (cmd: SlashCommand) => void
23
+ }
24
+
25
+ type Row =
26
+ | { type: "header"; cat: string }
27
+ | { type: "cmd"; cmd: SlashCommand; flat: number }
28
+
29
+ const MAX_VISIBLE = 14
30
+
31
+ /** Color for the source badge. Returns null for sources that shouldn't render. */
32
+ function badge(source: SlashSource, theme: Theme): RGBA | null {
33
+ if (source === "skill") return theme.success
34
+ if (source === "plugin") return theme.info
35
+ if (source === "mcp") return theme.warning
36
+ return null // "command" and "local" get no badge
37
+ }
38
+
39
+ export const SlashPopover = memo(({ commands: cmds, cursor, onCursor, onSelect }: Props) => {
40
+ const theme = useTheme().theme
41
+
42
+ if (cmds.length === 0) {
43
+ return (
44
+ <box
45
+ border
46
+ borderStyle="single"
47
+ borderColor={theme.border}
48
+ backgroundColor={theme.backgroundPanel}
49
+ paddingX={1}
50
+ height={3}
51
+ >
52
+ <text fg={theme.textMuted}>No matching commands</text>
53
+ </box>
54
+ )
55
+ }
56
+
57
+ // Build flat row list with category headers, stable order (sort by category).
58
+ const rows = useMemo(() => {
59
+ const sorted = sort(cmds)
60
+ const result: Row[] = []
61
+ let flat = 0
62
+ let lastCat = ""
63
+ for (const cmd of sorted) {
64
+ if (cmd.category !== lastCat) {
65
+ result.push({ type: "header", cat: cmd.category })
66
+ lastCat = cmd.category
67
+ }
68
+ result.push({ type: "cmd", cmd, flat: flat++ })
69
+ }
70
+ return result
71
+ }, [cmds])
72
+
73
+ // Find the row index of the cursor to drive the sliding window.
74
+ const cursorRow = rows.findIndex(r => r.type === "cmd" && r.flat === cursor)
75
+ const start = Math.max(0, Math.min(cursorRow - 2, rows.length - MAX_VISIBLE))
76
+ const visible = rows.slice(start, start + MAX_VISIBLE)
77
+ const clipped = rows.length > MAX_VISIBLE
78
+ const above = clipped && start > 0
79
+ const below = clipped && start + MAX_VISIBLE < rows.length
80
+ const height = visible.length + 2 + (above ? 1 : 0) + (below ? 1 : 0)
81
+
82
+ return (
83
+ <box
84
+ flexDirection="column"
85
+ border
86
+ borderStyle="single"
87
+ borderColor={theme.border}
88
+ backgroundColor={theme.backgroundPanel}
89
+ paddingX={1}
90
+ height={height}
91
+ >
92
+ {above ? (
93
+ <box height={1} paddingLeft={1}>
94
+ <text fg={theme.textMuted}>↑ more</text>
95
+ </box>
96
+ ) : null}
97
+ {visible.map((row) => {
98
+ if (row.type === "header") {
99
+ return (
100
+ <box key={`h-${row.cat}`} height={1} paddingLeft={1}>
101
+ <text>
102
+ <span fg={theme.textMuted}>
103
+ <strong>{row.cat}</strong>
104
+ </span>
105
+ </text>
106
+ </box>
107
+ )
108
+ }
109
+
110
+ const active = row.flat === cursor
111
+ const color = badge(row.cmd.source, theme)
112
+
113
+ return (
114
+ <box
115
+ key={`c-${row.cmd.name}`}
116
+ height={1}
117
+ flexDirection="row"
118
+ backgroundColor={active ? theme.backgroundElement : undefined}
119
+ onMouseOver={() => onCursor(row.flat)}
120
+ onMouseDown={() => onSelect(row.cmd)}
121
+ paddingLeft={2}
122
+ paddingRight={1}
123
+ >
124
+ {/* Left: /name [args] description */}
125
+ <box flexGrow={1} height={1}>
126
+ <text>
127
+ <span fg={active ? theme.primary : theme.text}>/{row.cmd.name}</span>
128
+ {row.cmd.argsHint ? (
129
+ <span fg={theme.textMuted}> {row.cmd.argsHint}</span>
130
+ ) : null}
131
+ <span fg={theme.textMuted}> {row.cmd.description}</span>
132
+ </text>
133
+ </box>
134
+
135
+ {/* Right: source badge + keybind */}
136
+ <box height={1} flexDirection="row">
137
+ {color ? (
138
+ <text>
139
+ <span fg={color}> {row.cmd.source}</span>
140
+ </text>
141
+ ) : null}
142
+ {row.cmd.keybind ? (
143
+ <text>
144
+ <span fg={theme.textMuted}> {row.cmd.keybind}</span>
145
+ </text>
146
+ ) : null}
147
+ </box>
148
+ </box>
149
+ )
150
+ })}
151
+ {below ? (
152
+ <box height={1} paddingLeft={1}>
153
+ <text fg={theme.textMuted}>↓ more</text>
154
+ </box>
155
+ ) : null}
156
+ </box>
157
+ )
158
+ })