herm-tui 1.0.0-dev.1 → 1.0.0-dev.11

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 (192) hide show
  1. package/README.md +8 -4
  2. package/assets/eikons/ares.eikon +367 -0
  3. package/assets/eikons/default.eikon +398 -0
  4. package/assets/eikons/mono.eikon +395 -0
  5. package/db.worker.js +81 -0
  6. package/highlights-eq9cgrbb.scm +604 -0
  7. package/highlights-ghv9g403.scm +205 -0
  8. package/highlights-hk7bwhj4.scm +284 -0
  9. package/highlights-r812a2qc.scm +150 -0
  10. package/highlights-x6tmsnaa.scm +115 -0
  11. package/index.js +4151 -0
  12. package/injections-73j83es3.scm +27 -0
  13. package/package.json +14 -64
  14. package/parser.worker.js +8 -0
  15. package/tree-sitter-3jzf13jk.wasm +0 -0
  16. package/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  17. package/tree-sitter-markdown-411r6y9b.wasm +0 -0
  18. package/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  19. package/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  20. package/tree-sitter-zig-e78zbjpm.wasm +0 -0
  21. package/scripts/postinstall.ts +0 -29
  22. package/src/app/gateway.tsx +0 -83
  23. package/src/app/gatewayEvents.ts +0 -203
  24. package/src/app/launch.ts +0 -41
  25. package/src/app/skin.tsx +0 -31
  26. package/src/app/spawnHistory.ts +0 -75
  27. package/src/app/tabs.ts +0 -23
  28. package/src/app/turnReducer.ts +0 -390
  29. package/src/app/useAppKeys.ts +0 -268
  30. package/src/app/useAtRefPopover.ts +0 -99
  31. package/src/app/useInputHistory.ts +0 -66
  32. package/src/app/useSession.ts +0 -102
  33. package/src/app/useSlashCommands.ts +0 -70
  34. package/src/app/useSlashPopover.ts +0 -48
  35. package/src/app.tsx +0 -917
  36. package/src/commands/slash.ts +0 -151
  37. package/src/components/avatar/AnimatedAvatar.tsx +0 -66
  38. package/src/components/avatar/eikon.ts +0 -144
  39. package/src/components/avatar/states/error.ts +0 -1155
  40. package/src/components/avatar/states/idle.ts +0 -1155
  41. package/src/components/avatar/states/index.ts +0 -30
  42. package/src/components/avatar/states/listening.ts +0 -1155
  43. package/src/components/avatar/states/speaking.ts +0 -1155
  44. package/src/components/avatar/states/thinking.ts +0 -1155
  45. package/src/components/avatar/states/working.ts +0 -1155
  46. package/src/components/chat/AtRefPopover.tsx +0 -54
  47. package/src/components/chat/CodeBlock.tsx +0 -67
  48. package/src/components/chat/Composer.tsx +0 -347
  49. package/src/components/chat/DiffBlock.tsx +0 -116
  50. package/src/components/chat/ErrorBlock.tsx +0 -70
  51. package/src/components/chat/MediaChip.tsx +0 -114
  52. package/src/components/chat/MessageItem.tsx +0 -282
  53. package/src/components/chat/MessageList.tsx +0 -114
  54. package/src/components/chat/PromptCard.tsx +0 -359
  55. package/src/components/chat/SlashPopover.tsx +0 -158
  56. package/src/components/chat/ThoughtCloud.tsx +0 -185
  57. package/src/components/chat/TypingIndicator.tsx +0 -25
  58. package/src/components/chat/tool/Subagent.tsx +0 -75
  59. package/src/components/chat/tool/frame.tsx +0 -69
  60. package/src/components/chat/tool/index.tsx +0 -65
  61. package/src/components/chat/tool/preview.ts +0 -57
  62. package/src/components/sidebar/ContextGauge.tsx +0 -102
  63. package/src/components/sidebar/Sidebar.tsx +0 -143
  64. package/src/components/tabs/TabBar.tsx +0 -50
  65. package/src/components/ui/FileLink.tsx +0 -52
  66. package/src/config/index.ts +0 -156
  67. package/src/config/lane.ts +0 -161
  68. package/src/config/models.ts +0 -95
  69. package/src/config/rules.ts +0 -80
  70. package/src/config/schema.ts +0 -308
  71. package/src/dialogs/alert.tsx +0 -52
  72. package/src/dialogs/chafa.tsx +0 -72
  73. package/src/dialogs/confirm.tsx +0 -58
  74. package/src/dialogs/curator.tsx +0 -153
  75. package/src/dialogs/eikon-picker.tsx +0 -95
  76. package/src/dialogs/help.tsx +0 -80
  77. package/src/dialogs/history.tsx +0 -92
  78. package/src/dialogs/info.tsx +0 -115
  79. package/src/dialogs/keys.tsx +0 -170
  80. package/src/dialogs/logs.tsx +0 -42
  81. package/src/dialogs/message.tsx +0 -38
  82. package/src/dialogs/model-picker.tsx +0 -123
  83. package/src/dialogs/new-profile.tsx +0 -69
  84. package/src/dialogs/new-task.tsx +0 -103
  85. package/src/dialogs/profile.tsx +0 -55
  86. package/src/dialogs/rollback.tsx +0 -190
  87. package/src/dialogs/spawn-history.tsx +0 -80
  88. package/src/dialogs/text-prompt.tsx +0 -68
  89. package/src/dialogs/theme-picker.tsx +0 -50
  90. package/src/home/index.ts +0 -23
  91. package/src/home/store.ts +0 -267
  92. package/src/index.tsx +0 -113
  93. package/src/keys/catalog.ts +0 -115
  94. package/src/keys/chord.ts +0 -125
  95. package/src/keys/conflicts.ts +0 -48
  96. package/src/keys/context.tsx +0 -112
  97. package/src/keys/index.ts +0 -5
  98. package/src/keys/list.ts +0 -94
  99. package/src/keys/oc-compat.ts +0 -87
  100. package/src/tabs/Agents.tsx +0 -607
  101. package/src/tabs/Analytics.tsx +0 -154
  102. package/src/tabs/Chat.tsx +0 -50
  103. package/src/tabs/Config.tsx +0 -605
  104. package/src/tabs/Context.tsx +0 -599
  105. package/src/tabs/Cron.tsx +0 -294
  106. package/src/tabs/Env.tsx +0 -227
  107. package/src/tabs/Kanban.tsx +0 -367
  108. package/src/tabs/Memory.tsx +0 -294
  109. package/src/tabs/Sessions.tsx +0 -786
  110. package/src/tabs/Skills.tsx +0 -507
  111. package/src/tabs/Toolsets.tsx +0 -266
  112. package/src/theme/builtin.ts +0 -78
  113. package/src/theme/context.tsx +0 -106
  114. package/src/theme/index.ts +0 -4
  115. package/src/theme/resolve.ts +0 -134
  116. package/src/theme/syntax.ts +0 -31
  117. package/src/theme/themes/aura.json +0 -69
  118. package/src/theme/themes/ayu.json +0 -80
  119. package/src/theme/themes/carbonfox.json +0 -248
  120. package/src/theme/themes/catppuccin-frappe.json +0 -233
  121. package/src/theme/themes/catppuccin-macchiato.json +0 -233
  122. package/src/theme/themes/catppuccin.json +0 -112
  123. package/src/theme/themes/cobalt2.json +0 -228
  124. package/src/theme/themes/cursor.json +0 -249
  125. package/src/theme/themes/dracula.json +0 -219
  126. package/src/theme/themes/everforest.json +0 -241
  127. package/src/theme/themes/flexoki.json +0 -237
  128. package/src/theme/themes/github.json +0 -233
  129. package/src/theme/themes/gruvbox.json +0 -242
  130. package/src/theme/themes/kanagawa.json +0 -77
  131. package/src/theme/themes/lucent-orng.json +0 -237
  132. package/src/theme/themes/material.json +0 -235
  133. package/src/theme/themes/matrix.json +0 -77
  134. package/src/theme/themes/mercury.json +0 -252
  135. package/src/theme/themes/monokai.json +0 -221
  136. package/src/theme/themes/nightowl.json +0 -221
  137. package/src/theme/themes/nord.json +0 -223
  138. package/src/theme/themes/one-dark.json +0 -84
  139. package/src/theme/themes/opencode.json +0 -245
  140. package/src/theme/themes/orng.json +0 -249
  141. package/src/theme/themes/osaka-jade.json +0 -93
  142. package/src/theme/themes/palenight.json +0 -222
  143. package/src/theme/themes/rosepine.json +0 -234
  144. package/src/theme/themes/solarized.json +0 -223
  145. package/src/theme/themes/synthwave84.json +0 -226
  146. package/src/theme/themes/tokyonight.json +0 -243
  147. package/src/theme/themes/vercel.json +0 -245
  148. package/src/theme/themes/vesper.json +0 -218
  149. package/src/theme/themes/zenburn.json +0 -223
  150. package/src/theme/types.ts +0 -119
  151. package/src/types/message.ts +0 -97
  152. package/src/ui/ChafaImage.tsx +0 -64
  153. package/src/ui/Splash.tsx +0 -118
  154. package/src/ui/borders.ts +0 -28
  155. package/src/ui/command.tsx +0 -104
  156. package/src/ui/dialog-select.tsx +0 -164
  157. package/src/ui/dialog.tsx +0 -102
  158. package/src/ui/fmt.ts +0 -82
  159. package/src/ui/kv.tsx +0 -28
  160. package/src/ui/shell.tsx +0 -45
  161. package/src/ui/spinner.tsx +0 -59
  162. package/src/ui/splash-art.ts +0 -123
  163. package/src/ui/table.tsx +0 -117
  164. package/src/ui/ticker.tsx +0 -90
  165. package/src/ui/toast.tsx +0 -130
  166. package/src/utils/categorical.ts +0 -77
  167. package/src/utils/chafa.ts +0 -173
  168. package/src/utils/clipboard.ts +0 -67
  169. package/src/utils/context-segments.ts +0 -317
  170. package/src/utils/control.ts +0 -495
  171. package/src/utils/drop.ts +0 -25
  172. package/src/utils/editor.ts +0 -33
  173. package/src/utils/fuzzy.ts +0 -45
  174. package/src/utils/gateway-client.ts +0 -253
  175. package/src/utils/gateway-types.ts +0 -282
  176. package/src/utils/git.ts +0 -57
  177. package/src/utils/hermes-analytics.ts +0 -134
  178. package/src/utils/hermes-home.ts +0 -821
  179. package/src/utils/hermes-kanban.ts +0 -154
  180. package/src/utils/hermes-profiles.ts +0 -217
  181. package/src/utils/interpolate.ts +0 -31
  182. package/src/utils/math-unicode.ts +0 -818
  183. package/src/utils/memory-activity.ts +0 -140
  184. package/src/utils/open-file.ts +0 -13
  185. package/src/utils/paths.ts +0 -52
  186. package/src/utils/perf.ts +0 -235
  187. package/src/utils/preferences.ts +0 -150
  188. package/src/utils/sessions-db.ts +0 -396
  189. package/src/utils/subagent-tree.ts +0 -146
  190. package/src/utils/terminal-reset.ts +0 -129
  191. package/src/utils/tips.ts +0 -67
  192. package/src/utils/tokens.ts +0 -87
@@ -1,54 +0,0 @@
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
- })
@@ -1,67 +0,0 @@
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
- })
@@ -1,347 +0,0 @@
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
- }))
@@ -1,116 +0,0 @@
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
- })
@@ -1,70 +0,0 @@
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
- })