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,46 @@
1
+ // Simple, robust rendering: one <text content="..." fg/bg /> per row.
2
+ // (Earlier multi-slot t`` template approach was hitting layout drift in
3
+ // OpenTUI when slots had wide padded content.)
4
+
5
+ import { theme } from "../theme.js"
6
+ import type { SlashCommand } from "../commands/registry.js"
7
+
8
+ export interface SlashMenuProps {
9
+ commands: SlashCommand[]
10
+ selectedIndex: number
11
+ /** When true, drop the leading "/" — items are arg suggestions, not commands. */
12
+ argMode?: boolean
13
+ }
14
+
15
+ const MAX_VISIBLE = 12
16
+
17
+ export function SlashMenu({ commands, selectedIndex, argMode }: SlashMenuProps) {
18
+ if (commands.length === 0) return null
19
+
20
+ const start = Math.max(
21
+ 0,
22
+ Math.min(selectedIndex - Math.floor(MAX_VISIBLE / 2), commands.length - MAX_VISIBLE),
23
+ )
24
+ const visible = commands.slice(start, Math.max(start, 0) + MAX_VISIBLE)
25
+ const longest = Math.max(...commands.map(c => c.name.length))
26
+
27
+ return (
28
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
29
+ {visible.map((cmd, i) => {
30
+ const realIdx = start + i
31
+ const active = realIdx === selectedIndex
32
+ const marker = active ? "› " : " "
33
+ const prefix = argMode ? "" : "/"
34
+ const line = `${marker}${prefix}${cmd.name.padEnd(longest)} ${cmd.description}`
35
+ return (
36
+ <text
37
+ key={cmd.name}
38
+ content={line}
39
+ fg={active ? theme.assistant : theme.dim}
40
+ bg={active ? theme.userBg : undefined}
41
+ />
42
+ )
43
+ })}
44
+ </box>
45
+ )
46
+ }
@@ -0,0 +1,16 @@
1
+ import { fg, t } from "@opentui/core"
2
+ import type { ChatEntry } from "../state/reducer.js"
3
+ import { theme } from "../theme.js"
4
+
5
+ type Subagent = Extract<ChatEntry, { kind: "subagent" }>
6
+
7
+ export function SubagentBlock({ entry }: { entry: Subagent }) {
8
+ const parts = [`subagent: ${entry.agentId}`]
9
+ if (entry.toolCount != null) parts.push(`${entry.toolCount} tools`)
10
+ if (entry.duration != null) parts.push(`${entry.duration}s`)
11
+ return (
12
+ <box flexDirection="column" marginTop={1}>
13
+ <text content={t`${fg(entry.done ? theme.toolDone : theme.accent)(entry.done ? "• " : "◆ ")}${fg(theme.assistant)(parts.join(" · "))}`} />
14
+ </box>
15
+ )
16
+ }
@@ -0,0 +1,24 @@
1
+ // Dispatches per-tool renderers. Add a new tool? Add a case here +
2
+ // a renderer in components/tools/.
3
+
4
+ import type { ChatEntry } from "../state/reducer.js"
5
+ import { BashTool } from "./tools/BashTool.js"
6
+ import { EditTool } from "./tools/EditTool.js"
7
+ import { ReadTool } from "./tools/ReadTool.js"
8
+ import { WriteTool } from "./tools/WriteTool.js"
9
+ import { SearchTool } from "./tools/SearchTool.js"
10
+ import { DefaultTool } from "./tools/DefaultTool.js"
11
+
12
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
13
+
14
+ export function ToolBlock({ tool }: { tool: Tool }) {
15
+ switch (tool.name) {
16
+ case "Bash": return <BashTool tool={tool} />
17
+ case "Edit": return <EditTool tool={tool} />
18
+ case "Write": return <WriteTool tool={tool} />
19
+ case "Read": return <ReadTool tool={tool} />
20
+ case "Glob":
21
+ case "Grep": return <SearchTool tool={tool} />
22
+ default: return <DefaultTool tool={tool} />
23
+ }
24
+ }
@@ -0,0 +1,75 @@
1
+ // CamelAGI welcome banner
2
+
3
+ import { fg, t, bold } from "@opentui/core"
4
+ import { theme } from "../theme.js"
5
+
6
+ export interface WelcomeProps {
7
+ cwd: string
8
+ model: string
9
+ version: string
10
+ }
11
+
12
+ const TIPS = [
13
+ "Type / to see commands",
14
+ "Shift+Tab cycles permission modes",
15
+ "Esc interrupts a running task",
16
+ ]
17
+
18
+ const CAMEL = [
19
+ " 🐪 ",
20
+ " CamelAGI ",
21
+ ]
22
+
23
+ export function Welcome({ cwd, model, version }: WelcomeProps) {
24
+ const cwdShort = shortenPath(cwd)
25
+ const modelLabel = shortenModel(model)
26
+
27
+ return (
28
+ <box flexDirection="column" marginTop={1} marginBottom={1}>
29
+ <box
30
+ flexDirection="row"
31
+ borderStyle="rounded"
32
+ borderColor={theme.border}
33
+ title={` CamelAGI v${version} `}
34
+ titleAlignment="left"
35
+ paddingLeft={2}
36
+ paddingRight={2}
37
+ paddingTop={1}
38
+ paddingBottom={1}
39
+ >
40
+ <box flexDirection="column" width={30} paddingRight={2}>
41
+ {CAMEL.map((line, i) => (
42
+ <text key={i} content={t`${bold(fg(theme.assistant)(line))}`} />
43
+ ))}
44
+ <text content="" />
45
+ <text content={modelLabel} fg={theme.assistant} />
46
+ <text content={cwdShort} fg={theme.dim} />
47
+ </box>
48
+ <box flexDirection="column" flexGrow={1}>
49
+ <text content={t`${bold(fg(theme.assistant)("Getting started"))}`} />
50
+ {TIPS.map((tip, i) => (
51
+ <text key={i} content={tip} fg={theme.assistant} />
52
+ ))}
53
+ <text content="" />
54
+ <text content="─────────────────────────────────────" fg={theme.border} />
55
+ <text content="" />
56
+ <text content="Connect to gateway at ws://127.0.0.1:18305" fg={theme.dim} />
57
+ <text content="Run 'camel serve' in another terminal first" fg={theme.dim} />
58
+ </box>
59
+ </box>
60
+ </box>
61
+ )
62
+ }
63
+
64
+ function shortenModel(model: string): string {
65
+ const last = model.split("/").pop() ?? model
66
+ return last
67
+ .split("-")
68
+ .map(part => part[0]?.toUpperCase() + part.slice(1))
69
+ .join(" ")
70
+ }
71
+
72
+ function shortenPath(p: string): string {
73
+ const home = process.env.HOME ?? ""
74
+ return home && p.startsWith(home) ? "~" + p.slice(home.length) : p
75
+ }
@@ -0,0 +1,27 @@
1
+ // Codex-style Bash block: action header + brief description, no stdout
2
+ // dump (the agent summarizes the output in its response — showing it twice
3
+ // just clutters the chat). Command is kept on a dim secondary line so it's
4
+ // still visible when you need it.
5
+
6
+ import { fg, t } from "@opentui/core"
7
+ import type { ChatEntry } from "../../state/reducer.js"
8
+ import { theme } from "../../theme.js"
9
+ import { ToolHeader } from "./ToolHeader.js"
10
+
11
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
12
+
13
+ export function BashTool({ tool }: { tool: Tool }) {
14
+ const command = String(tool.args.command ?? "")
15
+ const description = tool.args.description ? String(tool.args.description) : undefined
16
+ // Surface the agent's description as the tool subtitle (Codex pattern).
17
+ // The actual command stays one line below in dim.
18
+ return (
19
+ <box flexDirection="column" marginTop={1}>
20
+ <ToolHeader tool={tool} primary={undefined} secondary={description ?? command} verbOverride="Ran" />
21
+ <text content={t` ${fg(theme.branch)("└ ")}${fg(theme.dim)("$ " + command)}`} />
22
+ {tool.status === "error" && tool.result ? (
23
+ <text content={t` ${fg(theme.branch)("└ ")}${fg(theme.toolError)((tool.result.split("\n")[0] ?? "").slice(0, 200))}`} />
24
+ ) : null}
25
+ </box>
26
+ )
27
+ }
@@ -0,0 +1,38 @@
1
+ import type { ChatEntry } from "../../state/reducer.js"
2
+ import { theme } from "../../theme.js"
3
+ import { ToolHeader } from "./ToolHeader.js"
4
+
5
+ const PREVIEW_LINES = 6
6
+
7
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
8
+
9
+ export function DefaultTool({ tool }: { tool: Tool }) {
10
+ const argSummary = summarizeArgs(tool.args)
11
+ const result = tool.result ?? (tool.status === "running" ? "" : "")
12
+ const lines = result ? result.split("\n").filter(l => l.length > 0) : []
13
+ const truncated = lines.length > PREVIEW_LINES
14
+ const preview = truncated ? lines.slice(0, PREVIEW_LINES) : lines
15
+
16
+ return (
17
+ <box flexDirection="column" marginTop={1}>
18
+ <ToolHeader tool={tool} primary={argSummary} />
19
+ {preview.map((line, i) => (
20
+ <text key={i} content={" " + line} fg={theme.dim} />
21
+ ))}
22
+ {truncated ? (
23
+ <text content={` …(+${lines.length - PREVIEW_LINES} more)`} fg={theme.dim} />
24
+ ) : null}
25
+ </box>
26
+ )
27
+ }
28
+
29
+ function summarizeArgs(args: Record<string, unknown>): string | undefined {
30
+ const primary = (args.url ?? args.query ?? args.path ?? args.file_path) as string | undefined
31
+ if (typeof primary === "string") return truncate(primary, 60)
32
+ if (Object.keys(args).length === 0) return undefined
33
+ try { return truncate(JSON.stringify(args), 80) } catch { return undefined }
34
+ }
35
+
36
+ function truncate(s: string, n: number) {
37
+ return s.length > n ? s.slice(0, n - 1) + "…" : s
38
+ }
@@ -0,0 +1,91 @@
1
+ // Codex-style diff view: line-numbered rows, green/red tint, no patch header.
2
+
3
+ import { diffLines } from "diff"
4
+ import { theme } from "../../theme.js"
5
+
6
+ const DEFAULT_VISIBLE = 14
7
+
8
+ export interface DiffViewProps {
9
+ oldText: string
10
+ newText: string
11
+ expanded?: boolean
12
+ }
13
+
14
+ export function diffStats(oldText: string, newText: string): { added: number; removed: number } {
15
+ const changes = diffLines(oldText ?? "", newText ?? "")
16
+ let added = 0
17
+ let removed = 0
18
+ for (const c of changes) {
19
+ const segs = c.value.split("\n")
20
+ if (segs[segs.length - 1] === "") segs.pop()
21
+ if (c.added) added += segs.length
22
+ else if (c.removed) removed += segs.length
23
+ }
24
+ return { added, removed }
25
+ }
26
+
27
+ export function DiffView({ oldText, newText, expanded = false }: DiffViewProps) {
28
+ const rows = buildRows(oldText ?? "", newText ?? "")
29
+ const visible = expanded ? rows : rows.slice(0, DEFAULT_VISIBLE)
30
+ const truncated = !expanded && rows.length > DEFAULT_VISIBLE
31
+
32
+ return (
33
+ <box flexDirection="column" width="100%">
34
+ {visible.map((r, i) => <DiffRow key={i} {...r} />)}
35
+ {truncated ? (
36
+ <text content={` …(+${rows.length - DEFAULT_VISIBLE} more lines)`} fg={theme.dim} />
37
+ ) : null}
38
+ </box>
39
+ )
40
+ }
41
+
42
+ type Row = { kind: "add" | "remove" | "context"; lineNo: number; text: string }
43
+
44
+ function buildRows(oldText: string, newText: string): Row[] {
45
+ const changes = diffLines(oldText, newText)
46
+ const rows: Row[] = []
47
+ let newLineNo = 1
48
+ let oldLineNo = 1
49
+ for (const c of changes) {
50
+ const segs = c.value.split("\n")
51
+ if (segs[segs.length - 1] === "") segs.pop()
52
+ for (const seg of segs) {
53
+ if (c.added) {
54
+ rows.push({ kind: "add", lineNo: newLineNo++, text: seg })
55
+ } else if (c.removed) {
56
+ rows.push({ kind: "remove", lineNo: oldLineNo++, text: seg })
57
+ } else {
58
+ // Skip pure-empty context rows — they show up as orphan line numbers.
59
+ if (seg.length > 0) {
60
+ rows.push({ kind: "context", lineNo: newLineNo, text: seg })
61
+ }
62
+ newLineNo++
63
+ oldLineNo++
64
+ }
65
+ }
66
+ }
67
+ // Trim leading/trailing empty context rows — they're just noise from
68
+ // trailing newlines and look like orphan line numbers.
69
+ while (rows.length && rows[0].kind === "context" && rows[0].text === "") rows.shift()
70
+ while (rows.length && rows[rows.length - 1].kind === "context" && rows[rows.length - 1].text === "") rows.pop()
71
+ return rows
72
+ }
73
+
74
+ function DiffRow({ kind, lineNo, text }: Row) {
75
+ const num = String(lineNo).padStart(3, " ")
76
+ if (kind === "add") {
77
+ return (
78
+ <box width="100%" backgroundColor={theme.diffAdd}>
79
+ <text content={`${num} +${text}`} fg={theme.diffAddFg} bg={theme.diffAdd} />
80
+ </box>
81
+ )
82
+ }
83
+ if (kind === "remove") {
84
+ return (
85
+ <box width="100%" backgroundColor={theme.diffRemove}>
86
+ <text content={`${num} -${text}`} fg={theme.diffRemoveFg} bg={theme.diffRemove} />
87
+ </box>
88
+ )
89
+ }
90
+ return <text content={`${num} ${text}`} fg={theme.dim} />
91
+ }
@@ -0,0 +1,97 @@
1
+ // Renders a run of consecutive Edit/Write tools on the same file as a
2
+ // single block. Avoids the "Updated math.swift" header repeating 7×
3
+ // when the agent batches edits.
4
+
5
+ import { fg, t } from "@opentui/core"
6
+ import type { ChatEntry } from "../../state/reducer.js"
7
+ import { theme } from "../../theme.js"
8
+ import { ToolHeader } from "./ToolHeader.js"
9
+ import { DiffView, diffStats } from "./DiffView.js"
10
+
11
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
12
+
13
+ export function EditGroup({ tools }: { tools: Tool[] }) {
14
+ if (tools.length === 1) {
15
+ // Group of 1 — caller should have rendered the single tool directly,
16
+ // but handle gracefully.
17
+ }
18
+
19
+ const filePath = String(tools[0]!.args.file_path ?? "")
20
+ const allWrites = tools.every(t => t.name === "Write")
21
+ const verb = allWrites ? "Added" : "Updated"
22
+
23
+ // Aggregate stats across every edit in the group.
24
+ let totalAdd = 0
25
+ let totalRemove = 0
26
+ for (const tool of tools) {
27
+ if (tool.name === "Write") {
28
+ const c = String(tool.args.content ?? "")
29
+ const lines = c.split("\n")
30
+ if (lines[lines.length - 1] === "") lines.pop()
31
+ totalAdd += lines.length
32
+ } else {
33
+ const s = diffStats(
34
+ String(tool.args.old_string ?? ""),
35
+ String(tool.args.new_string ?? ""),
36
+ )
37
+ totalAdd += s.added
38
+ totalRemove += s.removed
39
+ }
40
+ }
41
+
42
+ // Use the latest tool's status for the header glyph (running/done/error).
43
+ const headerTool = tools[tools.length - 1]!
44
+
45
+ return (
46
+ <box flexDirection="column" width="100%" marginTop={1}>
47
+ <ToolHeader
48
+ tool={headerTool}
49
+ primary={shortenPath(filePath)}
50
+ secondary={`+${totalAdd} -${totalRemove} · ${tools.length} edits`}
51
+ verbOverride={verb}
52
+ />
53
+ <box flexDirection="column" width="100%" paddingLeft={2}>
54
+ {tools.map((tool, i) => (
55
+ <Hunk key={tool.id} tool={tool} index={i} total={tools.length} />
56
+ ))}
57
+ </box>
58
+ </box>
59
+ )
60
+ }
61
+
62
+ function Hunk({ tool, index, total }: { tool: Tool; index: number; total: number }) {
63
+ const showSep = index < total - 1
64
+ if (tool.name === "Write") {
65
+ const content = String(tool.args.content ?? "")
66
+ const lines = content.split("\n")
67
+ if (lines[lines.length - 1] === "") lines.pop()
68
+ return (
69
+ <box flexDirection="column" width="100%">
70
+ {lines.map((line, i) => {
71
+ const num = String(i + 1).padStart(3, " ")
72
+ return (
73
+ <box key={i} width="100%" backgroundColor={theme.diffAdd}>
74
+ <text content={`${num} +${line}`} fg={theme.diffAddFg} bg={theme.diffAdd} />
75
+ </box>
76
+ )
77
+ })}
78
+ {showSep ? <text content={t`${fg(theme.dim)(" ⋯")}`} /> : null}
79
+ </box>
80
+ )
81
+ }
82
+ return (
83
+ <box flexDirection="column" width="100%">
84
+ <DiffView
85
+ oldText={String(tool.args.old_string ?? "")}
86
+ newText={String(tool.args.new_string ?? "")}
87
+ />
88
+ {showSep ? <text content={t`${fg(theme.dim)(" ⋯")}`} /> : null}
89
+ </box>
90
+ )
91
+ }
92
+
93
+ function shortenPath(p: string): string {
94
+ const parts = p.split("/")
95
+ if (parts.length <= 2) return p
96
+ return ".../" + parts.slice(-2).join("/")
97
+ }
@@ -0,0 +1,41 @@
1
+ import { fg, t } from "@opentui/core"
2
+ import type { ChatEntry } from "../../state/reducer.js"
3
+ import { theme } from "../../theme.js"
4
+ import { ToolHeader } from "./ToolHeader.js"
5
+ import { DiffView, diffStats } from "./DiffView.js"
6
+
7
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
8
+
9
+ export function EditTool({ tool }: { tool: Tool }) {
10
+ const filePath = String(tool.args.file_path ?? "")
11
+ const oldString = String(tool.args.old_string ?? "")
12
+ const newString = String(tool.args.new_string ?? "")
13
+
14
+ const showDiff = tool.status !== "denied" && tool.status !== "error"
15
+ const stats = diffStats(oldString, newString)
16
+
17
+ return (
18
+ <box flexDirection="column" width="100%" marginTop={1}>
19
+ <ToolHeader
20
+ tool={tool}
21
+ primary={shortenPath(filePath)}
22
+ secondary={`+${stats.added} -${stats.removed}`}
23
+ verbOverride="Updated"
24
+ />
25
+ {showDiff ? (
26
+ <box flexDirection="column" width="100%" paddingLeft={2}>
27
+ <DiffView oldText={oldString} newText={newString} />
28
+ </box>
29
+ ) : null}
30
+ {tool.result && (tool.status === "error" || tool.status === "denied") ? (
31
+ <text content={t` ${fg(theme.branch)("└ ")}${fg(theme.toolError)(tool.result)}`} />
32
+ ) : null}
33
+ </box>
34
+ )
35
+ }
36
+
37
+ function shortenPath(p: string): string {
38
+ const parts = p.split("/")
39
+ if (parts.length <= 2) return p
40
+ return ".../" + parts.slice(-2).join("/")
41
+ }
@@ -0,0 +1,41 @@
1
+ import { fg, t } from "@opentui/core"
2
+ import type { ChatEntry } from "../../state/reducer.js"
3
+ import { theme } from "../../theme.js"
4
+ import { ToolHeader } from "./ToolHeader.js"
5
+
6
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
7
+
8
+ export function ReadTool({ tool }: { tool: Tool }) {
9
+ const filePath = String(tool.args.file_path ?? "")
10
+ const offset = tool.args.offset != null ? Number(tool.args.offset) : undefined
11
+ const limit = tool.args.limit != null ? Number(tool.args.limit) : undefined
12
+
13
+ const result = tool.result ?? ""
14
+ const lineCount = result ? result.split("\n").length : 0
15
+ const summary = tool.status === "running"
16
+ ? "reading…"
17
+ : tool.status === "done"
18
+ ? `${lineCount} line${lineCount === 1 ? "" : "s"}`
19
+ : tool.status === "error"
20
+ ? (result || "error").split("\n")[0].slice(0, 80)
21
+ : ""
22
+
23
+ const range = offset != null || limit != null
24
+ ? `lines ${offset ?? 1}–${limit ? (offset ?? 0) + limit : "end"}`
25
+ : undefined
26
+
27
+ return (
28
+ <box flexDirection="column" marginTop={1}>
29
+ <ToolHeader tool={tool} primary={shortenPath(filePath)} secondary={range} />
30
+ {summary ? (
31
+ <text content={t` ${fg(theme.branch)("└ ")}${fg(theme.dim)(summary)}`} />
32
+ ) : null}
33
+ </box>
34
+ )
35
+ }
36
+
37
+ function shortenPath(p: string): string {
38
+ const parts = p.split("/")
39
+ if (parts.length <= 2) return p
40
+ return ".../" + parts.slice(-2).join("/")
41
+ }
@@ -0,0 +1,27 @@
1
+ import { fg, t } from "@opentui/core"
2
+ import type { ChatEntry } from "../../state/reducer.js"
3
+ import { theme } from "../../theme.js"
4
+ import { ToolHeader } from "./ToolHeader.js"
5
+
6
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
7
+
8
+ export function SearchTool({ tool }: { tool: Tool }) {
9
+ const pattern = String(tool.args.pattern ?? tool.args.query ?? "")
10
+ const path = tool.args.path ? String(tool.args.path) : undefined
11
+ const result = tool.result ?? ""
12
+ const matchLines = result.split("\n").filter(l => l.trim().length > 0)
13
+ const summary = tool.status === "running"
14
+ ? "searching…"
15
+ : tool.status === "done"
16
+ ? `${matchLines.length} match${matchLines.length === 1 ? "" : "es"}`
17
+ : ""
18
+
19
+ return (
20
+ <box flexDirection="column" marginTop={1}>
21
+ <ToolHeader tool={tool} primary={pattern} secondary={path} />
22
+ {summary ? (
23
+ <text content={t` ${fg(theme.branch)("└ ")}${fg(theme.dim)(summary)}`} />
24
+ ) : null}
25
+ </box>
26
+ )
27
+ }
@@ -0,0 +1,48 @@
1
+ import { fg, t } from "@opentui/core"
2
+ import type { ChatEntry } from "../../state/reducer.js"
3
+ import { theme } from "../../theme.js"
4
+
5
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
6
+
7
+ export function statusVisual(status: Tool["status"]) {
8
+ switch (status) {
9
+ case "running": return { glyph: "●", color: theme.toolRunning, label: "running" }
10
+ case "done": return { glyph: "●", color: theme.bullet, label: "done" }
11
+ case "error": return { glyph: "✗", color: theme.toolError, label: "error" }
12
+ case "denied": return { glyph: "⏸", color: theme.toolDenied, label: "denied" }
13
+ }
14
+ }
15
+
16
+ export function ToolHeader({
17
+ tool,
18
+ primary,
19
+ secondary,
20
+ verbOverride,
21
+ }: {
22
+ tool: Tool
23
+ primary?: string
24
+ secondary?: string
25
+ /** Display label (Codex action-verb style). Falls back to tool.name. */
26
+ verbOverride?: string
27
+ }) {
28
+ const { glyph, color } = statusVisual(tool.status)
29
+ const name = verbOverride ?? toolVerb(tool.name)
30
+ const content = primary && secondary
31
+ ? t`${fg(color)(glyph + " ")}${fg(theme.assistant)(name)} ${fg(theme.dim)(primary)} ${fg(theme.dim)(secondary)}`
32
+ : primary
33
+ ? t`${fg(color)(glyph + " ")}${fg(theme.assistant)(name)} ${fg(theme.dim)(primary)}`
34
+ : secondary
35
+ ? t`${fg(color)(glyph + " ")}${fg(theme.assistant)(name)} ${fg(theme.dim)(secondary)}`
36
+ : t`${fg(color)(glyph + " ")}${fg(theme.assistant)(name)}`
37
+ return <text content={content} />
38
+ }
39
+
40
+ /** Map tool ids to action-verb labels (Codex aesthetic). Only the loud
41
+ * ones get verbs; everything else keeps its native name. */
42
+ function toolVerb(name: string): string {
43
+ switch (name) {
44
+ case "Bash": return "Ran"
45
+ case "Glob": return "Searched"
46
+ default: return name
47
+ }
48
+ }
@@ -0,0 +1,54 @@
1
+ import { fg, t } from "@opentui/core"
2
+ import type { ChatEntry } from "../../state/reducer.js"
3
+ import { theme } from "../../theme.js"
4
+ import { ToolHeader } from "./ToolHeader.js"
5
+
6
+ const PREVIEW_LINES = 14
7
+
8
+ type Tool = Extract<ChatEntry, { kind: "tool" }>
9
+
10
+ export function WriteTool({ tool }: { tool: Tool }) {
11
+ const filePath = String(tool.args.file_path ?? "")
12
+ const content = String(tool.args.content ?? "")
13
+ const lines = content.split("\n")
14
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
15
+ const truncated = lines.length > PREVIEW_LINES
16
+ const preview = truncated ? lines.slice(0, PREVIEW_LINES) : lines
17
+
18
+ return (
19
+ <box flexDirection="column" width="100%" marginTop={1}>
20
+ <ToolHeader
21
+ tool={tool}
22
+ primary={shortenPath(filePath)}
23
+ secondary={`+${lines.length} -0`}
24
+ verbOverride="Added"
25
+ />
26
+ <box flexDirection="column" width="100%" paddingLeft={2}>
27
+ {preview.map((line, i) => {
28
+ const num = String(i + 1).padStart(3, " ")
29
+ return (
30
+ <box key={i} width="100%" backgroundColor={theme.diffAdd}>
31
+ <text
32
+ content={`${num} +${line}`}
33
+ fg={theme.diffAddFg}
34
+ bg={theme.diffAdd}
35
+ />
36
+ </box>
37
+ )
38
+ })}
39
+ {truncated ? (
40
+ <text content={` …(+${lines.length - PREVIEW_LINES} more)`} fg={theme.dim} />
41
+ ) : null}
42
+ </box>
43
+ {tool.result && tool.status === "error" ? (
44
+ <text content={t` ${fg(theme.branch)("└ ")}${fg(theme.toolError)(tool.result)}`} />
45
+ ) : null}
46
+ </box>
47
+ )
48
+ }
49
+
50
+ function shortenPath(p: string): string {
51
+ const parts = p.split("/")
52
+ if (parts.length <= 2) return p
53
+ return ".../" + parts.slice(-2).join("/")
54
+ }
@@ -0,0 +1,6 @@
1
+ // CamelAGI gateway. The TUI connects via WebSocket.
2
+ // Override at launch: CAMELAGI_WS_URL=ws://192.168.1.5:18305
3
+ export const WS_URL =
4
+ process.env.CAMELAGI_WS_URL ?? "ws://127.0.0.1:18305"
5
+
6
+ export const DEFAULT_MODEL = "claude-sonnet-4-20250514"