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.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +82 -0
- package/scripts/postinstall.ts +29 -0
- package/src/app/gateway.tsx +83 -0
- package/src/app/gatewayEvents.ts +203 -0
- package/src/app/launch.ts +41 -0
- package/src/app/skin.tsx +31 -0
- package/src/app/spawnHistory.ts +75 -0
- package/src/app/tabs.ts +23 -0
- package/src/app/turnReducer.ts +390 -0
- package/src/app/useAppKeys.ts +268 -0
- package/src/app/useAtRefPopover.ts +99 -0
- package/src/app/useInputHistory.ts +66 -0
- package/src/app/useSession.ts +102 -0
- package/src/app/useSlashCommands.ts +70 -0
- package/src/app/useSlashPopover.ts +48 -0
- package/src/app.tsx +917 -0
- package/src/commands/slash.ts +151 -0
- package/src/components/avatar/AnimatedAvatar.tsx +66 -0
- package/src/components/avatar/eikon.ts +144 -0
- package/src/components/avatar/states/error.ts +1155 -0
- package/src/components/avatar/states/idle.ts +1155 -0
- package/src/components/avatar/states/index.ts +30 -0
- package/src/components/avatar/states/listening.ts +1155 -0
- package/src/components/avatar/states/speaking.ts +1155 -0
- package/src/components/avatar/states/thinking.ts +1155 -0
- package/src/components/avatar/states/working.ts +1155 -0
- package/src/components/chat/AtRefPopover.tsx +54 -0
- package/src/components/chat/CodeBlock.tsx +67 -0
- package/src/components/chat/Composer.tsx +347 -0
- package/src/components/chat/DiffBlock.tsx +116 -0
- package/src/components/chat/ErrorBlock.tsx +70 -0
- package/src/components/chat/MediaChip.tsx +114 -0
- package/src/components/chat/MessageItem.tsx +282 -0
- package/src/components/chat/MessageList.tsx +114 -0
- package/src/components/chat/PromptCard.tsx +359 -0
- package/src/components/chat/SlashPopover.tsx +158 -0
- package/src/components/chat/ThoughtCloud.tsx +185 -0
- package/src/components/chat/TypingIndicator.tsx +25 -0
- package/src/components/chat/tool/Subagent.tsx +75 -0
- package/src/components/chat/tool/frame.tsx +69 -0
- package/src/components/chat/tool/index.tsx +65 -0
- package/src/components/chat/tool/preview.ts +57 -0
- package/src/components/sidebar/ContextGauge.tsx +102 -0
- package/src/components/sidebar/Sidebar.tsx +143 -0
- package/src/components/tabs/TabBar.tsx +50 -0
- package/src/components/ui/FileLink.tsx +52 -0
- package/src/config/index.ts +156 -0
- package/src/config/lane.ts +161 -0
- package/src/config/models.ts +95 -0
- package/src/config/rules.ts +80 -0
- package/src/config/schema.ts +308 -0
- package/src/dialogs/alert.tsx +52 -0
- package/src/dialogs/chafa.tsx +72 -0
- package/src/dialogs/confirm.tsx +58 -0
- package/src/dialogs/curator.tsx +153 -0
- package/src/dialogs/eikon-picker.tsx +95 -0
- package/src/dialogs/help.tsx +80 -0
- package/src/dialogs/history.tsx +92 -0
- package/src/dialogs/info.tsx +115 -0
- package/src/dialogs/keys.tsx +170 -0
- package/src/dialogs/logs.tsx +42 -0
- package/src/dialogs/message.tsx +38 -0
- package/src/dialogs/model-picker.tsx +123 -0
- package/src/dialogs/new-profile.tsx +69 -0
- package/src/dialogs/new-task.tsx +103 -0
- package/src/dialogs/profile.tsx +55 -0
- package/src/dialogs/rollback.tsx +190 -0
- package/src/dialogs/spawn-history.tsx +80 -0
- package/src/dialogs/text-prompt.tsx +68 -0
- package/src/dialogs/theme-picker.tsx +50 -0
- package/src/home/index.ts +23 -0
- package/src/home/store.ts +267 -0
- package/src/index.tsx +113 -0
- package/src/keys/catalog.ts +115 -0
- package/src/keys/chord.ts +125 -0
- package/src/keys/conflicts.ts +48 -0
- package/src/keys/context.tsx +112 -0
- package/src/keys/index.ts +5 -0
- package/src/keys/list.ts +94 -0
- package/src/keys/oc-compat.ts +87 -0
- package/src/tabs/Agents.tsx +607 -0
- package/src/tabs/Analytics.tsx +154 -0
- package/src/tabs/Chat.tsx +50 -0
- package/src/tabs/Config.tsx +605 -0
- package/src/tabs/Context.tsx +599 -0
- package/src/tabs/Cron.tsx +294 -0
- package/src/tabs/Env.tsx +227 -0
- package/src/tabs/Kanban.tsx +367 -0
- package/src/tabs/Memory.tsx +294 -0
- package/src/tabs/Sessions.tsx +786 -0
- package/src/tabs/Skills.tsx +507 -0
- package/src/tabs/Toolsets.tsx +266 -0
- package/src/theme/builtin.ts +78 -0
- package/src/theme/context.tsx +106 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/resolve.ts +134 -0
- package/src/theme/syntax.ts +31 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +119 -0
- package/src/types/message.ts +97 -0
- package/src/ui/ChafaImage.tsx +64 -0
- package/src/ui/Splash.tsx +118 -0
- package/src/ui/borders.ts +28 -0
- package/src/ui/command.tsx +104 -0
- package/src/ui/dialog-select.tsx +164 -0
- package/src/ui/dialog.tsx +102 -0
- package/src/ui/fmt.ts +82 -0
- package/src/ui/kv.tsx +28 -0
- package/src/ui/shell.tsx +45 -0
- package/src/ui/spinner.tsx +59 -0
- package/src/ui/splash-art.ts +123 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/ticker.tsx +90 -0
- package/src/ui/toast.tsx +130 -0
- package/src/utils/categorical.ts +77 -0
- package/src/utils/chafa.ts +173 -0
- package/src/utils/clipboard.ts +67 -0
- package/src/utils/context-segments.ts +317 -0
- package/src/utils/control.ts +495 -0
- package/src/utils/drop.ts +25 -0
- package/src/utils/editor.ts +33 -0
- package/src/utils/fuzzy.ts +45 -0
- package/src/utils/gateway-client.ts +253 -0
- package/src/utils/gateway-types.ts +282 -0
- package/src/utils/git.ts +57 -0
- package/src/utils/hermes-analytics.ts +134 -0
- package/src/utils/hermes-home.ts +821 -0
- package/src/utils/hermes-kanban.ts +154 -0
- package/src/utils/hermes-profiles.ts +217 -0
- package/src/utils/interpolate.ts +31 -0
- package/src/utils/math-unicode.ts +818 -0
- package/src/utils/memory-activity.ts +140 -0
- package/src/utils/open-file.ts +13 -0
- package/src/utils/paths.ts +52 -0
- package/src/utils/perf.ts +235 -0
- package/src/utils/preferences.ts +150 -0
- package/src/utils/sessions-db.ts +396 -0
- package/src/utils/subagent-tree.ts +146 -0
- package/src/utils/terminal-reset.ts +129 -0
- package/src/utils/tips.ts +67 -0
- package/src/utils/tokens.ts +87 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* control.ts — HTTP control server for headless/automated interaction.
|
|
3
|
+
*
|
|
4
|
+
* Runs on CONTROL_PORT (default 7777) when CONTROL=1 env is set.
|
|
5
|
+
* Exposes imperative actions: tab navigation, message sending, perf dumps,
|
|
6
|
+
* key injection, and DOM queries for automated testing.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* CONTROL=1 bun run dev # start with control server
|
|
10
|
+
* curl localhost:7777/status # get app state
|
|
11
|
+
* curl localhost:7777/tab/3 # switch to Sessions tab
|
|
12
|
+
* curl -X POST localhost:7777/send -d '{"message":"hello"}'
|
|
13
|
+
* curl -X POST localhost:7777/key -d '{"name":"tab"}'
|
|
14
|
+
* curl localhost:7777/focus # get focus tree
|
|
15
|
+
* curl localhost:7777/perf # dump perf report
|
|
16
|
+
*
|
|
17
|
+
* The bridge is set by AppInner via control.setBridge({...}).
|
|
18
|
+
*
|
|
19
|
+
* SAFETY: Key injection is blocked for keys that would mutate state
|
|
20
|
+
* on dangerous tabs (Config, Sessions) unless safe=false is passed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as perf from "./perf"
|
|
24
|
+
import { TABS, TAB_MAX, CHAT_TAB } from "../app/tabs"
|
|
25
|
+
|
|
26
|
+
const PORT = Number(process.env.CONTROL_PORT) || 7777
|
|
27
|
+
export const enabled = process.env.CONTROL === "1"
|
|
28
|
+
|
|
29
|
+
const TAB_NAMES: readonly string[] = TABS.map(t => t.name)
|
|
30
|
+
|
|
31
|
+
type Bridge = {
|
|
32
|
+
tab: () => number
|
|
33
|
+
setTab: (n: number) => void
|
|
34
|
+
send: (msg: string) => void
|
|
35
|
+
ready: () => boolean
|
|
36
|
+
streaming: () => boolean
|
|
37
|
+
messages: () => number
|
|
38
|
+
session: () => string
|
|
39
|
+
input: () => string
|
|
40
|
+
setInput: (v: string) => void
|
|
41
|
+
focusRegion: () => "input" | "content"
|
|
42
|
+
setFocusRegion: (r: "input" | "content") => void
|
|
43
|
+
renderer: () => unknown // OpenTUI renderer instance
|
|
44
|
+
logs: (n?: number) => string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let bridge: Bridge | null = null
|
|
48
|
+
let pendingTab: number | null = null
|
|
49
|
+
|
|
50
|
+
export function setBridge(b: Bridge) {
|
|
51
|
+
bridge = b
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function currentTab(): number {
|
|
55
|
+
if (pendingTab !== null) return pendingTab
|
|
56
|
+
return bridge?.tab() ?? 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const json = (data: unknown, status = 200) =>
|
|
60
|
+
new Response(JSON.stringify(data), {
|
|
61
|
+
status,
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Keys that can mutate state on specific tabs
|
|
66
|
+
const DANGEROUS_KEYS: Record<number, Set<string>> = {
|
|
67
|
+
1: new Set(["return"]), // Chat: Enter sends message
|
|
68
|
+
3: new Set(["d", "delete", "return"]), // Sessions: d=delete, Enter=switch session
|
|
69
|
+
8: new Set(["space", "return", "h", "l", "]", "[", "ctrl+s"]), // Config: toggles, edits, save
|
|
70
|
+
9: new Set(["return", "space", "d", "delete"]), // Env: potential mutations
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isDangerous(tab: number, keyName: string, ctrl: boolean): boolean {
|
|
74
|
+
const set = DANGEROUS_KEYS[tab]
|
|
75
|
+
if (!set) return false
|
|
76
|
+
const id = ctrl ? `ctrl+${keyName}` : keyName
|
|
77
|
+
return set.has(id)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Key injection ───────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
interface ParsedKey {
|
|
83
|
+
name: string
|
|
84
|
+
ctrl: boolean
|
|
85
|
+
meta: boolean
|
|
86
|
+
shift: boolean
|
|
87
|
+
option: boolean
|
|
88
|
+
sequence: string
|
|
89
|
+
number: boolean
|
|
90
|
+
raw: string
|
|
91
|
+
eventType: "press" | "release"
|
|
92
|
+
source: "raw" | "kitty"
|
|
93
|
+
repeated?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeKey(opts: {
|
|
97
|
+
name: string
|
|
98
|
+
ctrl?: boolean
|
|
99
|
+
shift?: boolean
|
|
100
|
+
meta?: boolean
|
|
101
|
+
raw?: string
|
|
102
|
+
}): ParsedKey {
|
|
103
|
+
return {
|
|
104
|
+
name: opts.name,
|
|
105
|
+
ctrl: opts.ctrl ?? false,
|
|
106
|
+
meta: opts.meta ?? false,
|
|
107
|
+
shift: opts.shift ?? false,
|
|
108
|
+
option: false,
|
|
109
|
+
sequence: opts.raw ?? opts.name,
|
|
110
|
+
number: false,
|
|
111
|
+
raw: opts.raw ?? opts.name,
|
|
112
|
+
eventType: "press",
|
|
113
|
+
source: "raw",
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function injectKey(renderer: unknown, key: ParsedKey): boolean {
|
|
118
|
+
const r = renderer as { keyInput?: { processParsedKey?: (k: ParsedKey) => boolean } }
|
|
119
|
+
if (!r?.keyInput?.processParsedKey) return false
|
|
120
|
+
return r.keyInput.processParsedKey(key)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Focus tree query ────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
interface FocusNode {
|
|
126
|
+
type: string
|
|
127
|
+
focused: boolean
|
|
128
|
+
focusable: boolean
|
|
129
|
+
children: FocusNode[]
|
|
130
|
+
text?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type AnyNode = {
|
|
134
|
+
constructor?: { name?: string }
|
|
135
|
+
focused?: boolean
|
|
136
|
+
focusable?: boolean
|
|
137
|
+
getChildren?: () => AnyNode[]
|
|
138
|
+
getChildrenCount?: () => number
|
|
139
|
+
_childrenInLayoutOrder?: AnyNode[]
|
|
140
|
+
textContent?: string
|
|
141
|
+
text?: string
|
|
142
|
+
value?: string
|
|
143
|
+
id?: string
|
|
144
|
+
_type?: string
|
|
145
|
+
tagName?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getNodeChildren(n: AnyNode): AnyNode[] {
|
|
149
|
+
if (n.getChildren) return n.getChildren()
|
|
150
|
+
if (n._childrenInLayoutOrder) return [...n._childrenInLayoutOrder]
|
|
151
|
+
return []
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getNodeType(n: AnyNode): string {
|
|
155
|
+
return n._type || n.tagName || n.constructor?.name || "unknown"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildFocusTree(node: unknown, depth = 0): FocusNode | null {
|
|
159
|
+
if (!node || typeof node !== "object") return null
|
|
160
|
+
const n = node as AnyNode
|
|
161
|
+
|
|
162
|
+
const type = getNodeType(n)
|
|
163
|
+
const focused = n.focused ?? false
|
|
164
|
+
const focusable = n.focusable ?? false
|
|
165
|
+
const children: FocusNode[] = []
|
|
166
|
+
|
|
167
|
+
if (depth < 20) {
|
|
168
|
+
for (const child of getNodeChildren(n)) {
|
|
169
|
+
const c = buildFocusTree(child, depth + 1)
|
|
170
|
+
if (c) children.push(c)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Skip non-focusable nodes with no focusable descendants
|
|
175
|
+
const hasFocusable = focusable || children.some(c =>
|
|
176
|
+
c.focusable || c.focused || c.children.length > 0
|
|
177
|
+
)
|
|
178
|
+
if (!hasFocusable && !focused && depth > 0) return null
|
|
179
|
+
|
|
180
|
+
const text = (n.value || n.textContent || n.text || undefined) as string | undefined
|
|
181
|
+
|
|
182
|
+
return { type, focused, focusable, children, text }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function findFocused(node: unknown): string | null {
|
|
186
|
+
if (!node || typeof node !== "object") return null
|
|
187
|
+
const n = node as AnyNode
|
|
188
|
+
if (n.focused) return getNodeType(n)
|
|
189
|
+
for (const child of getNodeChildren(n)) {
|
|
190
|
+
const found = findFocused(child)
|
|
191
|
+
if (found) return found
|
|
192
|
+
}
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function countNodes(node: unknown): { total: number; focusable: number; focused: number } {
|
|
197
|
+
const result = { total: 0, focusable: 0, focused: 0 }
|
|
198
|
+
function walk(n: unknown) {
|
|
199
|
+
if (!n || typeof n !== "object") return
|
|
200
|
+
const nd = n as AnyNode
|
|
201
|
+
result.total++
|
|
202
|
+
if (nd.focusable) result.focusable++
|
|
203
|
+
if (nd.focused) result.focused++
|
|
204
|
+
for (const child of getNodeChildren(nd)) walk(child)
|
|
205
|
+
}
|
|
206
|
+
walk(node)
|
|
207
|
+
return result
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Request handler ─────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
async function handle(req: Request): Promise<Response> {
|
|
213
|
+
const url = new URL(req.url)
|
|
214
|
+
const path = url.pathname
|
|
215
|
+
|
|
216
|
+
if (!bridge) return json({ error: "bridge not ready" }, 503)
|
|
217
|
+
|
|
218
|
+
// GET /status — app state snapshot
|
|
219
|
+
if (path === "/status") {
|
|
220
|
+
const m = process.memoryUsage()
|
|
221
|
+
const tab = currentTab()
|
|
222
|
+
// Clear pending tab once React has had time to commit
|
|
223
|
+
pendingTab = null
|
|
224
|
+
return json({
|
|
225
|
+
tab,
|
|
226
|
+
tabName: TAB_NAMES[tab] ?? "unknown",
|
|
227
|
+
ready: bridge.ready(),
|
|
228
|
+
streaming: bridge.streaming(),
|
|
229
|
+
messages: bridge.messages(),
|
|
230
|
+
session: bridge.session(),
|
|
231
|
+
input: bridge.input(),
|
|
232
|
+
focusRegion: bridge.focusRegion(),
|
|
233
|
+
rss: Math.round(m.rss / 1024 / 1024),
|
|
234
|
+
heap: Math.round(m.heapUsed / 1024 / 1024),
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// GET /tab/:n — switch tab by injecting Ctrl+Right/Left key events
|
|
239
|
+
const tabMatch = path.match(/^\/tab\/(\d+)$/)
|
|
240
|
+
if (tabMatch) {
|
|
241
|
+
const n = Number(tabMatch[1])
|
|
242
|
+
if (n < 0 || n > TAB_MAX) return json({ error: `tab 0-${TAB_MAX}` }, 400)
|
|
243
|
+
|
|
244
|
+
const renderer = bridge.renderer()
|
|
245
|
+
if (renderer) {
|
|
246
|
+
// Inject Ctrl+Left/Right keys to navigate to target tab
|
|
247
|
+
const cur = bridge.tab()
|
|
248
|
+
const diff = n - cur
|
|
249
|
+
if (diff !== 0) {
|
|
250
|
+
const keyName = diff > 0 ? "right" : "left"
|
|
251
|
+
const steps = Math.abs(diff)
|
|
252
|
+
for (let i = 0; i < steps; i++) {
|
|
253
|
+
injectKey(renderer, makeKey({ name: keyName, ctrl: true }))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
// Fallback to direct setState (may not work reliably)
|
|
258
|
+
bridge.setTab(n)
|
|
259
|
+
}
|
|
260
|
+
pendingTab = n
|
|
261
|
+
return json({ tab: n, tabName: TAB_NAMES[n] })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// POST /send — send a message
|
|
265
|
+
if (path === "/send" && req.method === "POST") {
|
|
266
|
+
const body = await req.json() as { message?: string }
|
|
267
|
+
if (!body.message) return json({ error: "message required" }, 400)
|
|
268
|
+
if (!bridge.ready()) return json({ error: "not connected" }, 503)
|
|
269
|
+
if (bridge.streaming()) return json({ error: "already streaming" }, 409)
|
|
270
|
+
bridge.send(body.message)
|
|
271
|
+
return json({ sent: true, message: body.message })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// POST /key — inject a key event
|
|
275
|
+
//
|
|
276
|
+
// Body: { name: "tab", ctrl?: bool, shift?: bool, meta?: bool, raw?: string, safe?: bool }
|
|
277
|
+
//
|
|
278
|
+
// safe (default true): blocks keys known to mutate state on current tab.
|
|
279
|
+
// Set safe=false to override (use for intentional mutation testing).
|
|
280
|
+
if (path === "/key" && req.method === "POST") {
|
|
281
|
+
const body = await req.json() as {
|
|
282
|
+
name?: string
|
|
283
|
+
ctrl?: boolean
|
|
284
|
+
shift?: boolean
|
|
285
|
+
meta?: boolean
|
|
286
|
+
raw?: string
|
|
287
|
+
safe?: boolean
|
|
288
|
+
}
|
|
289
|
+
if (!body.name) return json({ error: "name required" }, 400)
|
|
290
|
+
|
|
291
|
+
const renderer = bridge.renderer()
|
|
292
|
+
if (!renderer) return json({ error: "renderer not available" }, 503)
|
|
293
|
+
|
|
294
|
+
const safe = body.safe !== false // default true
|
|
295
|
+
const tab = currentTab()
|
|
296
|
+
|
|
297
|
+
if (safe && isDangerous(tab, body.name, !!body.ctrl)) {
|
|
298
|
+
return json({
|
|
299
|
+
error: "blocked",
|
|
300
|
+
reason: `Key "${body.ctrl ? "ctrl+" : ""}${body.name}" is dangerous on tab ${TAB_NAMES[tab]} (index ${tab}). Pass safe=false to override.`,
|
|
301
|
+
tab,
|
|
302
|
+
tabName: TAB_NAMES[tab],
|
|
303
|
+
}, 403)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const key = makeKey({
|
|
307
|
+
name: body.name,
|
|
308
|
+
ctrl: body.ctrl,
|
|
309
|
+
shift: body.shift,
|
|
310
|
+
meta: body.meta,
|
|
311
|
+
raw: body.raw ?? (body.name.length === 1 ? body.name : ""),
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const handled = injectKey(renderer, key)
|
|
315
|
+
return json({ injected: true, handled, key: body.name, tab, tabName: TAB_NAMES[tab] })
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// POST /keys — inject a sequence of key events
|
|
319
|
+
//
|
|
320
|
+
// Body: { keys: [{name, ctrl?, ...}, ...], delay?: number, safe?: bool }
|
|
321
|
+
if (path === "/keys" && req.method === "POST") {
|
|
322
|
+
const body = await req.json() as {
|
|
323
|
+
keys?: Array<{ name: string; ctrl?: boolean; shift?: boolean; meta?: boolean; raw?: string }>
|
|
324
|
+
delay?: number
|
|
325
|
+
safe?: boolean
|
|
326
|
+
}
|
|
327
|
+
if (!body.keys?.length) return json({ error: "keys array required" }, 400)
|
|
328
|
+
|
|
329
|
+
const renderer = bridge.renderer()
|
|
330
|
+
if (!renderer) return json({ error: "renderer not available" }, 503)
|
|
331
|
+
|
|
332
|
+
const safe = body.safe !== false
|
|
333
|
+
const tab = currentTab()
|
|
334
|
+
const delay = body.delay ?? 0
|
|
335
|
+
const results: Array<{ key: string; injected: boolean; handled: boolean; blocked?: boolean }> = []
|
|
336
|
+
|
|
337
|
+
for (const k of body.keys) {
|
|
338
|
+
if (safe && isDangerous(currentTab(), k.name, !!k.ctrl)) {
|
|
339
|
+
results.push({ key: k.name, injected: false, handled: false, blocked: true })
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
const key = makeKey({
|
|
343
|
+
name: k.name,
|
|
344
|
+
ctrl: k.ctrl,
|
|
345
|
+
shift: k.shift,
|
|
346
|
+
meta: k.meta,
|
|
347
|
+
raw: k.raw ?? (k.name.length === 1 ? k.name : ""),
|
|
348
|
+
})
|
|
349
|
+
const handled = injectKey(renderer, key)
|
|
350
|
+
results.push({ key: k.name, injected: true, handled })
|
|
351
|
+
if (delay > 0) await new Promise(r => setTimeout(r, delay))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return json({ results, tab, tabName: TAB_NAMES[tab] })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// POST /type — inject a string as individual keystrokes
|
|
358
|
+
//
|
|
359
|
+
// Body: { text: "hello", safe?: bool, delay?: ms }
|
|
360
|
+
// delay paces characters for cinematic typing (demo recordings).
|
|
361
|
+
if (path === "/type" && req.method === "POST") {
|
|
362
|
+
const body = await req.json() as { text?: string; safe?: boolean; delay?: number }
|
|
363
|
+
if (!body.text) return json({ error: "text required" }, 400)
|
|
364
|
+
|
|
365
|
+
const renderer = bridge.renderer()
|
|
366
|
+
if (!renderer) return json({ error: "renderer not available" }, 503)
|
|
367
|
+
|
|
368
|
+
const safe = body.safe !== false
|
|
369
|
+
const tab = currentTab()
|
|
370
|
+
const delay = body.delay ?? 0
|
|
371
|
+
let count = 0
|
|
372
|
+
|
|
373
|
+
for (const ch of body.text) {
|
|
374
|
+
if (safe && isDangerous(tab, ch, false)) continue
|
|
375
|
+
const key = makeKey({ name: ch, raw: ch })
|
|
376
|
+
injectKey(renderer, key)
|
|
377
|
+
count++
|
|
378
|
+
if (delay > 0) await new Promise(r => setTimeout(r, delay))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return json({ typed: count, total: body.text.length, tab, tabName: TAB_NAMES[tab] })
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// POST /input — set composer value in one shot (no per-char keys).
|
|
385
|
+
if (path === "/input" && req.method === "POST") {
|
|
386
|
+
const body = await req.json() as { text?: string }
|
|
387
|
+
bridge.setInput(body.text ?? "")
|
|
388
|
+
return json({ ok: true, text: body.text ?? "" })
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// GET /quit — clean exit so a recording PTY sees EOF. Macrotask so the
|
|
392
|
+
// 200 flushes before the process dies.
|
|
393
|
+
if (path === "/quit") {
|
|
394
|
+
setTimeout(() => process.exit(0), 10)
|
|
395
|
+
return json({ ok: true })
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// GET /focus — focus tree (focusable elements and their state)
|
|
399
|
+
if (path === "/focus") {
|
|
400
|
+
const r = bridge.renderer() as {
|
|
401
|
+
root?: unknown
|
|
402
|
+
currentFocusedRenderable?: AnyNode | null
|
|
403
|
+
} | null
|
|
404
|
+
if (!r?.root) return json({ error: "no renderer root" }, 503)
|
|
405
|
+
const counts = countNodes(r.root)
|
|
406
|
+
const tree = buildFocusTree(r.root)
|
|
407
|
+
const focused = findFocused(r.root)
|
|
408
|
+
const currentFocus = r.currentFocusedRenderable
|
|
409
|
+
? getNodeType(r.currentFocusedRenderable)
|
|
410
|
+
: null
|
|
411
|
+
return json({ focused, currentFocus, counts, tree })
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// GET /frame — current screen buffer as plain text. `?grep=pat` returns
|
|
415
|
+
// only matching lines. `?json=1` wraps in {frame, match, lines}.
|
|
416
|
+
if (path === "/frame") {
|
|
417
|
+
const r = bridge.renderer() as {
|
|
418
|
+
currentRenderBuffer?: { getRealCharBytes(nl: boolean): Uint8Array }
|
|
419
|
+
} | null
|
|
420
|
+
if (!r?.currentRenderBuffer) return json({ error: "no render buffer" }, 503)
|
|
421
|
+
const frame = new TextDecoder().decode(r.currentRenderBuffer.getRealCharBytes(true))
|
|
422
|
+
const grep = url.searchParams.get("grep")
|
|
423
|
+
const body = grep ? frame.split("\n").filter(l => l.includes(grep)).join("\n") : frame
|
|
424
|
+
if (url.searchParams.get("json") === "1") {
|
|
425
|
+
return json({
|
|
426
|
+
frame: body,
|
|
427
|
+
match: grep ? frame.includes(grep) : undefined,
|
|
428
|
+
lines: frame.split("\n").length,
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } })
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// GET /logs?n=N — gateway stderr ring buffer (same source as /logs dialog)
|
|
435
|
+
if (path === "/logs") {
|
|
436
|
+
const n = Number(url.searchParams.get("n")) || 200
|
|
437
|
+
return new Response(bridge.logs(n), { headers: { "Content-Type": "text/plain; charset=utf-8" } })
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// GET /perf — return all profiling data as JSON
|
|
441
|
+
if (path === "/perf") {
|
|
442
|
+
const d = perf.data()
|
|
443
|
+
if (!d) return json({ error: "PERF not enabled" }, 400)
|
|
444
|
+
return json(d)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// GET /tabs — cycle through all tabs with a delay
|
|
448
|
+
if (path === "/tabs") {
|
|
449
|
+
const ms = Number(url.searchParams.get("delay") || "500")
|
|
450
|
+
for (let i = 0; i <= TAB_MAX; i++) {
|
|
451
|
+
bridge.setTab(i)
|
|
452
|
+
await new Promise(r => setTimeout(r, ms))
|
|
453
|
+
}
|
|
454
|
+
bridge.setTab(CHAT_TAB)
|
|
455
|
+
return json({ cycled: TAB_MAX + 1, delay: ms })
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// GET /mem — memory snapshot
|
|
459
|
+
if (path === "/mem") {
|
|
460
|
+
perf.mem("control:snapshot")
|
|
461
|
+
const m = process.memoryUsage()
|
|
462
|
+
return json({
|
|
463
|
+
rss: Math.round(m.rss / 1024 / 1024),
|
|
464
|
+
heap: Math.round(m.heapUsed / 1024 / 1024),
|
|
465
|
+
heapTotal: Math.round(m.heapTotal / 1024 / 1024),
|
|
466
|
+
external: Math.round(m.external / 1024 / 1024),
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return json({
|
|
471
|
+
error: "not found",
|
|
472
|
+
routes: [
|
|
473
|
+
"GET /status",
|
|
474
|
+
"GET /tab/:n",
|
|
475
|
+
"POST /send {message}",
|
|
476
|
+
"POST /key {name, ctrl?, shift?, meta?, raw?, safe?}",
|
|
477
|
+
"POST /keys {keys: [{name, ...}], delay?, safe?}",
|
|
478
|
+
"POST /type {text, delay?, safe?}",
|
|
479
|
+
"POST /input {text}",
|
|
480
|
+
"GET /quit",
|
|
481
|
+
"GET /frame ?grep=pat&json=1",
|
|
482
|
+
"GET /logs ?n=200",
|
|
483
|
+
"GET /focus",
|
|
484
|
+
"GET /perf",
|
|
485
|
+
"GET /tabs",
|
|
486
|
+
"GET /mem",
|
|
487
|
+
],
|
|
488
|
+
}, 404)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function start() {
|
|
492
|
+
if (!enabled) return
|
|
493
|
+
Bun.serve({ port: PORT, fetch: handle })
|
|
494
|
+
process.stderr.write(`\x1b[90m[control] http://localhost:${PORT}\x1b[0m\n`)
|
|
495
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Cheap client-side sniff for "this paste is probably a local file path".
|
|
2
|
+
// Mirrors the starts_like_path gate in hermes cli._detect_file_drop — the
|
|
3
|
+
// gateway's input.detect_drop RPC is the authority (it stats the file and
|
|
4
|
+
// handles quoting/escapes/file://); this only decides whether to bother
|
|
5
|
+
// asking. Kept deliberately narrow so prose that happens to start with `/`
|
|
6
|
+
// (e.g. a pasted regex) still falls through to verbatim insert on miss.
|
|
7
|
+
|
|
8
|
+
/** Windows drive prefix (`C:\` or `C:/`), optionally behind a quote. */
|
|
9
|
+
const winDrive = (s: string, off = 0) =>
|
|
10
|
+
s.length >= off + 3 && /[A-Za-z]/.test(s[off]!) && s[off + 1] === ":" && (s[off + 2] === "\\" || s[off + 2] === "/")
|
|
11
|
+
|
|
12
|
+
export function looksLikePath(s: string): boolean {
|
|
13
|
+
const t = s.trim()
|
|
14
|
+
if (!t || t.includes("\n")) return false
|
|
15
|
+
if (t.startsWith("file://")) return true
|
|
16
|
+
if (t.startsWith("/") || t.startsWith("~") || t.startsWith("./") || t.startsWith("../")) return true
|
|
17
|
+
if (winDrive(t)) return true
|
|
18
|
+
const q = t[0]
|
|
19
|
+
if (q === '"' || q === "'") {
|
|
20
|
+
const inner = t[1]
|
|
21
|
+
if (inner === "/" || inner === "~") return true
|
|
22
|
+
if (winDrive(t, 1)) return true
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Suspend the renderer, open $VISUAL/$EDITOR on a tmpfile seeded with the
|
|
2
|
+
// current input, read it back. Returns undefined if no editor configured
|
|
3
|
+
// or the user emptied the file.
|
|
4
|
+
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { rm } from "node:fs/promises"
|
|
8
|
+
import type { CliRenderer } from "@opentui/core"
|
|
9
|
+
|
|
10
|
+
export async function editInEditor(renderer: CliRenderer, seed: string): Promise<string | undefined> {
|
|
11
|
+
const cmd = process.env.VISUAL || process.env.EDITOR
|
|
12
|
+
if (!cmd) return undefined
|
|
13
|
+
|
|
14
|
+
const path = join(tmpdir(), `herm-${Date.now()}.md`)
|
|
15
|
+
await Bun.write(path, seed)
|
|
16
|
+
|
|
17
|
+
renderer.suspend()
|
|
18
|
+
renderer.currentRenderBuffer.clear()
|
|
19
|
+
try {
|
|
20
|
+
const parts = cmd.split(" ")
|
|
21
|
+
const proc = Bun.spawn([...parts, path], {
|
|
22
|
+
stdin: "inherit", stdout: "inherit", stderr: "inherit",
|
|
23
|
+
})
|
|
24
|
+
await proc.exited
|
|
25
|
+
const text = await Bun.file(path).text().catch(() => "")
|
|
26
|
+
return text.trim() || undefined
|
|
27
|
+
} finally {
|
|
28
|
+
rm(path, { force: true }).catch(() => {})
|
|
29
|
+
renderer.currentRenderBuffer.clear()
|
|
30
|
+
renderer.resume()
|
|
31
|
+
renderer.requestRender()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy subsequence scorer.
|
|
3
|
+
*
|
|
4
|
+
* Returns a positive number when `needle` is a (case-insensitive) subsequence
|
|
5
|
+
* of `hay`, weighted so that tighter / earlier / boundary-aligned matches rank
|
|
6
|
+
* higher. Returns 0 when the needle cannot be found as a subsequence.
|
|
7
|
+
*
|
|
8
|
+
* Bonuses:
|
|
9
|
+
* - start of string
|
|
10
|
+
* - consecutive characters
|
|
11
|
+
* - word boundaries: after `_ - / . space` or a lower→Upper camel hump
|
|
12
|
+
* - exact prefix (guarantees prefix matches outrank scattered ones)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SEP = new Set(["-", "_", "/", " ", "."])
|
|
16
|
+
|
|
17
|
+
function boundary(hay: string, i: number) {
|
|
18
|
+
if (i === 0) return true
|
|
19
|
+
const prev = hay[i - 1]
|
|
20
|
+
if (SEP.has(prev)) return true
|
|
21
|
+
if (prev === prev.toLowerCase() && hay[i] !== hay[i].toLowerCase()) return true
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function score(needle: string, hay: string): number {
|
|
26
|
+
if (!needle) return 0
|
|
27
|
+
const n = needle.toLowerCase()
|
|
28
|
+
const h = hay.toLowerCase()
|
|
29
|
+
let pts = 0
|
|
30
|
+
let from = 0
|
|
31
|
+
let prev = -2
|
|
32
|
+
for (let i = 0; i < n.length; i++) {
|
|
33
|
+
const at = h.indexOf(n[i], from)
|
|
34
|
+
if (at < 0) return 0
|
|
35
|
+
pts += 1
|
|
36
|
+
if (at === 0) pts += 8
|
|
37
|
+
if (at === prev + 1) pts += 5
|
|
38
|
+
if (at !== prev + 1 && boundary(hay, at)) pts += 4
|
|
39
|
+
pts -= (at - (prev < 0 ? 0 : prev + 1)) * 0.1
|
|
40
|
+
prev = at
|
|
41
|
+
from = at + 1
|
|
42
|
+
}
|
|
43
|
+
if (h.startsWith(n)) pts += 100
|
|
44
|
+
return pts - hay.length * 0.01
|
|
45
|
+
}
|