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