Skip to content

Commit 654386c

Browse files
committed
feat: bash tool UI component (supermemoryai#890)
1 parent 57daef3 commit 654386c

1 file changed

Lines changed: 120 additions & 7 deletions

File tree

apps/web/components/chat/message/agent-message.tsx

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,30 @@ import { useState } from "react"
44
import type { UIMessage } from "@ai-sdk/react"
55
import { Streamdown } from "streamdown"
66
import {
7+
BookOpenIcon,
78
ChevronDownIcon,
89
ChevronRightIcon,
9-
Loader2,
10-
SearchIcon,
11-
GlobeIcon,
12-
PlusIcon,
13-
BookOpenIcon,
1410
ClockIcon,
11+
GlobeIcon,
1512
ListIcon,
16-
XCircleIcon,
13+
Loader2,
14+
PlusIcon,
15+
SearchIcon,
16+
TerminalIcon,
1717
WrenchIcon,
18+
XCircleIcon,
1819
} from "lucide-react"
1920
import { cn } from "@lib/utils"
2021
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
2122
import { RelatedMemories } from "./related-memories"
2223
import { MessageActions } from "./message-actions"
2324

2425
const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
25-
searchMemories: { label: "Search Memories", icon: SearchIcon },
26+
bash: { label: "Memory", icon: TerminalIcon },
2627
web_search: { label: "Web search", icon: GlobeIcon },
2728
google_search: { label: "Google search", icon: GlobeIcon },
29+
// legacy tool names kept for existing persisted messages
30+
searchMemories: { label: "Search Memories", icon: SearchIcon },
2831
addMemory: { label: "Add Memory", icon: PlusIcon },
2932
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
3033
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
@@ -95,9 +98,119 @@ function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
9598
)
9699
}
97100

101+
function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
102+
const [expanded, setExpanded] = useState(false)
103+
const isLoading =
104+
part.state === "input-streaming" || part.state === "input-available"
105+
const isDone = part.state === "output-available"
106+
const isError = part.state === "error" || part.state === "output-error"
107+
108+
const cmd =
109+
part.input && typeof part.input === "object" && "cmd" in part.input
110+
? String((part.input as { cmd: string }).cmd)
111+
: undefined
112+
113+
const output =
114+
isDone && part.output && typeof part.output === "object"
115+
? (part.output as { stdout?: string; stderr?: string; exitCode?: number })
116+
: undefined
117+
118+
const hasOutput =
119+
output &&
120+
((output.stdout && output.stdout.length > 0) ||
121+
(output.stderr && output.stderr.length > 0))
122+
const errorText = part.errorText
123+
const hasExpandable = hasOutput || (isError && errorText)
124+
125+
return (
126+
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden font-mono">
127+
<button
128+
type="button"
129+
onClick={() => setExpanded(!expanded)}
130+
className={cn(
131+
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
132+
expanded && hasExpandable && "border-b border-[#1E2128]",
133+
)}
134+
>
135+
{isLoading ? (
136+
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
137+
) : (
138+
<TerminalIcon
139+
className={cn(
140+
"size-3 shrink-0",
141+
isDone
142+
? output?.exitCode === 0
143+
? "text-emerald-400"
144+
: "text-amber-400"
145+
: isError
146+
? "text-red-400"
147+
: "text-white/50",
148+
)}
149+
/>
150+
)}
151+
<span className={cn("text-white/50", isLoading && "text-blue-400/60")}>
152+
$
153+
</span>
154+
<span
155+
className={cn(
156+
"flex-1 text-left truncate",
157+
isDone
158+
? output?.exitCode === 0
159+
? "text-emerald-300"
160+
: "text-amber-300"
161+
: isLoading
162+
? "text-blue-300"
163+
: isError
164+
? "text-red-300"
165+
: "text-white/70",
166+
)}
167+
>
168+
{cmd ?? "..."}
169+
</span>
170+
{isLoading && (
171+
<span className="text-white/30 shrink-0">running...</span>
172+
)}
173+
{isDone && !hasOutput && (
174+
<span className="text-white/30 shrink-0">done</span>
175+
)}
176+
{isError && <span className="text-red-400/60 shrink-0">error</span>}
177+
{hasExpandable &&
178+
(expanded ? (
179+
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
180+
) : (
181+
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
182+
))}
183+
</button>
184+
185+
{expanded && (hasOutput || (isError && errorText)) && (
186+
<div className="px-3 py-2 space-y-1">
187+
{output?.stdout && output.stdout.length > 0 && (
188+
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
189+
{output.stdout}
190+
</pre>
191+
)}
192+
{output?.stderr && output.stderr.length > 0 && (
193+
<pre className="text-amber-300/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
194+
{output.stderr}
195+
</pre>
196+
)}
197+
{isError && errorText && (
198+
<pre className="text-red-300/90 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
199+
{errorText}
200+
</pre>
201+
)}
202+
</div>
203+
)}
204+
</div>
205+
)
206+
}
207+
98208
function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
99209
const [expanded, setExpanded] = useState(false)
100210
const toolName = part.type.replace("tool-", "")
211+
if (toolName === "bash") {
212+
return <BashToolDisplay part={part} />
213+
}
101214
const meta =
102215
TOOL_META[toolName] ??
103216
(isWebSearchToolName(toolName)

0 commit comments

Comments
 (0)