feat: bash tool UI component (#890)

This commit is contained in:
MaheshtheDev 2026-04-30 02:27:24 +00:00
parent 57daef335d
commit 654386c644

View file

@ -4,17 +4,18 @@ import { useState } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { Streamdown } from "streamdown"
import {
BookOpenIcon,
ChevronDownIcon,
ChevronRightIcon,
Loader2,
SearchIcon,
GlobeIcon,
PlusIcon,
BookOpenIcon,
ClockIcon,
GlobeIcon,
ListIcon,
XCircleIcon,
Loader2,
PlusIcon,
SearchIcon,
TerminalIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
@ -22,9 +23,11 @@ import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
searchMemories: { label: "Search Memories", icon: SearchIcon },
bash: { label: "Memory", icon: TerminalIcon },
web_search: { label: "Web search", icon: GlobeIcon },
google_search: { label: "Google search", icon: GlobeIcon },
// legacy tool names kept for existing persisted messages
searchMemories: { label: "Search Memories", icon: SearchIcon },
addMemory: { label: "Add Memory", icon: PlusIcon },
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
@ -95,9 +98,119 @@ function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
)
}
function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const isLoading =
part.state === "input-streaming" || part.state === "input-available"
const isDone = part.state === "output-available"
const isError = part.state === "error" || part.state === "output-error"
const cmd =
part.input && typeof part.input === "object" && "cmd" in part.input
? String((part.input as { cmd: string }).cmd)
: undefined
const output =
isDone && part.output && typeof part.output === "object"
? (part.output as { stdout?: string; stderr?: string; exitCode?: number })
: undefined
const hasOutput =
output &&
((output.stdout && output.stdout.length > 0) ||
(output.stderr && output.stderr.length > 0))
const errorText = part.errorText
const hasExpandable = hasOutput || (isError && errorText)
return (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden font-mono">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
expanded && hasExpandable && "border-b border-[#1E2128]",
)}
>
{isLoading ? (
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
) : (
<TerminalIcon
className={cn(
"size-3 shrink-0",
isDone
? output?.exitCode === 0
? "text-emerald-400"
: "text-amber-400"
: isError
? "text-red-400"
: "text-white/50",
)}
/>
)}
<span className={cn("text-white/50", isLoading && "text-blue-400/60")}>
$
</span>
<span
className={cn(
"flex-1 text-left truncate",
isDone
? output?.exitCode === 0
? "text-emerald-300"
: "text-amber-300"
: isLoading
? "text-blue-300"
: isError
? "text-red-300"
: "text-white/70",
)}
>
{cmd ?? "..."}
</span>
{isLoading && (
<span className="text-white/30 shrink-0">running...</span>
)}
{isDone && !hasOutput && (
<span className="text-white/30 shrink-0">done</span>
)}
{isError && <span className="text-red-400/60 shrink-0">error</span>}
{hasExpandable &&
(expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
))}
</button>
{expanded && (hasOutput || (isError && errorText)) && (
<div className="px-3 py-2 space-y-1">
{output?.stdout && output.stdout.length > 0 && (
<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]">
{output.stdout}
</pre>
)}
{output?.stderr && output.stderr.length > 0 && (
<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]">
{output.stderr}
</pre>
)}
{isError && errorText && (
<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]">
{errorText}
</pre>
)}
</div>
)}
</div>
)
}
function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const toolName = part.type.replace("tool-", "")
if (toolName === "bash") {
return <BashToolDisplay part={part} />
}
const meta =
TOOL_META[toolName] ??
(isWebSearchToolName(toolName)