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,54 @@
1
+ // @-ref popover — same sliding-window shell as SlashPopover but with
2
+ // a flat file/keyword list (no categories, no source badges).
3
+
4
+ import { memo } from "react"
5
+ import { useTheme } from "../../theme"
6
+ import type { AtRefItem } from "../../app/useAtRefPopover"
7
+
8
+ type Props = {
9
+ readonly items: ReadonlyArray<AtRefItem>
10
+ readonly cursor: number
11
+ readonly onCursor: (idx: number) => void
12
+ readonly onSelect: (idx: number) => void
13
+ }
14
+
15
+ const MAX_VISIBLE = 10
16
+
17
+ export const AtRefPopover = memo(({ items, cursor, onCursor, onSelect }: Props) => {
18
+ const theme = useTheme().theme
19
+
20
+ const start = Math.max(0, Math.min(cursor - 2, items.length - MAX_VISIBLE))
21
+ const visible = items.slice(start, start + MAX_VISIBLE)
22
+ const above = start > 0
23
+ const below = start + MAX_VISIBLE < items.length
24
+ const height = visible.length + 2 + (above ? 1 : 0) + (below ? 1 : 0)
25
+
26
+ return (
27
+ <box flexDirection="column" border borderStyle="single"
28
+ borderColor={theme.border} backgroundColor={theme.backgroundPanel}
29
+ paddingX={1} height={height}>
30
+ {above ? <box height={1} paddingLeft={1}><text fg={theme.textMuted}>↑ more</text></box> : null}
31
+ {visible.map((it, j) => {
32
+ const i = start + j
33
+ const active = i === cursor
34
+ return (
35
+ <box key={it.text} height={1} flexDirection="row"
36
+ backgroundColor={active ? theme.backgroundElement : undefined}
37
+ onMouseOver={() => onCursor(i)} onMouseDown={() => onSelect(i)}
38
+ paddingLeft={2} paddingRight={1}>
39
+ <box flexGrow={1} height={1} overflow="hidden">
40
+ <text>
41
+ <span fg={active ? theme.primary : theme.text}>{it.display}</span>
42
+ {it.text !== it.display
43
+ ? <span fg={theme.textMuted}>{` ${it.text}`}</span>
44
+ : null}
45
+ </text>
46
+ </box>
47
+ <box height={1}><text fg={theme.textMuted}>{it.meta}</text></box>
48
+ </box>
49
+ )
50
+ })}
51
+ {below ? <box height={1} paddingLeft={1}><text fg={theme.textMuted}>↓ more</text></box> : null}
52
+ </box>
53
+ )
54
+ })
@@ -0,0 +1,67 @@
1
+ // Fenced code block chrome — bg panel, ┃-bar, lang label, click-to-copy.
2
+ // The body is a <code> renderable so tree-sitter highlighting stays
3
+ // identical to what MarkdownRenderable would have produced; the point
4
+ // of pulling fences out of the markdown stream is purely to wrap them.
5
+
6
+ import { memo, useState } from "react"
7
+ import { LEFT_BAR } from "../../ui/borders"
8
+ import { useTheme } from "../../theme"
9
+ import { useToast } from "../../ui/toast"
10
+ import { copy } from "../../utils/clipboard"
11
+
12
+ // Info-string → tree-sitter filetype. Only the handful that differ
13
+ // from their canonical fence tag; everything else passes through.
14
+ const FILETYPE: Record<string, string> = {
15
+ ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
16
+ py: "python", rb: "ruby", rs: "rust", sh: "bash", shell: "bash",
17
+ yml: "yaml", md: "markdown",
18
+ }
19
+
20
+ export const CodeBlock = memo((props: { code: string; lang?: string; streaming?: boolean }) => {
21
+ const { theme, syntaxStyle } = useTheme()
22
+ const toast = useToast()
23
+ const [hover, setHover] = useState(false)
24
+
25
+ const ft = props.lang ? FILETYPE[props.lang.toLowerCase()] ?? props.lang.toLowerCase() : undefined
26
+ const lines = props.code.split("\n").length
27
+
28
+ const onCopy = () => {
29
+ void copy(props.code)
30
+ toast.show({ variant: "success", message: `Copied ${lines} line${lines === 1 ? "" : "s"}` })
31
+ }
32
+
33
+ return (
34
+ <box
35
+ flexDirection="column"
36
+ marginTop={1}
37
+ border={["left"]}
38
+ borderColor={theme.border}
39
+ customBorderChars={LEFT_BAR}
40
+ backgroundColor={theme.backgroundPanel}
41
+ paddingLeft={1}
42
+ >
43
+ <box
44
+ flexDirection="row" height={1}
45
+ backgroundColor={theme.backgroundElement}
46
+ onMouseDown={onCopy}
47
+ onMouseOver={() => setHover(true)}
48
+ onMouseOut={() => setHover(false)}
49
+ >
50
+ <box flexGrow={1} paddingLeft={1}>
51
+ <text fg={theme.textMuted}>{props.lang || "text"}</text>
52
+ </box>
53
+ <box paddingRight={1}>
54
+ <text fg={hover ? theme.accent : theme.textMuted}>
55
+ {hover ? "⧉ copy" : `${lines} ln`}
56
+ </text>
57
+ </box>
58
+ </box>
59
+ <box paddingX={1} paddingY={ft ? 0 : 1}>
60
+ {ft
61
+ ? <code content={props.code} filetype={ft} syntaxStyle={syntaxStyle}
62
+ fg={theme.text} wrapMode="none" streaming={props.streaming} />
63
+ : <text fg={theme.text}>{props.code}</text>}
64
+ </box>
65
+ </box>
66
+ )
67
+ })
@@ -0,0 +1,347 @@
1
+ // Composer — owns the chat input buffer, slash popover, ghost completion
2
+ // and prompt history. The shell (app.tsx) drives keyboard routing through
3
+ // the imperative handle so there is exactly one global useKeyboard.
4
+
5
+ import { forwardRef, memo, useImperativeHandle, useRef, useState, useCallback, useMemo, useEffect } from "react"
6
+ import type { TextareaRenderable, PasteEvent } from "@opentui/core"
7
+ import { decodePasteBytes } from "@opentui/core"
8
+ import { useTheme } from "../../theme"
9
+ import { useKeys, toBindings } from "../../keys"
10
+ import { useGateway } from "../../app/gateway"
11
+ import type { ImageAttachResponse, DropDetectResponse } from "../../utils/gateway-types"
12
+ import { looksLikePath } from "../../utils/drop"
13
+ import type { SlashCommand } from "../../commands/slash"
14
+ import { useSlashPopover } from "../../app/useSlashPopover"
15
+ import { useAtRefPopover } from "../../app/useAtRefPopover"
16
+ import { useInputHistory } from "../../app/useInputHistory"
17
+ import { SlashPopover } from "./SlashPopover"
18
+ import { AtRefPopover } from "./AtRefPopover"
19
+ import { ChafaImage } from "../../ui/ChafaImage"
20
+ import { trunc } from "../../ui/fmt"
21
+
22
+ export type ComposerHandle = {
23
+ value: () => string
24
+ set: (v: string) => void
25
+ /** Insert text at the cursor (verbatim, multi-line ok). */
26
+ insert: (text: string) => void
27
+ /** Logical line count of the current buffer. */
28
+ lines: () => number
29
+ /** True iff the buffer is empty (no text, no whitespace-only). */
30
+ isEmpty: () => boolean
31
+ popOpen: () => boolean
32
+ popNav: (d: -1 | 1) => void
33
+ popAccept: () => void
34
+ popCancel: () => void
35
+ /** Returns false when not applicable (multi-line buffer → caller lets textarea own ↑/↓). */
36
+ historyUp: () => boolean
37
+ historyDown: () => boolean
38
+ }
39
+
40
+ type Props = {
41
+ focused: boolean
42
+ ready: boolean
43
+ streaming: boolean
44
+ status?: string
45
+ queue?: ReadonlyArray<string>
46
+ attachments?: ReadonlyArray<ImageAttachResponse>
47
+ cmds: ReadonlyArray<SlashCommand>
48
+ onSend: (text: string) => void
49
+ onSlash: (cmd: SlashCommand) => void
50
+ onAttach?: (r: ImageAttachResponse) => void
51
+ onEnqueue?: (text: string) => void
52
+ onDequeue?: (i: number) => void
53
+ /** Enter pressed with an empty buffer. Return true to consume. */
54
+ onEmptyEnter?: () => boolean
55
+ /** Fires on the empty↔non-empty edge of the input buffer. */
56
+ onDirty?: (dirty: boolean) => void
57
+ }
58
+
59
+ const MAX_ROWS = 6
60
+
61
+ function fmt(n: number): string {
62
+ if (n < 1000) return String(n)
63
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
64
+ return `${(n / 1_000_000).toFixed(2)}M`
65
+ }
66
+
67
+ export const Composer = memo(forwardRef<ComposerHandle, Props>((props, ref) => {
68
+ const theme = useTheme().theme
69
+ const gw = useGateway()
70
+ const keys = useKeys()
71
+ const ta = useRef<TextareaRenderable | null>(null)
72
+ // Mirror of the textarea buffer. The renderable is the source of truth;
73
+ // this drives React-side derivations (popover matching, row count, hints).
74
+ const [input, setInput] = useState("")
75
+
76
+ // Slash and @-ref popovers key off the first line only — both grammars
77
+ // are single-line prefixes, and a newline is a hard boundary.
78
+ const head = useMemo(() => {
79
+ const i = input.indexOf("\n")
80
+ return i < 0 ? input : input.slice(0, i)
81
+ }, [input])
82
+
83
+ const pop = useSlashPopover(head, props.cmds)
84
+ const at = useAtRefPopover(head)
85
+
86
+ const write = useCallback((v: string) => {
87
+ ta.current?.setText(v)
88
+ ta.current?.gotoBufferEnd()
89
+ setInput(v)
90
+ }, [])
91
+
92
+ const hist = useInputHistory(input, write)
93
+
94
+ // Merged over the renderable's default map (which has bare return →
95
+ // newline), so input.submit's `return` entry overrides it and the
96
+ // newline alternates add on top. Recomputes only when a user rebinds.
97
+ const bindings = useMemo(() => [
98
+ ...toBindings(keys.chord("input.submit"), "submit"),
99
+ ...toBindings(keys.chord("input.newline"), "newline"),
100
+ ], [keys])
101
+
102
+ // Hold latest pop/props in a ref so the imperative handle is stable.
103
+ const live = useRef({ pop, at, props, input })
104
+ live.current = { pop, at, props, input }
105
+
106
+ // Notify parent only on the empty↔non-empty edge so the splash
107
+ // continue-prompt can hide the moment typing starts.
108
+ const wasDirty = useRef(false)
109
+ useEffect(() => {
110
+ const dirty = input.trim().length > 0
111
+ if (dirty === wasDirty.current) return
112
+ wasDirty.current = dirty
113
+ live.current.props.onDirty?.(dirty)
114
+ }, [input])
115
+
116
+ // Selecting a popover entry: subcommand synthetics (name contains a
117
+ // space) complete the input for further typing; real commands dispatch.
118
+ const select = (c: SlashCommand) => {
119
+ if (c.name.includes(" ")) { write(`/${c.name} `); return }
120
+ write("")
121
+ live.current.props.onSlash(c)
122
+ }
123
+
124
+ const atAccept = (idx?: number) => {
125
+ const next = live.current.at.accept(live.current.input, idx)
126
+ if (next !== null) write(next)
127
+ }
128
+
129
+ // Paste routing, in priority order:
130
+ // 1. Single-line paste that *looks* like a local path → ask the gateway.
131
+ // input.detect_drop is authoritative (stats the file, handles file://,
132
+ // quoting, escaped spaces, ~/ expansion, WSL drive rewriting). Image
133
+ // hits append to session["attached_images"] server-side; herm mirrors
134
+ // the chip and inserts only the trailing remainder text, not the
135
+ // `[User attached image: …]` placeholder (that's for blind clients).
136
+ // Non-image hits (pdf/txt/…) insert the `[User attached file: …]`
137
+ // wrapper so the agent sees the path. Any miss falls through.
138
+ // 2. ≥5 lines → gateway writes a temp file and hands back a
139
+ // `[Pasted #N …]` placeholder (hermes CLI convention; expanded
140
+ // server-side in prompt.submit).
141
+ // 3. Otherwise insert verbatim minus trailing newlines — terminals append
142
+ // one on bracketed paste and `echo`/`cat` output copied from a shell
143
+ // always carries one, so a naive 1-line paste would otherwise push the
144
+ // cursor to a blank second row. A paste that is *only* newlines is let
145
+ // through unchanged (intentional line break).
146
+ const paste = useCallback((e: PasteEvent) => {
147
+ e.preventDefault()
148
+ const raw = decodePasteBytes(e.bytes).replace(/\r\n?/g, "\n")
149
+ const text = /[^\n]/.test(raw) ? raw.replace(/\n+$/, "") : raw
150
+ const verbatim = () => ta.current?.insertText(text)
151
+ if (looksLikePath(text)) {
152
+ gw.request<DropDetectResponse>("input.detect_drop", { text })
153
+ .then(r => {
154
+ if (!r.matched) return verbatim()
155
+ if (r.is_image) {
156
+ const { path, count, name, width, height, token_estimate } = r
157
+ live.current.props.onAttach?.({ attached: true, path, count, name, width, height, token_estimate })
158
+ if (!r.text.startsWith("[User attached")) ta.current?.insertText(r.text + " ")
159
+ return
160
+ }
161
+ ta.current?.insertText(r.text + " ")
162
+ })
163
+ .catch(verbatim)
164
+ return
165
+ }
166
+ if (text.split("\n").length < 5) return verbatim()
167
+ gw.request<{ placeholder: string }>("paste.collapse", { text })
168
+ .then(r => ta.current?.insertText(r.placeholder + " "))
169
+ .catch(verbatim)
170
+ }, [gw])
171
+
172
+ const submit = () => {
173
+ // While streaming, slash/at popovers are suppressed; anything
174
+ // typed is a plain prompt to enqueue.
175
+ if (live.current.props.streaming) {
176
+ const text = live.current.input.trim()
177
+ if (!text || !live.current.props.ready) return
178
+ hist.push(text)
179
+ write("")
180
+ live.current.props.onEnqueue?.(text)
181
+ return
182
+ }
183
+ const a = live.current.at
184
+ if (a.open) return atAccept()
185
+ const p = live.current.pop
186
+ if (p.open) {
187
+ const c = p.popover?.[p.cursor]
188
+ if (c) select(c)
189
+ return
190
+ }
191
+ const text = live.current.input.trim()
192
+ const hasAtt = (live.current.props.attachments?.length ?? 0) > 0
193
+ if (!text && !hasAtt) { live.current.props.onEmptyEnter?.(); return }
194
+ if (!live.current.props.ready) return
195
+ if (text) hist.push(text)
196
+ write("")
197
+ live.current.props.onSend(text)
198
+ }
199
+
200
+ const multi = () => live.current.input.includes("\n")
201
+
202
+ useImperativeHandle(ref, () => ({
203
+ value: () => live.current.input,
204
+ set: write,
205
+ insert: (text) => ta.current?.insertText(text),
206
+ lines: () => (ta.current?.lineCount ?? 1),
207
+ isEmpty: () => live.current.input.trim().length === 0,
208
+ popOpen: () => live.current.pop.open || live.current.at.open,
209
+ popNav: (d) => {
210
+ const a = live.current.at
211
+ if (a.open) return a.setCursor(c => Math.max(0, Math.min(a.items.length - 1, c + d)))
212
+ const max = (live.current.pop.popover?.length ?? 1) - 1
213
+ pop.setCursor(c => Math.max(0, Math.min(max, c + d)))
214
+ },
215
+ popAccept: () => {
216
+ const a = live.current.at
217
+ if (a.open) return atAccept()
218
+ const p = live.current.pop
219
+ const c = p.popover?.[p.cursor]
220
+ if (c) write(`/${c.name}${c.name.includes(" ") ? " " : ""}`)
221
+ },
222
+ popCancel: () => {
223
+ const a = live.current.at
224
+ if (a.open) return a.dismiss()
225
+ write("")
226
+ },
227
+ historyUp: () => { if (multi()) return false; hist.up(); return true },
228
+ historyDown: () => { if (multi()) return false; hist.down(); return true },
229
+ }), [hist.up, hist.down, pop.setCursor, write])
230
+
231
+ const active = props.focused && !props.streaming
232
+ const label = !props.ready ? "Connecting..."
233
+ : props.streaming ? (props.status || "Generating...")
234
+ : "Ready"
235
+ const dot = props.ready ? (props.streaming ? theme.warning : theme.success) : theme.error
236
+
237
+ // Logical-line row count (wrap-induced growth ignored; yoga sizes the
238
+ // textarea, this only positions the absolute popover above the border).
239
+ const rows = Math.min(MAX_ROWS, Math.max(1, input.split("\n").length))
240
+ const lift = rows + 3
241
+
242
+ return (
243
+ <box flexDirection="column" position="relative">
244
+ {active && pop.open ? (
245
+ <box position="absolute" bottom={lift} left={0} right={0}>
246
+ <SlashPopover
247
+ commands={pop.popover!}
248
+ cursor={pop.cursor}
249
+ onCursor={pop.setCursor}
250
+ onSelect={select}
251
+ />
252
+ </box>
253
+ ) : active && at.open ? (
254
+ <box position="absolute" bottom={lift} left={0} right={0}>
255
+ <AtRefPopover
256
+ items={at.items}
257
+ cursor={at.cursor}
258
+ onCursor={at.setCursor}
259
+ onSelect={atAccept}
260
+ />
261
+ </box>
262
+ ) : null}
263
+
264
+ {(props.queue?.length ?? 0) > 0 ? (
265
+ <box flexDirection="column" paddingX={1} paddingBottom={1}>
266
+ {props.queue!.map((q, i) => (
267
+ <box key={i} height={1} onMouseDown={() => props.onDequeue?.(i)}>
268
+ <text>
269
+ <span fg={theme.borderSubtle}>{i === 0 ? "╭" : "│"} </span>
270
+ <span fg={theme.textMuted}>⏸ {i + 1}. {trunc(q, 60)}</span>
271
+ </text>
272
+ </box>
273
+ ))}
274
+ </box>
275
+ ) : null}
276
+
277
+ {(props.attachments?.length ?? 0) > 0 ? (
278
+ <box flexDirection="column" paddingX={1} paddingBottom={1} gap={1}>
279
+ {props.attachments!.map(a => a.path
280
+ ? <ChafaImage key={`p-${a.path}`} path={a.path} width={60} />
281
+ : null)}
282
+ </box>
283
+ ) : null}
284
+
285
+ {(props.attachments?.length ?? 0) > 0 ? (
286
+ <box flexDirection="row" flexWrap="wrap" gap={1} paddingX={1} paddingBottom={1}>
287
+ {props.attachments!.map((a, i) => (
288
+ <text key={a.path ?? i}>
289
+ <span bg={theme.accent} fg={theme.background}> img </span>
290
+ <span bg={theme.backgroundElement} fg={theme.textMuted}> {a.name ?? `image ${i + 1}`} </span>
291
+ {a.width && a.height
292
+ ? <span bg={theme.backgroundElement} fg={theme.textMuted}>{a.width}×{a.height} </span>
293
+ : null}
294
+ {a.token_estimate
295
+ ? <span bg={theme.backgroundElement} fg={theme.textMuted}>~{fmt(a.token_estimate)}t </span>
296
+ : null}
297
+ <span fg={theme.textMuted}> </span>
298
+ <span fg={theme.textMuted}>⌫ to detach</span>
299
+ </text>
300
+ ))}
301
+ </box>
302
+ ) : null}
303
+
304
+ <box
305
+ border
306
+ borderStyle="single"
307
+ borderColor={active ? theme.borderActive : theme.border}
308
+ flexDirection="row"
309
+ position="relative"
310
+ >
311
+ <box width={1}><text fg={theme.primary}>{">"}</text></box>
312
+ <box width={1} />
313
+ <textarea
314
+ ref={ta}
315
+ onContentChange={() => setInput(ta.current?.plainText ?? "")}
316
+ onSubmit={submit}
317
+ onPaste={paste}
318
+ keyBindings={bindings}
319
+ wrapMode="word"
320
+ minHeight={1}
321
+ maxHeight={MAX_ROWS}
322
+ placeholder={props.streaming ? "Type to queue... (Enter queues, click chip to edit)" : "Message Hermes... (/ for commands, Shift+Enter for newline)"}
323
+ focused={props.focused}
324
+ textColor={theme.text}
325
+ focusedTextColor={theme.text}
326
+ placeholderColor={theme.textMuted}
327
+ cursorColor={theme.text}
328
+ backgroundColor="transparent"
329
+ focusedBackgroundColor="transparent"
330
+ flexGrow={1}
331
+ />
332
+ {pop.ghost && active && rows === 1 ? (
333
+ <box position="absolute" top={0} left={2 + input.length} height={1}>
334
+ <text fg={theme.textMuted}>{pop.ghost}</text>
335
+ </box>
336
+ ) : null}
337
+ </box>
338
+
339
+ <box height={1} flexDirection="row" paddingX={1}>
340
+ <text>
341
+ <span fg={dot}>● </span>
342
+ <span fg={theme.textMuted}>{label}</span>
343
+ </text>
344
+ </box>
345
+ </box>
346
+ )
347
+ }))
@@ -0,0 +1,116 @@
1
+ import { memo } from "react"
2
+ import { useTheme } from "../../theme"
3
+
4
+ // Strip ANSI escape sequences — the gateway's inline_diff may arrive
5
+ // pre-colored for a pty and OpenTUI rendering it as literal text
6
+ // produces garble plus our own theme-coloring applied on top.
7
+ // eslint-disable-next-line no-control-regex
8
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
9
+
10
+ /** Heuristic: unified-diff output from patch/edit tools. */
11
+ export function isDiff(s: string | undefined): boolean {
12
+ if (!s) return false
13
+ return /^--- a\//m.test(s) || /^@@ /m.test(s) || /^diff --git /m.test(s)
14
+ }
15
+
16
+ const CAP = 80
17
+ const WORD_CAP = 40
18
+
19
+ type Seg = { text: string; hi: boolean }
20
+
21
+ const tokens = (s: string) => s.split(/(\s+)/).filter(t => t.length > 0)
22
+
23
+ /** Common-prefix/suffix word diff. Returns segments for (old, new). */
24
+ export function wordDiff(a: string, b: string): [Seg[], Seg[]] {
25
+ const ta = tokens(a)
26
+ const tb = tokens(b)
27
+ let p = 0
28
+ while (p < ta.length && p < tb.length && ta[p] === tb[p]) p++
29
+ let s = 0
30
+ while (s < ta.length - p && s < tb.length - p && ta[ta.length - 1 - s] === tb[tb.length - 1 - s]) s++
31
+ const seg = (t: string[]) => {
32
+ const mid = t.slice(p, t.length - s).join("")
33
+ const out: Seg[] = []
34
+ if (p) out.push({ text: t.slice(0, p).join(""), hi: false })
35
+ if (mid) out.push({ text: mid, hi: true })
36
+ if (s) out.push({ text: t.slice(t.length - s).join(""), hi: false })
37
+ return out
38
+ }
39
+ return [seg(ta), seg(tb)]
40
+ }
41
+
42
+ /**
43
+ * Pair runs of `-` lines immediately followed by `+` lines index-wise
44
+ * and compute intra-line word segments. Returns null for lines that
45
+ * stay whole-line colored. Skipped entirely above WORD_CAP.
46
+ */
47
+ export function intraline(rows: string[]): (Seg[] | null)[] {
48
+ const marks: (Seg[] | null)[] = rows.map(() => null)
49
+ if (rows.length > WORD_CAP) return marks
50
+ const del = (l: string) => l.startsWith("-") && !l.startsWith("---")
51
+ const add = (l: string) => l.startsWith("+") && !l.startsWith("+++")
52
+ let i = 0
53
+ while (i < rows.length) {
54
+ if (!del(rows[i])) { i++; continue }
55
+ let j = i
56
+ while (j < rows.length && del(rows[j])) j++
57
+ let k = j
58
+ while (k < rows.length && add(rows[k])) k++
59
+ const n = Math.min(j - i, k - j)
60
+ for (let d = 0; d < n; d++) {
61
+ const [rm, ad] = wordDiff(rows[i + d].slice(1), rows[j + d].slice(1))
62
+ marks[i + d] = rm
63
+ marks[j + d] = ad
64
+ }
65
+ i = k
66
+ }
67
+ return marks
68
+ }
69
+
70
+ /**
71
+ * Line-colored unified diff. OpenTUI ships a native `<diff>` renderable
72
+ * (split/unified, line numbers), but it manages its own scroll regions
73
+ * and height — heavy for an inline preview nested inside the chat
74
+ * scrollbox. This block renders one `<text>` per line with theme colors
75
+ * and a hard 80-line cap so layout stays stable. For small diffs
76
+ * (≤40 lines) paired -/+ change lines get word-level bg highlights.
77
+ */
78
+ export const DiffBlock = memo(({ text }: { text: string }) => {
79
+ const theme = useTheme().theme
80
+ const all = text.replace(ANSI, "").replace(/\n$/, "").split("\n")
81
+ const rows = all.slice(0, CAP)
82
+ const more = all.length - rows.length
83
+ const marks = intraline(rows)
84
+
85
+ const fg = (l: string) =>
86
+ l.startsWith("@@") ? theme.accent
87
+ : l.startsWith("+++") || l.startsWith("---") ? theme.textMuted
88
+ : l.startsWith("+") ? theme.success
89
+ : l.startsWith("-") ? theme.error
90
+ : theme.textMuted
91
+
92
+ return (
93
+ <box flexDirection="column" backgroundColor={theme.backgroundPanel}>
94
+ {rows.map((l, i) => {
95
+ const segs = marks[i]
96
+ const bg = l.startsWith("+") ? theme.diffAddedBg : theme.diffRemovedBg
97
+ return (
98
+ <box key={i} height={1} overflow="hidden" minWidth={0}>
99
+ <text fg={fg(l)}>
100
+ {segs
101
+ ? <>{l[0]}{segs.map((s, j) => s.hi
102
+ ? <span key={j} bg={bg}>{s.text}</span>
103
+ : s.text)}</>
104
+ : l || " "}
105
+ </text>
106
+ </box>
107
+ )
108
+ })}
109
+ {more > 0 ? (
110
+ <box height={1}>
111
+ <text fg={theme.textMuted}>… {more} more lines</text>
112
+ </box>
113
+ ) : null}
114
+ </box>
115
+ )
116
+ })
@@ -0,0 +1,70 @@
1
+ // Turn-level error card — a failed assistant turn (API error, agent
2
+ // crash) renders this in place of the ✗ one-liner. Uses the same
3
+ // panel grammar as BlockTool (┃ left bar, backgroundPanel) so it
4
+ // reads as "a block in the trail that happens to be red."
5
+
6
+ import { memo, useEffect, useState } from "react"
7
+ import { LEFT_BAR } from "../../ui/borders"
8
+ import { useTheme } from "../../theme"
9
+ import { copy } from "../../utils/clipboard"
10
+
11
+ const CAP = 6
12
+
13
+ export const ErrorBlock = memo(({ text }: { text: string }) => {
14
+ const theme = useTheme().theme
15
+ const [open, setOpen] = useState(false)
16
+ const [copied, setCopied] = useState(false)
17
+
18
+ const lines = text.trimEnd().split("\n")
19
+ const head = lines[0] || "Error"
20
+ const body = lines.slice(1)
21
+ const over = body.length > CAP
22
+ const shown = open || !over ? body : body.slice(0, CAP)
23
+
24
+ useEffect(() => {
25
+ if (!copied) return
26
+ const t = setTimeout(() => setCopied(false), 1500)
27
+ return () => clearTimeout(t)
28
+ }, [copied])
29
+
30
+ const doCopy = () => {
31
+ void copy(text)
32
+ setCopied(true)
33
+ }
34
+
35
+ return (
36
+ <box
37
+ border={["left"]}
38
+ borderColor={theme.error}
39
+ customBorderChars={LEFT_BAR}
40
+ backgroundColor={theme.backgroundPanel}
41
+ paddingLeft={2}
42
+ paddingTop={1}
43
+ paddingBottom={1}
44
+ marginTop={1}
45
+ flexDirection="column"
46
+ gap={1}
47
+ >
48
+ <box flexDirection="row" height={1}>
49
+ <box flexGrow={1}>
50
+ <text><span fg={theme.error}>✗ </span><span fg={theme.text}>{head}</span></text>
51
+ </box>
52
+ <box onMouseDown={doCopy} paddingX={1}>
53
+ <text fg={copied ? theme.success : theme.textMuted}>{copied ? "copied" : "copy"}</text>
54
+ </box>
55
+ </box>
56
+ {shown.length ? (
57
+ <box flexDirection="column" onMouseDown={over ? () => setOpen(o => !o) : undefined}>
58
+ {shown.map((l, i) => (
59
+ <box key={i} height={1}><text fg={theme.textMuted}>{l || " "}</text></box>
60
+ ))}
61
+ {over ? (
62
+ <box height={1}>
63
+ <text fg={theme.textMuted}>{open ? "Click to collapse" : `… ${body.length - CAP} more — click to expand`}</text>
64
+ </box>
65
+ ) : null}
66
+ </box>
67
+ ) : null}
68
+ </box>
69
+ )
70
+ })