camelagi 0.5.49 → 0.5.51
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/dist/cli/cmd-chat.js +69 -2
- package/dist/cli/cmd-chat.js.map +1 -1
- package/dist/core/version.js +1 -1
- package/dist/runtime/orchestrate.js +1 -1
- package/dist/telegram/admin-bot.js +1 -1
- package/package.json +5 -2
- package/tui/package.json +23 -0
- package/tui/src/App.tsx +161 -0
- package/tui/src/agent/parse.ts +184 -0
- package/tui/src/agent/types.ts +75 -0
- package/tui/src/commands/registry.ts +301 -0
- package/tui/src/components/ActivityIndicator.tsx +148 -0
- package/tui/src/components/ApprovalPrompt.tsx +58 -0
- package/tui/src/components/BottomBar.tsx +74 -0
- package/tui/src/components/Chat.tsx +98 -0
- package/tui/src/components/Divider.tsx +12 -0
- package/tui/src/components/HorizontalRule.tsx +12 -0
- package/tui/src/components/Input.tsx +126 -0
- package/tui/src/components/Markdown.tsx +290 -0
- package/tui/src/components/Message.tsx +77 -0
- package/tui/src/components/PermissionBanner.tsx +30 -0
- package/tui/src/components/Picker.tsx +127 -0
- package/tui/src/components/SlashMenu.tsx +46 -0
- package/tui/src/components/SubagentBlock.tsx +16 -0
- package/tui/src/components/ToolBlock.tsx +24 -0
- package/tui/src/components/Welcome.tsx +75 -0
- package/tui/src/components/tools/BashTool.tsx +27 -0
- package/tui/src/components/tools/DefaultTool.tsx +38 -0
- package/tui/src/components/tools/DiffView.tsx +91 -0
- package/tui/src/components/tools/EditGroup.tsx +97 -0
- package/tui/src/components/tools/EditTool.tsx +41 -0
- package/tui/src/components/tools/ReadTool.tsx +41 -0
- package/tui/src/components/tools/SearchTool.tsx +27 -0
- package/tui/src/components/tools/ToolHeader.tsx +48 -0
- package/tui/src/components/tools/WriteTool.tsx +54 -0
- package/tui/src/config.ts +6 -0
- package/tui/src/hooks/useAgent.ts +202 -0
- package/tui/src/main.tsx +12 -0
- package/tui/src/models.ts +26 -0
- package/tui/src/state/reducer.ts +290 -0
- package/tui/src/theme.ts +28 -0
- package/tui/src/util/nativeNotify.ts +47 -0
- package/tui/src/util/spinner.ts +11 -0
- package/tui/tsconfig.json +19 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Slash command registry for CamelAGI TUI.
|
|
2
|
+
|
|
3
|
+
import { spawn as spawnChild } from "node:child_process"
|
|
4
|
+
import { writeFileSync } from "node:fs"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import type { PermissionMode } from "../agent/types.js"
|
|
7
|
+
import type { ChatState } from "../state/reducer.js"
|
|
8
|
+
import { MODELS, EFFORT_LEVELS, type Effort, findModel } from "../models.js"
|
|
9
|
+
import type { PickerItem } from "../components/Picker.js"
|
|
10
|
+
|
|
11
|
+
export interface Settings {
|
|
12
|
+
model: string
|
|
13
|
+
effort: Effort
|
|
14
|
+
cwd: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CommandContext {
|
|
18
|
+
pushSystem: (text: string, tone?: "info" | "warn" | "error") => void
|
|
19
|
+
agent: {
|
|
20
|
+
state: ChatState
|
|
21
|
+
clear: () => void
|
|
22
|
+
abort: () => void
|
|
23
|
+
setPermissionMode: (mode: PermissionMode) => void
|
|
24
|
+
switchModel: (model: string, thinking?: string, effort?: string) => void
|
|
25
|
+
wsSend: (msg: Record<string, unknown>) => void
|
|
26
|
+
sessionId: string
|
|
27
|
+
}
|
|
28
|
+
settings: Settings
|
|
29
|
+
setSettings: (patch: Partial<Settings>) => void
|
|
30
|
+
openPicker: (opts: {
|
|
31
|
+
title: string
|
|
32
|
+
items: PickerItem[]
|
|
33
|
+
initialIndex?: number
|
|
34
|
+
onSelect: (value: string) => void
|
|
35
|
+
}) => void
|
|
36
|
+
exit: () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SlashCommand {
|
|
40
|
+
name: string
|
|
41
|
+
description: string
|
|
42
|
+
hidden?: boolean
|
|
43
|
+
run: (ctx: CommandContext, args: string[]) => Promise<void> | void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const COMMANDS: SlashCommand[] = [
|
|
47
|
+
{
|
|
48
|
+
name: "model",
|
|
49
|
+
description: "Pick a model",
|
|
50
|
+
run: ctx => {
|
|
51
|
+
const items: PickerItem[] = MODELS.map(m => ({
|
|
52
|
+
value: m.id,
|
|
53
|
+
label: m.label,
|
|
54
|
+
description: m.notes ?? "",
|
|
55
|
+
badge: m.vendor,
|
|
56
|
+
}))
|
|
57
|
+
const initialIndex = Math.max(0, MODELS.findIndex(m => m.id === ctx.settings.model))
|
|
58
|
+
ctx.openPicker({
|
|
59
|
+
title: "Select model",
|
|
60
|
+
items,
|
|
61
|
+
initialIndex,
|
|
62
|
+
onSelect: value => {
|
|
63
|
+
ctx.setSettings({ model: value })
|
|
64
|
+
ctx.agent.switchModel(value)
|
|
65
|
+
const meta = findModel(value)
|
|
66
|
+
ctx.pushSystem(`Model → ${meta?.label ?? value}`)
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "effort",
|
|
73
|
+
description: "Set effort level (low | medium | high | max)",
|
|
74
|
+
run: (ctx, args) => {
|
|
75
|
+
const arg = args[0] as Effort | undefined
|
|
76
|
+
if (arg && EFFORT_LEVELS.includes(arg)) {
|
|
77
|
+
ctx.setSettings({ effort: arg })
|
|
78
|
+
ctx.agent.switchModel(ctx.settings.model, undefined, arg)
|
|
79
|
+
ctx.pushSystem(`Effort → ${arg}`)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const items: PickerItem[] = EFFORT_LEVELS.map(level => ({
|
|
83
|
+
value: level,
|
|
84
|
+
label: level,
|
|
85
|
+
description: descEffort(level),
|
|
86
|
+
}))
|
|
87
|
+
const initialIndex = Math.max(0, EFFORT_LEVELS.indexOf(ctx.settings.effort))
|
|
88
|
+
ctx.openPicker({
|
|
89
|
+
title: "Select effort level",
|
|
90
|
+
items,
|
|
91
|
+
initialIndex,
|
|
92
|
+
onSelect: value => {
|
|
93
|
+
ctx.setSettings({ effort: value as Effort })
|
|
94
|
+
ctx.agent.switchModel(ctx.settings.model, undefined, value)
|
|
95
|
+
ctx.pushSystem(`Effort → ${value}`)
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "think",
|
|
102
|
+
description: "Set thinking level (off | low | medium | high)",
|
|
103
|
+
run: (ctx, args) => {
|
|
104
|
+
const levels = ["off", "low", "medium", "high"] as const
|
|
105
|
+
const arg = args[0]
|
|
106
|
+
if (arg && levels.includes(arg as typeof levels[number])) {
|
|
107
|
+
ctx.agent.switchModel(ctx.settings.model, arg)
|
|
108
|
+
ctx.pushSystem(`Thinking → ${arg}`)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
ctx.openPicker({
|
|
112
|
+
title: "Select thinking level",
|
|
113
|
+
items: levels.map(l => ({ value: l, label: l })),
|
|
114
|
+
onSelect: value => {
|
|
115
|
+
ctx.agent.switchModel(ctx.settings.model, value)
|
|
116
|
+
ctx.pushSystem(`Thinking → ${value}`)
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "new",
|
|
123
|
+
description: "Start a fresh session",
|
|
124
|
+
run: ctx => {
|
|
125
|
+
ctx.agent.clear()
|
|
126
|
+
ctx.pushSystem("New session started.")
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "clear",
|
|
131
|
+
description: "Clear chat history",
|
|
132
|
+
run: ctx => ctx.agent.clear(),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "abort",
|
|
136
|
+
description: "Abort the running agent",
|
|
137
|
+
run: ctx => ctx.agent.abort(),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "compact",
|
|
141
|
+
description: "Force compaction of chat history",
|
|
142
|
+
run: ctx => {
|
|
143
|
+
ctx.agent.wsSend({ type: "compact", session: ctx.agent.sessionId })
|
|
144
|
+
ctx.pushSystem("Compaction requested.")
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "status",
|
|
149
|
+
description: "Show session status and usage",
|
|
150
|
+
run: ctx => {
|
|
151
|
+
ctx.agent.wsSend({ type: "status", session: ctx.agent.sessionId })
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "sessions",
|
|
156
|
+
description: "List saved sessions",
|
|
157
|
+
run: ctx => {
|
|
158
|
+
ctx.agent.wsSend({ type: "sessions.list" })
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "cwd",
|
|
163
|
+
description: "Show or change working directory",
|
|
164
|
+
run: (ctx, args) => {
|
|
165
|
+
if (args.length === 0) {
|
|
166
|
+
ctx.pushSystem(`Current cwd: ${ctx.settings.cwd}`)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
ctx.setSettings({ cwd: args.join(" ") })
|
|
170
|
+
ctx.pushSystem(`cwd → ${args.join(" ")}`)
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "copy",
|
|
175
|
+
description: "Copy the last assistant message to clipboard",
|
|
176
|
+
run: ctx => {
|
|
177
|
+
const last = lastAssistantText(ctx.agent.state)
|
|
178
|
+
if (!last) {
|
|
179
|
+
ctx.pushSystem("No assistant message to copy yet.", "warn")
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
copyToClipboard(last)
|
|
183
|
+
.then(() => ctx.pushSystem(`Copied ${last.length} chars to clipboard.`))
|
|
184
|
+
.catch(err => ctx.pushSystem(`Copy failed: ${(err as Error).message}`, "error"))
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "save",
|
|
189
|
+
description: "Save chat to a file in cwd",
|
|
190
|
+
run: ctx => {
|
|
191
|
+
const out = formatTranscript(ctx.agent.state)
|
|
192
|
+
const filename = `camelagi-chat-${new Date().toISOString().replace(/[:.]/g, "-")}.md`
|
|
193
|
+
const path = join(ctx.settings.cwd, filename)
|
|
194
|
+
try {
|
|
195
|
+
writeFileSync(path, out, "utf8")
|
|
196
|
+
ctx.pushSystem(`Saved chat to ${path}`)
|
|
197
|
+
} catch (err) {
|
|
198
|
+
ctx.pushSystem(`Save failed: ${(err as Error).message}`, "error")
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "help",
|
|
204
|
+
description: "Show all commands and shortcuts",
|
|
205
|
+
run: ctx => {
|
|
206
|
+
const longest = Math.max(...COMMANDS.filter(c => !c.hidden).map(c => c.name.length))
|
|
207
|
+
const lines = [
|
|
208
|
+
"Commands:",
|
|
209
|
+
...COMMANDS.filter(c => !c.hidden).map(c => ` /${c.name.padEnd(longest)} ${c.description}`),
|
|
210
|
+
"",
|
|
211
|
+
"Shortcuts:",
|
|
212
|
+
" ctrl+c abort current run, twice to exit",
|
|
213
|
+
" ctrl+l clear chat",
|
|
214
|
+
" esc cancel input / deny approval / close picker",
|
|
215
|
+
" ↑ / ↓ navigate menu / picker",
|
|
216
|
+
]
|
|
217
|
+
ctx.pushSystem(lines.join("\n"))
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "exit",
|
|
222
|
+
description: "Quit",
|
|
223
|
+
run: ctx => ctx.exit(),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "quit",
|
|
227
|
+
description: "Quit",
|
|
228
|
+
hidden: true,
|
|
229
|
+
run: ctx => ctx.exit(),
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
export function findCommand(name: string): SlashCommand | undefined {
|
|
234
|
+
return COMMANDS.find(c => c.name === name)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function filterCommands(query: string): SlashCommand[] {
|
|
238
|
+
const q = query.toLowerCase()
|
|
239
|
+
return COMMANDS.filter(c => {
|
|
240
|
+
if (!c.name.startsWith(q)) return false
|
|
241
|
+
if (c.hidden && q.length < 3) return false
|
|
242
|
+
return true
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function descEffort(e: Effort): string {
|
|
247
|
+
switch (e) {
|
|
248
|
+
case "low": return "fastest, cheapest"
|
|
249
|
+
case "medium": return "balanced"
|
|
250
|
+
case "high": return "more thinking (default)"
|
|
251
|
+
case "max": return "all-out reasoning"
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function lastAssistantText(state: ChatState): string | null {
|
|
256
|
+
for (let i = state.entries.length - 1; i >= 0; i--) {
|
|
257
|
+
const e = state.entries[i]
|
|
258
|
+
if (e.kind === "assistant" && e.text) return e.text
|
|
259
|
+
}
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function copyToClipboard(text: string): Promise<void> {
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const cmd = process.platform === "darwin" ? "pbcopy"
|
|
266
|
+
: process.platform === "win32" ? "clip"
|
|
267
|
+
: "xclip"
|
|
268
|
+
const args = process.platform === "linux" ? ["-selection", "clipboard"] : []
|
|
269
|
+
const child = spawnChild(cmd, args, { stdio: ["pipe", "ignore", "ignore"] })
|
|
270
|
+
child.on("error", reject)
|
|
271
|
+
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)))
|
|
272
|
+
child.stdin.end(text)
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatTranscript(state: ChatState): string {
|
|
277
|
+
const lines: string[] = [`# CamelAGI chat — ${new Date().toISOString()}`, ""]
|
|
278
|
+
for (const e of state.entries) {
|
|
279
|
+
switch (e.kind) {
|
|
280
|
+
case "user":
|
|
281
|
+
lines.push(`### You`, "", e.text, "")
|
|
282
|
+
break
|
|
283
|
+
case "assistant":
|
|
284
|
+
if (e.thinking) lines.push(`> _thinking:_ ${e.thinking.replace(/\n/g, " ")}`, "")
|
|
285
|
+
lines.push(`### Assistant`, "", e.text, "")
|
|
286
|
+
break
|
|
287
|
+
case "tool":
|
|
288
|
+
lines.push(`**${e.name}** (${e.status})`, "```", JSON.stringify(e.args, null, 2), "```")
|
|
289
|
+
if (e.result) lines.push("```", e.result, "```")
|
|
290
|
+
lines.push("")
|
|
291
|
+
break
|
|
292
|
+
case "system":
|
|
293
|
+
lines.push(`_system: ${e.text}_`, "")
|
|
294
|
+
break
|
|
295
|
+
case "subagent":
|
|
296
|
+
lines.push(`**subagent: ${e.agentId}** — ${e.done ? "done" : "running"}`, "")
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return lines.join("\n")
|
|
301
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Claude Code-style live status: ✦ Thinking… (7s · esc to interrupt)
|
|
2
|
+
// The verb shimmers (color oscillates between two shades) and rotates
|
|
3
|
+
// through a curated word list every few seconds while the model is in
|
|
4
|
+
// generic "thinking/responding" mode. Specific labels like "Running Bash"
|
|
5
|
+
// stay fixed.
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef, useState } from "react"
|
|
8
|
+
import { fg, t } from "@opentui/core"
|
|
9
|
+
import { theme } from "../theme.js"
|
|
10
|
+
|
|
11
|
+
// Verbs in many languages, each in its native script. The rotation picks
|
|
12
|
+
// at random so a single session feels multilingual.
|
|
13
|
+
const VERBS = [
|
|
14
|
+
// English
|
|
15
|
+
"Thinking", "Working", "Pondering", "Reasoning", "Crafting",
|
|
16
|
+
"Analyzing", "Synthesizing", "Brainstorming", "Reflecting", "Mulling",
|
|
17
|
+
// Spanish
|
|
18
|
+
"Pensando", "Trabajando", "Reflexionando", "Analizando", "Creando",
|
|
19
|
+
// French
|
|
20
|
+
"Réfléchissant", "Travaillant", "Analysant", "Créant",
|
|
21
|
+
// German
|
|
22
|
+
"Denkend", "Arbeitend", "Überlegend", "Analysierend",
|
|
23
|
+
// Italian
|
|
24
|
+
"Pensando", "Lavorando", "Analizzando",
|
|
25
|
+
// Portuguese
|
|
26
|
+
"Trabalhando", "Refletindo", "Criando",
|
|
27
|
+
// Dutch
|
|
28
|
+
"Denkend", "Werkend",
|
|
29
|
+
// Swedish
|
|
30
|
+
"Tänker", "Arbetar",
|
|
31
|
+
// Polish
|
|
32
|
+
"Myślę", "Pracuję", "Tworzę",
|
|
33
|
+
// Turkish
|
|
34
|
+
"Düşünüyorum", "Çalışıyorum",
|
|
35
|
+
// Greek
|
|
36
|
+
"Σκέφτομαι", "Εργάζομαι",
|
|
37
|
+
// Russian
|
|
38
|
+
"Думаю", "Работаю", "Размышляю", "Анализирую", "Создаю",
|
|
39
|
+
// Arabic
|
|
40
|
+
"أفكر", "أعمل", "أحلل", "أتأمل", "أبدع",
|
|
41
|
+
// Hebrew
|
|
42
|
+
"חושב", "עובד", "מנתח",
|
|
43
|
+
// Hindi
|
|
44
|
+
"सोच रहा हूँ", "काम कर रहा हूँ",
|
|
45
|
+
// Japanese
|
|
46
|
+
"考え中", "作業中", "分析中", "思案中",
|
|
47
|
+
// Chinese
|
|
48
|
+
"思考中", "工作中", "分析中", "创作中",
|
|
49
|
+
// Korean
|
|
50
|
+
"생각중", "작업중", "분석중",
|
|
51
|
+
// Vietnamese
|
|
52
|
+
"Đang suy nghĩ", "Đang làm việc",
|
|
53
|
+
// Thai
|
|
54
|
+
"กำลังคิด", "กำลังทำงาน",
|
|
55
|
+
// Swahili
|
|
56
|
+
"Kufikiri", "Kufanya kazi",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
const SHIMMER_DIM = "#475569" // slate-600
|
|
60
|
+
const SHIMMER_BRIGHT = "#e5e7eb" // gray-200
|
|
61
|
+
const VERB_ROTATE_MS = 4000
|
|
62
|
+
|
|
63
|
+
export interface ActivityIndicatorProps {
|
|
64
|
+
active: boolean
|
|
65
|
+
startedAt: number | null
|
|
66
|
+
label: string | null
|
|
67
|
+
liveTokens: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ActivityIndicator({ active, startedAt, label, liveTokens }: ActivityIndicatorProps) {
|
|
71
|
+
// Force re-render at 10Hz for shimmer animation + 1Hz elapsed clock.
|
|
72
|
+
const [, setTick] = useState(0)
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!active) return
|
|
75
|
+
const id = setInterval(() => setTick(x => x + 1), 100)
|
|
76
|
+
return () => clearInterval(id)
|
|
77
|
+
}, [active])
|
|
78
|
+
|
|
79
|
+
// Verb selection. Generic labels rotate through random words; specific
|
|
80
|
+
// labels (like "Running Bash") are shown verbatim.
|
|
81
|
+
const isGeneric = !label || label === "Thinking" || label === "Responding"
|
|
82
|
+
const verbIdxRef = useRef<number>(Math.floor(Math.random() * VERBS.length))
|
|
83
|
+
const lastRotateRef = useRef<number>(Date.now())
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!active || !isGeneric) return
|
|
87
|
+
verbIdxRef.current = Math.floor(Math.random() * VERBS.length)
|
|
88
|
+
lastRotateRef.current = Date.now()
|
|
89
|
+
}, [active, isGeneric, label])
|
|
90
|
+
|
|
91
|
+
if (!active || !startedAt) return null
|
|
92
|
+
|
|
93
|
+
// Time-based rotation. Every VERB_ROTATE_MS, pick a new random verb.
|
|
94
|
+
if (isGeneric && Date.now() - lastRotateRef.current >= VERB_ROTATE_MS) {
|
|
95
|
+
let next = Math.floor(Math.random() * VERBS.length)
|
|
96
|
+
if (VERBS.length > 1 && next === verbIdxRef.current) next = (next + 1) % VERBS.length
|
|
97
|
+
verbIdxRef.current = next
|
|
98
|
+
lastRotateRef.current = Date.now()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const verb = isGeneric ? VERBS[verbIdxRef.current] : (label ?? VERBS[0])
|
|
102
|
+
const shimmerColor = computeShimmer(startedAt)
|
|
103
|
+
const dots = ".".repeat(Math.floor((Date.now() / 400) % 4)) // 0..3, cycles ~1.6s
|
|
104
|
+
|
|
105
|
+
const elapsed = formatElapsed(Date.now() - startedAt)
|
|
106
|
+
const tokens = formatTokens(liveTokens)
|
|
107
|
+
const meta = `(${elapsed}${tokens ? ` · ↓ ${tokens} tokens` : ""} · esc to interrupt)`
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<box paddingLeft={1} paddingRight={1} marginTop={1}>
|
|
111
|
+
<text content={t`${fg(theme.toolDone)("✦ ")}${fg(shimmerColor)(verb + dots)} ${fg(theme.dim)(meta)}`} />
|
|
112
|
+
</box>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 0.6 Hz sine oscillation between SHIMMER_DIM and SHIMMER_BRIGHT.
|
|
117
|
+
function computeShimmer(startedAt: number): string {
|
|
118
|
+
const t = (Date.now() - startedAt) / 1000
|
|
119
|
+
const alpha = (Math.sin(t * 4) + 1) / 2 // 0..1, ~0.6 Hz
|
|
120
|
+
return lerpHex(SHIMMER_DIM, SHIMMER_BRIGHT, alpha)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function lerpHex(a: string, b: string, alpha: number): string {
|
|
124
|
+
const ar = parseInt(a.slice(1, 3), 16)
|
|
125
|
+
const ag = parseInt(a.slice(3, 5), 16)
|
|
126
|
+
const ab = parseInt(a.slice(5, 7), 16)
|
|
127
|
+
const br = parseInt(b.slice(1, 3), 16)
|
|
128
|
+
const bg = parseInt(b.slice(3, 5), 16)
|
|
129
|
+
const bb = parseInt(b.slice(5, 7), 16)
|
|
130
|
+
const r = Math.round(ar + (br - ar) * alpha)
|
|
131
|
+
const g = Math.round(ag + (bg - ag) * alpha)
|
|
132
|
+
const bl = Math.round(ab + (bb - ab) * alpha)
|
|
133
|
+
return "#" + [r, g, bl].map(v => v.toString(16).padStart(2, "0")).join("")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatElapsed(ms: number) {
|
|
137
|
+
const total = Math.max(0, Math.floor(ms / 1000))
|
|
138
|
+
if (total < 60) return `${total}s`
|
|
139
|
+
const m = Math.floor(total / 60)
|
|
140
|
+
const s = total % 60
|
|
141
|
+
return `${m}m ${s}s`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatTokens(n: number) {
|
|
145
|
+
if (n <= 0) return ""
|
|
146
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
|
147
|
+
return String(n)
|
|
148
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import { fg, t } from "@opentui/core"
|
|
4
|
+
import type { ApprovalRequest } from "../agent/types.js"
|
|
5
|
+
import { theme } from "../theme.js"
|
|
6
|
+
|
|
7
|
+
const OPTIONS = [
|
|
8
|
+
{ value: "allow", label: "Allow once", desc: "Run this time only" },
|
|
9
|
+
{ value: "deny", label: "Deny", desc: "Block this tool call" },
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
export interface ApprovalPromptProps {
|
|
13
|
+
request: ApprovalRequest
|
|
14
|
+
onResolve: (behavior: "allow" | "deny") => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ApprovalPrompt({ request, onResolve }: ApprovalPromptProps) {
|
|
18
|
+
const [idx, setIdx] = useState(0)
|
|
19
|
+
|
|
20
|
+
useKeyboard(key => {
|
|
21
|
+
if (key.name === "up" || key.name === "k") setIdx(i => Math.max(0, i - 1))
|
|
22
|
+
else if (key.name === "down" || key.name === "j") setIdx(i => Math.min(OPTIONS.length - 1, i + 1))
|
|
23
|
+
else if (key.name === "return") onResolve(OPTIONS[idx].value)
|
|
24
|
+
else if (key.name === "escape") onResolve("deny")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const summary = summarize(request)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<box flexDirection="column" borderStyle="double" borderColor={theme.toolRunning} padding={1}>
|
|
31
|
+
<text content={t`${fg(theme.toolRunning)("⚠ approval required: ")}${fg(theme.assistant)(request.tool)}`} />
|
|
32
|
+
{summary ? <text content={summary} fg={theme.dim} /> : null}
|
|
33
|
+
<box flexDirection="column" marginTop={1}>
|
|
34
|
+
{OPTIONS.map((opt, i) => {
|
|
35
|
+
const active = i === idx
|
|
36
|
+
const marker = active ? "› " : " "
|
|
37
|
+
const markerColor = active ? theme.accent : theme.dim
|
|
38
|
+
const labelColor = active ? theme.assistant : theme.dim
|
|
39
|
+
return (
|
|
40
|
+
<text
|
|
41
|
+
key={opt.value}
|
|
42
|
+
content={t`${fg(markerColor)(marker)}${fg(labelColor)(opt.label)} ${fg(theme.dim)("— " + opt.desc)}`}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
})}
|
|
46
|
+
</box>
|
|
47
|
+
<text content="↑/↓ to choose · enter to confirm · esc to deny" fg={theme.dim} />
|
|
48
|
+
</box>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function summarize(req: ApprovalRequest): string {
|
|
53
|
+
const primary = (req.input.file_path ?? req.input.path ?? req.input.command ?? req.input.url) as
|
|
54
|
+
| string
|
|
55
|
+
| undefined
|
|
56
|
+
if (typeof primary === "string") return primary.length > 100 ? primary.slice(0, 99) + "…" : primary
|
|
57
|
+
try { return JSON.stringify(req.input).slice(0, 200) } catch { return "" }
|
|
58
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Bottom row Claude Code-style:
|
|
2
|
+
// [permission banner / status] [token hint right-aligned]
|
|
3
|
+
// Replaced by SlashMenu when the user is composing a /command.
|
|
4
|
+
|
|
5
|
+
import { fg, t } from "@opentui/core"
|
|
6
|
+
import { useTerminalDimensions } from "@opentui/react"
|
|
7
|
+
import type { ChatState } from "../state/reducer.js"
|
|
8
|
+
import type { PermissionMode } from "../agent/types.js"
|
|
9
|
+
import { theme } from "../theme.js"
|
|
10
|
+
|
|
11
|
+
export interface BottomBarProps {
|
|
12
|
+
state: ChatState
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function BottomBar({ state }: BottomBarProps) {
|
|
16
|
+
const { width } = useTerminalDimensions()
|
|
17
|
+
const left = leftContent(state)
|
|
18
|
+
const right = rightContent(state)
|
|
19
|
+
const padded = padBetween(left.text, right.text, Math.max(40, width - 4))
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
23
|
+
<text
|
|
24
|
+
content={
|
|
25
|
+
left.styled && right.styled
|
|
26
|
+
? t`${fg(left.color)(left.text)}${fg(theme.dim)(padded.spacer)}${fg(theme.dim)(right.text)}`
|
|
27
|
+
: right.styled
|
|
28
|
+
? t`${fg(theme.dim)(left.text)}${fg(theme.dim)(padded.spacer)}${fg(theme.dim)(right.text)}`
|
|
29
|
+
: t`${fg(left.color)(left.text)}${fg(theme.dim)(padded.spacer)}${fg(theme.dim)(right.text)}`
|
|
30
|
+
}
|
|
31
|
+
/>
|
|
32
|
+
</box>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function leftContent(state: ChatState): { text: string; color: string; styled: boolean } {
|
|
37
|
+
if (state.permissionMode === "default") {
|
|
38
|
+
return { text: "", color: theme.dim, styled: false }
|
|
39
|
+
}
|
|
40
|
+
const { label, color } = banner(state.permissionMode)
|
|
41
|
+
const hint = isBusy(state.status)
|
|
42
|
+
? "(shift+tab to cycle · esc to interrupt)"
|
|
43
|
+
: "(shift+tab to cycle)"
|
|
44
|
+
return { text: `▶▶ ${label} ${hint}`, color, styled: true }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function rightContent(state: ChatState): { text: string; styled: boolean } {
|
|
48
|
+
const total = state.usage ? state.usage.inputTokens + state.usage.outputTokens : 0
|
|
49
|
+
if (total > 0) return { text: `new task? /clear to save ${formatTokens(total)} tokens`, styled: true }
|
|
50
|
+
return { text: "", styled: false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function banner(mode: PermissionMode): { label: string; color: string } {
|
|
54
|
+
switch (mode) {
|
|
55
|
+
case "acceptEdits": return { label: "accept edits on", color: theme.modeAcceptEdits }
|
|
56
|
+
case "bypassPermissions": return { label: "bypass permissions on", color: theme.modeBypass }
|
|
57
|
+
case "plan": return { label: "plan mode on", color: theme.modePlan }
|
|
58
|
+
default: return { label: "", color: theme.dim }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isBusy(s: ChatState["status"]) {
|
|
63
|
+
return s === "thinking" || s === "streaming" || s === "awaiting_approval"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function padBetween(left: string, right: string, width: number): { spacer: string } {
|
|
67
|
+
const gap = Math.max(2, width - left.length - right.length)
|
|
68
|
+
return { spacer: " ".repeat(gap) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatTokens(n: number) {
|
|
72
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
|
73
|
+
return String(n)
|
|
74
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Chat scrollback. Welcome banner pinned to the top of the history.
|
|
2
|
+
// Content flows top-down (Claude Code / Codex convention). When the
|
|
3
|
+
// conversation overflows the viewport, stickyScroll auto-scrolls to
|
|
4
|
+
// keep the latest message in view.
|
|
5
|
+
|
|
6
|
+
import type { ReactNode } from "react"
|
|
7
|
+
import type { ChatEntry } from "../state/reducer.js"
|
|
8
|
+
import { AssistantMessage, SystemMessage, UserMessage } from "./Message.js"
|
|
9
|
+
import { SubagentBlock } from "./SubagentBlock.js"
|
|
10
|
+
import { ToolBlock } from "./ToolBlock.js"
|
|
11
|
+
import { Divider } from "./Divider.js"
|
|
12
|
+
import { EditGroup } from "./tools/EditGroup.js"
|
|
13
|
+
|
|
14
|
+
type Tool = Extract<ChatEntry, { kind: "tool" }>
|
|
15
|
+
|
|
16
|
+
export interface ChatProps {
|
|
17
|
+
entries: ChatEntry[]
|
|
18
|
+
header?: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Chat({ entries, header }: ChatProps) {
|
|
22
|
+
const items = groupEntries(entries)
|
|
23
|
+
return (
|
|
24
|
+
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom">
|
|
25
|
+
{header}
|
|
26
|
+
{items.map(item => {
|
|
27
|
+
if (item.kind === "edit-group") {
|
|
28
|
+
return <EditGroup key={item.tools[0]!.id} tools={item.tools} />
|
|
29
|
+
}
|
|
30
|
+
return renderEntry(item.entry)
|
|
31
|
+
})}
|
|
32
|
+
</scrollbox>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderEntry(entry: ChatEntry): ReactNode {
|
|
37
|
+
switch (entry.kind) {
|
|
38
|
+
case "user":
|
|
39
|
+
return <UserMessage key={entry.id} text={entry.text} />
|
|
40
|
+
case "assistant":
|
|
41
|
+
return (
|
|
42
|
+
<AssistantMessage
|
|
43
|
+
key={entry.id}
|
|
44
|
+
text={entry.text}
|
|
45
|
+
thinking={entry.thinking}
|
|
46
|
+
streaming={entry.streaming}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
case "tool":
|
|
50
|
+
return <ToolBlock key={entry.id} tool={entry} />
|
|
51
|
+
case "subagent":
|
|
52
|
+
return <SubagentBlock key={entry.id} entry={entry} />
|
|
53
|
+
case "system":
|
|
54
|
+
return <SystemMessage key={entry.id} text={entry.text} tone={entry.tone} />
|
|
55
|
+
case "divider":
|
|
56
|
+
return <Divider key={entry.id} />
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── grouping ───────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
type RenderItem =
|
|
63
|
+
| { kind: "single"; entry: ChatEntry }
|
|
64
|
+
| { kind: "edit-group"; tools: Tool[] }
|
|
65
|
+
|
|
66
|
+
// Collapse runs of consecutive Edit/Write tools targeting the same file
|
|
67
|
+
// into a single render unit. Other entry kinds break the run.
|
|
68
|
+
function groupEntries(entries: ChatEntry[]): RenderItem[] {
|
|
69
|
+
const out: RenderItem[] = []
|
|
70
|
+
let buffer: Tool[] = []
|
|
71
|
+
const flush = () => {
|
|
72
|
+
if (buffer.length === 0) return
|
|
73
|
+
if (buffer.length === 1) {
|
|
74
|
+
out.push({ kind: "single", entry: buffer[0]! })
|
|
75
|
+
} else {
|
|
76
|
+
out.push({ kind: "edit-group", tools: buffer })
|
|
77
|
+
}
|
|
78
|
+
buffer = []
|
|
79
|
+
}
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
if (e.kind === "tool" && (e.name === "Edit" || e.name === "Write")) {
|
|
82
|
+
const path = String(e.args.file_path ?? "")
|
|
83
|
+
const head = buffer[0]
|
|
84
|
+
const headPath = head ? String(head.args.file_path ?? "") : null
|
|
85
|
+
if (head && headPath === path) {
|
|
86
|
+
buffer.push(e)
|
|
87
|
+
} else {
|
|
88
|
+
flush()
|
|
89
|
+
buffer.push(e)
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
flush()
|
|
93
|
+
out.push({ kind: "single", entry: e })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
flush()
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useTerminalDimensions } from "@opentui/react"
|
|
2
|
+
import { theme } from "../theme.js"
|
|
3
|
+
|
|
4
|
+
export function Divider() {
|
|
5
|
+
const { width } = useTerminalDimensions()
|
|
6
|
+
const w = Math.max(20, Math.min(width - 2, 200))
|
|
7
|
+
return (
|
|
8
|
+
<box marginTop={1} marginBottom={1}>
|
|
9
|
+
<text content={"─".repeat(w)} fg={theme.divider} />
|
|
10
|
+
</box>
|
|
11
|
+
)
|
|
12
|
+
}
|