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.
Files changed (44) hide show
  1. package/dist/cli/cmd-chat.js +69 -2
  2. package/dist/cli/cmd-chat.js.map +1 -1
  3. package/dist/core/version.js +1 -1
  4. package/dist/runtime/orchestrate.js +1 -1
  5. package/dist/telegram/admin-bot.js +1 -1
  6. package/package.json +5 -2
  7. package/tui/package.json +23 -0
  8. package/tui/src/App.tsx +161 -0
  9. package/tui/src/agent/parse.ts +184 -0
  10. package/tui/src/agent/types.ts +75 -0
  11. package/tui/src/commands/registry.ts +301 -0
  12. package/tui/src/components/ActivityIndicator.tsx +148 -0
  13. package/tui/src/components/ApprovalPrompt.tsx +58 -0
  14. package/tui/src/components/BottomBar.tsx +74 -0
  15. package/tui/src/components/Chat.tsx +98 -0
  16. package/tui/src/components/Divider.tsx +12 -0
  17. package/tui/src/components/HorizontalRule.tsx +12 -0
  18. package/tui/src/components/Input.tsx +126 -0
  19. package/tui/src/components/Markdown.tsx +290 -0
  20. package/tui/src/components/Message.tsx +77 -0
  21. package/tui/src/components/PermissionBanner.tsx +30 -0
  22. package/tui/src/components/Picker.tsx +127 -0
  23. package/tui/src/components/SlashMenu.tsx +46 -0
  24. package/tui/src/components/SubagentBlock.tsx +16 -0
  25. package/tui/src/components/ToolBlock.tsx +24 -0
  26. package/tui/src/components/Welcome.tsx +75 -0
  27. package/tui/src/components/tools/BashTool.tsx +27 -0
  28. package/tui/src/components/tools/DefaultTool.tsx +38 -0
  29. package/tui/src/components/tools/DiffView.tsx +91 -0
  30. package/tui/src/components/tools/EditGroup.tsx +97 -0
  31. package/tui/src/components/tools/EditTool.tsx +41 -0
  32. package/tui/src/components/tools/ReadTool.tsx +41 -0
  33. package/tui/src/components/tools/SearchTool.tsx +27 -0
  34. package/tui/src/components/tools/ToolHeader.tsx +48 -0
  35. package/tui/src/components/tools/WriteTool.tsx +54 -0
  36. package/tui/src/config.ts +6 -0
  37. package/tui/src/hooks/useAgent.ts +202 -0
  38. package/tui/src/main.tsx +12 -0
  39. package/tui/src/models.ts +26 -0
  40. package/tui/src/state/reducer.ts +290 -0
  41. package/tui/src/theme.ts +28 -0
  42. package/tui/src/util/nativeNotify.ts +47 -0
  43. package/tui/src/util/spinner.ts +11 -0
  44. 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
+ }