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,390 @@
|
|
|
1
|
+
// Chat turn state — messages, streaming flags. Parts are appended in the
|
|
2
|
+
// order the gateway emits them (text → tool → text → …), so rendering can
|
|
3
|
+
// iterate `parts` chronologically without regrouping.
|
|
4
|
+
|
|
5
|
+
import type { Message, Part, TextPart, ToolPart, PromptPart, PromptReq, Usage } from "../types/message"
|
|
6
|
+
import { mid, pid } from "../types/message"
|
|
7
|
+
import type { SubagentPayload, TranscriptMessage } from "../utils/gateway-types"
|
|
8
|
+
|
|
9
|
+
export type TurnState = {
|
|
10
|
+
messages: Message[]
|
|
11
|
+
streaming: boolean
|
|
12
|
+
hasContent: boolean
|
|
13
|
+
toolActive: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const initialTurn: TurnState = {
|
|
17
|
+
messages: [],
|
|
18
|
+
streaming: false,
|
|
19
|
+
hasContent: false,
|
|
20
|
+
toolActive: false,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Action =
|
|
24
|
+
| { kind: "reset" }
|
|
25
|
+
| { kind: "load"; messages: Message[] }
|
|
26
|
+
| { kind: "push"; message: Message }
|
|
27
|
+
| { kind: "user"; text: string }
|
|
28
|
+
| { kind: "system"; text: string }
|
|
29
|
+
| { kind: "message.start" }
|
|
30
|
+
| { kind: "message.delta"; chunk: string }
|
|
31
|
+
| { kind: "message.complete"; text?: string; usage?: Usage }
|
|
32
|
+
| { kind: "tool.start"; id: string; name: string; preview?: string }
|
|
33
|
+
| { kind: "tool.progress"; name?: string; preview?: string }
|
|
34
|
+
| { kind: "tool.generating"; name?: string }
|
|
35
|
+
| { kind: "tool.complete"; id: string; summary?: string; error?: string; inline_diff?: string }
|
|
36
|
+
| { kind: "thinking"; text: string; final: boolean }
|
|
37
|
+
| { kind: "subagent"; event: "start" | "thinking" | "tool" | "progress" | "complete"; payload: SubagentPayload }
|
|
38
|
+
| { kind: "prompt"; id: string; req: PromptReq }
|
|
39
|
+
| { kind: "prompt.answered"; id: string; label: string; ok: boolean }
|
|
40
|
+
| { kind: "error"; text: string }
|
|
41
|
+
| { kind: "interrupt.notice"; text: string }
|
|
42
|
+
|
|
43
|
+
export function turnReducer(state: TurnState, a: Action): TurnState {
|
|
44
|
+
switch (a.kind) {
|
|
45
|
+
case "reset":
|
|
46
|
+
return initialTurn
|
|
47
|
+
|
|
48
|
+
case "load":
|
|
49
|
+
return { ...initialTurn, messages: a.messages }
|
|
50
|
+
|
|
51
|
+
case "push":
|
|
52
|
+
return { ...state, messages: [...state.messages, a.message] }
|
|
53
|
+
|
|
54
|
+
case "user":
|
|
55
|
+
return { ...state, messages: [...state.messages, userMessage(a.text)] }
|
|
56
|
+
|
|
57
|
+
case "system":
|
|
58
|
+
return { ...state, messages: [...state.messages, systemMessage(a.text)] }
|
|
59
|
+
|
|
60
|
+
case "message.start":
|
|
61
|
+
return { ...state, streaming: true, hasContent: false, toolActive: false }
|
|
62
|
+
|
|
63
|
+
case "message.delta":
|
|
64
|
+
return {
|
|
65
|
+
...state,
|
|
66
|
+
hasContent: true,
|
|
67
|
+
toolActive: false,
|
|
68
|
+
messages: appendText(state.messages, a.chunk),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case "message.complete":
|
|
72
|
+
return {
|
|
73
|
+
...state,
|
|
74
|
+
streaming: false,
|
|
75
|
+
hasContent: false,
|
|
76
|
+
toolActive: false,
|
|
77
|
+
messages: finalize(state.messages, a.text, a.usage),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case "tool.start": {
|
|
81
|
+
// `context` carries the raw tool input; when JSON-shaped we keep it
|
|
82
|
+
// as args so the UI can render KV lines on expand.
|
|
83
|
+
const json = a.preview && /^\s*\{/.test(a.preview)
|
|
84
|
+
const part: ToolPart = {
|
|
85
|
+
type: "tool", id: a.id, name: a.name,
|
|
86
|
+
args: json ? a.preview! : "",
|
|
87
|
+
status: "running", startedAt: Date.now(),
|
|
88
|
+
preview: a.preview,
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...state,
|
|
92
|
+
toolActive: true,
|
|
93
|
+
hasContent: false,
|
|
94
|
+
messages: appendPart(state.messages, part, true),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case "tool.progress":
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
messages: updateRunningTool(state.messages, a.name, p => ({
|
|
102
|
+
...p, preview: a.preview || p.preview,
|
|
103
|
+
})),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case "tool.generating":
|
|
107
|
+
return {
|
|
108
|
+
...state,
|
|
109
|
+
messages: updateRunningTool(state.messages, a.name, p => ({
|
|
110
|
+
...p, preview: p.preview ?? "generating…",
|
|
111
|
+
})),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case "tool.complete":
|
|
115
|
+
return {
|
|
116
|
+
...state,
|
|
117
|
+
toolActive: false,
|
|
118
|
+
messages: updateToolById(state.messages, a.id, p => ({
|
|
119
|
+
...p,
|
|
120
|
+
status: (a.error ? "error" : "done") as ToolPart["status"],
|
|
121
|
+
duration: p.startedAt ? Date.now() - p.startedAt : undefined,
|
|
122
|
+
preview: a.summary || a.inline_diff || p.preview,
|
|
123
|
+
result: a.error || a.summary,
|
|
124
|
+
diff: a.inline_diff,
|
|
125
|
+
})),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "thinking":
|
|
129
|
+
return { ...state, messages: upsertThinking(state.messages, a.text, a.final) }
|
|
130
|
+
|
|
131
|
+
case "subagent":
|
|
132
|
+
return { ...state, messages: renderSubagent(state.messages, a.event, a.payload) }
|
|
133
|
+
|
|
134
|
+
case "prompt": {
|
|
135
|
+
const part: PromptPart = {
|
|
136
|
+
type: "prompt", id: a.id, variant: a.req.variant, req: a.req,
|
|
137
|
+
}
|
|
138
|
+
// Append to the in-progress assistant turn (prompts arrive
|
|
139
|
+
// mid-stream, between tool calls). Seal any open text so the
|
|
140
|
+
// prompt sits chronologically.
|
|
141
|
+
return {
|
|
142
|
+
...state,
|
|
143
|
+
messages: appendPart(state.messages, part, true),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case "prompt.answered":
|
|
148
|
+
return {
|
|
149
|
+
...state,
|
|
150
|
+
messages: updatePrompt(state.messages, a.id, p => ({
|
|
151
|
+
...p, answered: { label: a.label, ok: a.ok, at: Date.now() },
|
|
152
|
+
})),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case "error":
|
|
156
|
+
return {
|
|
157
|
+
...state,
|
|
158
|
+
streaming: false,
|
|
159
|
+
hasContent: false,
|
|
160
|
+
toolActive: false,
|
|
161
|
+
messages: [...state.messages, systemMessage(`Error: ${a.text}`)],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "interrupt.notice": {
|
|
165
|
+
const last = state.messages[state.messages.length - 1]
|
|
166
|
+
const already = last?.role === "system"
|
|
167
|
+
&& last.parts[0]?.type === "text"
|
|
168
|
+
&& last.parts[0].content.includes(a.text)
|
|
169
|
+
if (already) return state
|
|
170
|
+
return { ...state, messages: [...state.messages, systemMessage(a.text)] }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Constructors ────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function userMessage(text: string): Message {
|
|
178
|
+
return {
|
|
179
|
+
id: mid(), role: "user",
|
|
180
|
+
parts: [{ type: "text", content: text, streaming: false }],
|
|
181
|
+
timestamp: Date.now() / 1000,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function systemMessage(text: string): Message {
|
|
186
|
+
return {
|
|
187
|
+
id: mid(), role: "system",
|
|
188
|
+
parts: [{ type: "text", content: text, streaming: false }],
|
|
189
|
+
timestamp: Date.now() / 1000,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Flatten a transcript row's `text` to a plain string for the reducer.
|
|
194
|
+
// Native-mode image routing stores user turns as an OpenAI content-parts
|
|
195
|
+
// list (`[{type:"text",text:…}, {type:"image_url",image_url:{url:…}}, …]`)
|
|
196
|
+
// instead of a plain string. ChafaImage needs a path, so a `data:` URL
|
|
197
|
+
// can't be reconstructed into a MEDIA: line — but we emit the `withMedia`
|
|
198
|
+
// prefix on the wire now (app.tsx:send), so the path sits inside the
|
|
199
|
+
// leading {type:"text"} fragment and survives.
|
|
200
|
+
function flatten(text: TranscriptMessage["text"]): string {
|
|
201
|
+
if (typeof text === "string") return text
|
|
202
|
+
if (!Array.isArray(text)) return ""
|
|
203
|
+
const out: string[] = []
|
|
204
|
+
for (const p of text) {
|
|
205
|
+
if (p && typeof p === "object" && "type" in p && p.type === "text"
|
|
206
|
+
&& "text" in p && typeof p.text === "string") out.push(p.text)
|
|
207
|
+
}
|
|
208
|
+
return out.join("\n")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function transcriptToMessages(rows: TranscriptMessage[]): Message[] {
|
|
212
|
+
return rows
|
|
213
|
+
.filter(r => r.role === "user" || r.role === "assistant")
|
|
214
|
+
.map(r => ({ role: r.role, content: flatten(r.text) }))
|
|
215
|
+
.filter(r => r.content)
|
|
216
|
+
.map(r => ({
|
|
217
|
+
id: mid(),
|
|
218
|
+
role: r.role as "user" | "assistant",
|
|
219
|
+
parts: [{ type: "text" as const, content: r.content, streaming: false }],
|
|
220
|
+
timestamp: Date.now() / 1000,
|
|
221
|
+
}))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Internals ───────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
function assistant(parts: Part[]): Message {
|
|
227
|
+
return { id: mid(), role: "assistant", parts, timestamp: Date.now() / 1000 }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function withLastAssistant(
|
|
231
|
+
messages: Message[],
|
|
232
|
+
fn: (m: Message) => Message,
|
|
233
|
+
otherwise: () => Message,
|
|
234
|
+
): Message[] {
|
|
235
|
+
const last = messages[messages.length - 1]
|
|
236
|
+
if (last?.role === "assistant") return [...messages.slice(0, -1), fn(last)]
|
|
237
|
+
return [...messages, otherwise()]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Seal the trailing streaming text part so the next chunk starts fresh. */
|
|
241
|
+
function seal(parts: Part[]): Part[] {
|
|
242
|
+
const last = parts[parts.length - 1]
|
|
243
|
+
if (last?.type === "text" && last.streaming)
|
|
244
|
+
return [...parts.slice(0, -1), { ...last, streaming: false }]
|
|
245
|
+
return parts
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Append a chunk to the trailing streaming text part, or open a new one. */
|
|
249
|
+
function appendText(messages: Message[], chunk: string): Message[] {
|
|
250
|
+
return withLastAssistant(
|
|
251
|
+
messages,
|
|
252
|
+
m => {
|
|
253
|
+
const last = m.parts[m.parts.length - 1]
|
|
254
|
+
if (last?.type === "text" && last.streaming) {
|
|
255
|
+
const part: TextPart = { ...last, content: last.content + chunk }
|
|
256
|
+
return { ...m, parts: [...m.parts.slice(0, -1), part] }
|
|
257
|
+
}
|
|
258
|
+
return { ...m, parts: [...m.parts, { type: "text", key: pid(), content: chunk, streaming: true }] }
|
|
259
|
+
},
|
|
260
|
+
() => assistant([{ type: "text", key: pid(), content: chunk, streaming: true }]),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Append a non-text part, optionally sealing any open text stream first. */
|
|
265
|
+
function appendPart(messages: Message[], part: Part, close: boolean): Message[] {
|
|
266
|
+
return withLastAssistant(
|
|
267
|
+
messages,
|
|
268
|
+
m => ({ ...m, parts: [...(close ? seal(m.parts) : m.parts), part] }),
|
|
269
|
+
() => assistant([part]),
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function finalize(messages: Message[], final?: string, usage?: Usage): Message[] {
|
|
274
|
+
const last = messages[messages.length - 1]
|
|
275
|
+
if (last?.role === "assistant") {
|
|
276
|
+
const tail = last.parts[last.parts.length - 1]
|
|
277
|
+
const parts = tail?.type === "text" && tail.streaming
|
|
278
|
+
? [...last.parts.slice(0, -1), { ...tail, content: final || tail.content, streaming: false }]
|
|
279
|
+
: final && final !== joinText(last.parts)
|
|
280
|
+
? [...last.parts, { type: "text" as const, content: final, streaming: false }]
|
|
281
|
+
: seal(last.parts)
|
|
282
|
+
return [...messages.slice(0, -1), { ...last, parts, usage }]
|
|
283
|
+
}
|
|
284
|
+
if (!final) return messages
|
|
285
|
+
return [...messages, { ...assistant([{ type: "text", content: final, streaming: false }]), usage }]
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function joinText(parts: Part[]): string {
|
|
289
|
+
return parts.filter(p => p.type === "text").map(p => p.content).join("")
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function updateRunningTool(
|
|
293
|
+
messages: Message[],
|
|
294
|
+
name: string | undefined,
|
|
295
|
+
fn: (p: ToolPart) => ToolPart,
|
|
296
|
+
): Message[] {
|
|
297
|
+
const last = messages[messages.length - 1]
|
|
298
|
+
if (!last || last.role !== "assistant") return messages
|
|
299
|
+
for (let i = last.parts.length - 1; i >= 0; i--) {
|
|
300
|
+
const p = last.parts[i]
|
|
301
|
+
if (p.type !== "tool" || p.status !== "running") continue
|
|
302
|
+
if (name && p.name !== name) continue
|
|
303
|
+
const parts = [...last.parts]
|
|
304
|
+
parts[i] = fn(p)
|
|
305
|
+
return [...messages.slice(0, -1), { ...last, parts }]
|
|
306
|
+
}
|
|
307
|
+
return messages
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function updateToolById(messages: Message[], id: string, fn: (p: ToolPart) => ToolPart): Message[] {
|
|
311
|
+
const last = messages[messages.length - 1]
|
|
312
|
+
if (!last || last.role !== "assistant") return messages
|
|
313
|
+
const parts = last.parts.map(p => p.type === "tool" && p.id === id ? fn(p) : p)
|
|
314
|
+
return [...messages.slice(0, -1), { ...last, parts }]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Prompts can be answered after the assistant turn has moved on
|
|
318
|
+
* (user scrolled away, agent kept streaming). Search all messages,
|
|
319
|
+
* not just the tail. */
|
|
320
|
+
function updatePrompt(messages: Message[], id: string, fn: (p: PromptPart) => PromptPart): Message[] {
|
|
321
|
+
return messages.map(m => {
|
|
322
|
+
if (m.role !== "assistant") return m
|
|
323
|
+
if (!m.parts.some(p => p.type === "prompt" && p.id === id)) return m
|
|
324
|
+
return { ...m, parts: m.parts.map(p => p.type === "prompt" && p.id === id ? fn(p) : p) }
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function upsertThinking(messages: Message[], text: string, final: boolean): Message[] {
|
|
329
|
+
return withLastAssistant(
|
|
330
|
+
messages,
|
|
331
|
+
m => {
|
|
332
|
+
const idx = m.parts.findIndex(p => p.type === "thinking")
|
|
333
|
+
if (idx >= 0) {
|
|
334
|
+
const prev = m.parts[idx] as Part & { type: "thinking"; content: string }
|
|
335
|
+
// `final` (reasoning.available) is a fallback for providers
|
|
336
|
+
// that don't stream deltas — keep the accumulated buffer if we
|
|
337
|
+
// have one. Matches Ink turnController.recordReasoningAvailable.
|
|
338
|
+
const content = final ? prev.content.trim() || text : prev.content + text
|
|
339
|
+
const parts = [...m.parts]
|
|
340
|
+
parts[idx] = { ...prev, content, streaming: !final }
|
|
341
|
+
return { ...m, parts }
|
|
342
|
+
}
|
|
343
|
+
return { ...m, parts: [{ type: "thinking" as const, key: pid(), content: text, streaming: !final }, ...m.parts] }
|
|
344
|
+
},
|
|
345
|
+
() => assistant([{ type: "thinking", key: pid(), content: text, streaming: !final }]),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderSubagent(
|
|
350
|
+
messages: Message[],
|
|
351
|
+
event: "start" | "thinking" | "tool" | "progress" | "complete",
|
|
352
|
+
p: SubagentPayload,
|
|
353
|
+
): Message[] {
|
|
354
|
+
// Prefer the stable subagent_id threaded by delegate_tool; fall back
|
|
355
|
+
// to task_index for older gateways. task_index alone collides when
|
|
356
|
+
// orchestrator children spawn their own batch (same index, new depth).
|
|
357
|
+
const id = p.subagent_id ? `sub-${p.subagent_id}` : `sub-${p.task_index}`
|
|
358
|
+
|
|
359
|
+
if (event === "start") {
|
|
360
|
+
const part: ToolPart = {
|
|
361
|
+
type: "tool", id, name: "delegate_task", args: "",
|
|
362
|
+
status: "running", startedAt: Date.now(),
|
|
363
|
+
preview: p.goal, goal: p.goal, depth: p.depth ?? 0, trail: [],
|
|
364
|
+
}
|
|
365
|
+
return appendPart(messages, part, true)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (event === "tool" && p.tool_name) {
|
|
369
|
+
return updateToolById(messages, id, t => ({
|
|
370
|
+
...t,
|
|
371
|
+
trail: [...(t.trail ?? []), { name: p.tool_name!, preview: p.tool_preview }],
|
|
372
|
+
preview: p.tool_preview ? `${p.tool_name}: ${p.tool_preview}` : p.tool_name,
|
|
373
|
+
}))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (event === "complete") {
|
|
377
|
+
const tokens = (p.input_tokens ?? 0) + (p.output_tokens ?? 0)
|
|
378
|
+
const extra = tokens ? ` · ${(tokens / 1000).toFixed(1)}k tok` : ""
|
|
379
|
+
return updateToolById(messages, id, t => ({
|
|
380
|
+
...t,
|
|
381
|
+
status: (p.status === "failed" ? "error" : "done") as ToolPart["status"],
|
|
382
|
+
duration: p.duration_seconds ? p.duration_seconds * 1000 : (t.startedAt ? Date.now() - t.startedAt : undefined),
|
|
383
|
+
result: p.summary ? p.summary + extra : undefined,
|
|
384
|
+
preview: t.goal ?? t.preview,
|
|
385
|
+
}))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// thinking / progress — surface transient text on the row.
|
|
389
|
+
return updateToolById(messages, id, t => ({ ...t, preview: p.text ?? t.preview }))
|
|
390
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Shell-level keyboard routing. Input-scoped keys (popover nav, prompt
|
|
2
|
+
// history) are delegated to the Composer via its imperative handle so
|
|
3
|
+
// there is exactly one global useKeyboard.
|
|
4
|
+
|
|
5
|
+
import { useKeyboard, useRenderer } from "@opentui/react"
|
|
6
|
+
import { resolveRenderLib, RGBA, type ParsedKey } from "@opentui/core"
|
|
7
|
+
|
|
8
|
+
/** Wipe the physical screen and force a full re-emit on next frame. */
|
|
9
|
+
export function redraw(renderer: {
|
|
10
|
+
rendererPtr: unknown
|
|
11
|
+
currentRenderBuffer: { clear(c: unknown): void }
|
|
12
|
+
requestRender(): void
|
|
13
|
+
}) {
|
|
14
|
+
resolveRenderLib().clearTerminal(renderer.rendererPtr as never)
|
|
15
|
+
renderer.currentRenderBuffer.clear(RGBA.fromValues(0, 0, 0, 0))
|
|
16
|
+
renderer.requestRender()
|
|
17
|
+
}
|
|
18
|
+
import { useRef, useEffect, type RefObject } from "react"
|
|
19
|
+
import { copySelection } from "../utils/clipboard"
|
|
20
|
+
import { editInEditor } from "../utils/editor"
|
|
21
|
+
import { useKeys, conflicts } from "../keys"
|
|
22
|
+
import { print as chordPrint } from "../keys/chord"
|
|
23
|
+
import type { ComposerHandle } from "../components/chat/Composer"
|
|
24
|
+
|
|
25
|
+
const INTERRUPT_MS = 5000
|
|
26
|
+
export const DOUBLE_TAB_MS = 400
|
|
27
|
+
|
|
28
|
+
type Region = "input" | "content"
|
|
29
|
+
|
|
30
|
+
type Opts = {
|
|
31
|
+
tab: number
|
|
32
|
+
tabMax: number
|
|
33
|
+
chatTab: number
|
|
34
|
+
setTab: (fn: (t: number) => number) => void
|
|
35
|
+
focusRegion: Region
|
|
36
|
+
setFocusRegion: (r: Region | ((r: Region) => Region)) => void
|
|
37
|
+
streaming: boolean
|
|
38
|
+
dialogOpen: boolean
|
|
39
|
+
composer: RefObject<ComposerHandle | null>
|
|
40
|
+
/** Offer the key to a pending inline prompt card. Return true to
|
|
41
|
+
* consume + stopPropagation; false to fall through to the shell. */
|
|
42
|
+
onPromptKey?: (key: ParsedKey) => boolean
|
|
43
|
+
/** Idle-mode Esc, before focus bounce. Return true to consume. */
|
|
44
|
+
onEscape?: () => boolean
|
|
45
|
+
onInterrupt: () => void
|
|
46
|
+
onInterruptNotice: () => void
|
|
47
|
+
onCopyLast: () => void
|
|
48
|
+
onAttachClipboard: () => void
|
|
49
|
+
/** Remove the last pending attachment (backspace on empty composer). */
|
|
50
|
+
onDetachLast: () => boolean
|
|
51
|
+
onNotice: (text: string) => void
|
|
52
|
+
onToggleSidebar: () => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useAppKeys(o: Opts) {
|
|
56
|
+
const renderer = useRenderer()
|
|
57
|
+
const keys = useKeys()
|
|
58
|
+
const lastEsc = useRef(0)
|
|
59
|
+
const lastTab = useRef(0)
|
|
60
|
+
|
|
61
|
+
// Tabs with their own keyboard surface own focus on entry; Chat keeps
|
|
62
|
+
// the composer since its content region has no keybinds.
|
|
63
|
+
const regionFor = (t: number): Region => t === o.chatTab ? "input" : "content"
|
|
64
|
+
|
|
65
|
+
// One-shot conflict scan whenever the resolved table changes (i.e. a
|
|
66
|
+
// user override was written). DEFAULTS are swept by a test, so any
|
|
67
|
+
// hit here is user-introduced — warn but honor the override.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const found = conflicts(keys.table)
|
|
70
|
+
// Same chord, disjoint modes — dialogOpen gate below makes these
|
|
71
|
+
// mutually exclusive, not a real collision.
|
|
72
|
+
.filter(c => !(c.a === "session.interrupt" && c.b === "dialog.cancel"))
|
|
73
|
+
if (found.length === 0) return
|
|
74
|
+
const first = found[0]
|
|
75
|
+
o.onNotice(
|
|
76
|
+
`Keybinding conflict: ${chordPrint([first.chord])} → ${first.a} and ${first.b}` +
|
|
77
|
+
(found.length > 1 ? ` (+${found.length - 1} more)` : ""),
|
|
78
|
+
)
|
|
79
|
+
}, [keys.table])
|
|
80
|
+
|
|
81
|
+
useKeyboard((key) => {
|
|
82
|
+
const c = o.composer.current
|
|
83
|
+
|
|
84
|
+
if (keys.match("app.exit", key)) {
|
|
85
|
+
if (copySelection(renderer)) return
|
|
86
|
+
// destroy() tears down the renderer but does NOT exit the process
|
|
87
|
+
// — under `bun --watch` (or with CONTROL/PERF servers up) the
|
|
88
|
+
// event loop stays alive and the user is stranded on the main
|
|
89
|
+
// screen with no prompt. exit(0) fires the `exit` hook in
|
|
90
|
+
// terminal-reset which flushes the mode-reset blob synchronously.
|
|
91
|
+
renderer.destroy()
|
|
92
|
+
process.exit(0)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (keys.match("app.suspend", key)) {
|
|
96
|
+
renderer.suspend()
|
|
97
|
+
process.kill(process.pid, "SIGTSTP")
|
|
98
|
+
// Resumes on SIGCONT; OpenTUI's suspend/resume cycle re-enables
|
|
99
|
+
// raw mode and redraws on the next frame.
|
|
100
|
+
process.once("SIGCONT", () => renderer.resume())
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (keys.match("app.redraw", key)) {
|
|
105
|
+
// OpenTUI's renderNative() only emits cells that diff against
|
|
106
|
+
// the previous frame, so pty garbage from a child process / ssh
|
|
107
|
+
// banner / macOS Cmd+K sticks until those cells happen to
|
|
108
|
+
// change. clearTerminal() writes CSI 2J + CSI H to wipe the
|
|
109
|
+
// physical screen; zeroing currentRenderBuffer (the diff
|
|
110
|
+
// baseline — same trick resume() uses) makes the next normal
|
|
111
|
+
// render see every populated cell as changed and re-emit it.
|
|
112
|
+
// Calling lib.render(ptr, true) directly would bypass the loop
|
|
113
|
+
// and rot the native buffer-swap state, so go through
|
|
114
|
+
// requestRender() instead.
|
|
115
|
+
redraw(renderer)
|
|
116
|
+
key.stopPropagation()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (keys.match("app.sidebar", key)) {
|
|
121
|
+
o.onToggleSidebar()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Modal means modal: with a dialog open, the shell yields
|
|
126
|
+
// everything except process-level escapes above. DialogProvider
|
|
127
|
+
// handles Esc-to-close; tabs/composer/interrupt all sit behind the
|
|
128
|
+
// overlay and shouldn't move.
|
|
129
|
+
if (o.dialogOpen) return
|
|
130
|
+
|
|
131
|
+
// Inline prompt gets first refusal on nav/answer keys. It only
|
|
132
|
+
// claims the narrow set it cares about (←/→/↑/↓/Enter/Esc/1-9);
|
|
133
|
+
// everything else — including printable chars while the composer
|
|
134
|
+
// is focused — falls through so typing-to-queue still works.
|
|
135
|
+
if (o.onPromptKey && !keys.leader && !key.ctrl && !key.meta && key.eventType !== "release") {
|
|
136
|
+
if (o.onPromptKey(key)) { key.stopPropagation(); return }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (keys.match("editor.open", key) && !o.streaming) {
|
|
140
|
+
const seed = c?.value() ?? ""
|
|
141
|
+
void editInEditor(renderer, seed).then(out => {
|
|
142
|
+
if (out === undefined) {
|
|
143
|
+
if (!process.env.VISUAL && !process.env.EDITOR)
|
|
144
|
+
o.onNotice("Set $EDITOR or $VISUAL to use the external editor")
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
c?.set(out)
|
|
148
|
+
o.setFocusRegion("input")
|
|
149
|
+
})
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (keys.match("tab.prev", key)) {
|
|
154
|
+
o.setTab(t => { const n = Math.max(0, t - 1); o.setFocusRegion(regionFor(n)); return n })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (keys.match("tab.next", key)) {
|
|
158
|
+
o.setTab(t => { const n = Math.min(o.tabMax, t + 1); o.setFocusRegion(regionFor(n)); return n })
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
// <leader> 1..0 → tab 1..10 (1-indexed), <leader> - → tab 11.
|
|
162
|
+
// Structural, not catalog — ten near-identical rebindable actions is
|
|
163
|
+
// noise, and the leader itself is the rebindable part.
|
|
164
|
+
if (keys.leader && !key.ctrl && !key.meta && !key.shift && key.eventType !== "release") {
|
|
165
|
+
const map: Record<string, number> = {
|
|
166
|
+
"1": 0, "2": 1, "3": 2, "4": 3, "5": 4,
|
|
167
|
+
"6": 5, "7": 6, "8": 7, "9": 8, "0": 9, "-": 10,
|
|
168
|
+
}
|
|
169
|
+
const n = map[key.name]
|
|
170
|
+
if (n !== undefined && n <= o.tabMax) {
|
|
171
|
+
o.setTab(() => { o.setFocusRegion(regionFor(n)); return n })
|
|
172
|
+
key.stopPropagation()
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Popover owns up/down/tab/escape while open; stopPropagation keeps the
|
|
178
|
+
// textarea renderable from also moving the cursor on the same keypress.
|
|
179
|
+
// Structural — popover nav is composer-state, not a catalog action.
|
|
180
|
+
if (!o.streaming && c?.popOpen()) {
|
|
181
|
+
if (key.name === "escape") return c.popCancel()
|
|
182
|
+
if (key.name === "up") { c.popNav(-1); key.stopPropagation(); return }
|
|
183
|
+
if (key.name === "down") { c.popNav(1); key.stopPropagation(); return }
|
|
184
|
+
if (key.name === "tab") return c.popAccept()
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (keys.match("focus.cycle", key) && !o.streaming) {
|
|
189
|
+
if (o.tab === o.chatTab) {
|
|
190
|
+
o.setFocusRegion(r => r === "input" ? "content" : "input")
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
if (o.focusRegion === "input") {
|
|
194
|
+
o.setFocusRegion("content")
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
// Content-focused on a non-Chat tab: single Tab stays (tab owns it as a
|
|
198
|
+
// nav key); double-tap within the window jumps to the composer.
|
|
199
|
+
const now = Date.now()
|
|
200
|
+
if (now - lastTab.current < DOUBLE_TAB_MS) {
|
|
201
|
+
o.setFocusRegion("input")
|
|
202
|
+
lastTab.current = 0
|
|
203
|
+
key.stopPropagation()
|
|
204
|
+
} else {
|
|
205
|
+
lastTab.current = now
|
|
206
|
+
}
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (keys.match("session.interrupt", key)) {
|
|
211
|
+
if (!o.streaming && o.onEscape?.()) return
|
|
212
|
+
if (o.streaming) {
|
|
213
|
+
const now = Date.now()
|
|
214
|
+
if (now - lastEsc.current < INTERRUPT_MS) {
|
|
215
|
+
o.onInterrupt()
|
|
216
|
+
lastEsc.current = 0
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
lastEsc.current = now
|
|
220
|
+
o.onInterruptNotice()
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (o.tab === o.chatTab && o.focusRegion === "content") o.setFocusRegion("input")
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (keys.match("reply.copy", key)) return o.onCopyLast()
|
|
228
|
+
if (keys.match("clipboard.attach", key)) {
|
|
229
|
+
o.onAttachClipboard()
|
|
230
|
+
key.stopPropagation()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ↑/↓ with a single-line buffer cycles prompt history; with a multi-line
|
|
235
|
+
// buffer historyUp/Down return false so the keystroke falls through to
|
|
236
|
+
// the textarea renderable's move-up/move-down. No stopPropagation — on a
|
|
237
|
+
// single-line buffer the textarea's move-up/down is a no-op anyway, and
|
|
238
|
+
// swallowing the key would starve dialog/select renderables that share
|
|
239
|
+
// the global key bus while focusRegion is still "input".
|
|
240
|
+
if (o.focusRegion === "input" && !o.streaming) {
|
|
241
|
+
if (key.name === "up") return void c?.historyUp()
|
|
242
|
+
if (key.name === "down") return void c?.historyDown()
|
|
243
|
+
// Backspace on an empty buffer with attachments → detach the last.
|
|
244
|
+
// Swallow before the textarea sees it so a subsequent backspace on
|
|
245
|
+
// a still-empty buffer keeps peeling attachments off, not chars.
|
|
246
|
+
if (key.name === "backspace" && !key.ctrl && !key.meta
|
|
247
|
+
&& c?.isEmpty() && o.onDetachLast()) {
|
|
248
|
+
key.stopPropagation()
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Printable char while Chat transcript has focus → bounce to composer
|
|
254
|
+
// AND deliver the char (so the first keystroke isn't swallowed). Other
|
|
255
|
+
// tabs own their printable keys (v=reveal, d=delete, …), so the shell
|
|
256
|
+
// must not intercept there.
|
|
257
|
+
if (o.tab === o.chatTab && o.focusRegion === "content" && !o.streaming
|
|
258
|
+
&& !key.ctrl && !key.meta && key.eventType !== "release") {
|
|
259
|
+
if (key.name.length === 1 && key.name !== " ") {
|
|
260
|
+
const ch = key.shift && /[a-z]/.test(key.name)
|
|
261
|
+
? key.name.toUpperCase() : key.name
|
|
262
|
+
o.setFocusRegion("input")
|
|
263
|
+
c?.insert(ch)
|
|
264
|
+
key.stopPropagation()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
}
|