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,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
|
+
}
|