/* Copyright 2026 Marimo. All rights reserved. */ import type { ContentBlock, ToolCallContent, ToolCallLocation, } from "@zed-industries/agent-client-protocol"; import { BotMessageSquareIcon, FileAudio2Icon, FileIcon, FileImageIcon, FileJsonIcon, FileTextIcon, FileVideoCameraIcon, RotateCcwIcon, WifiIcon, WifiOffIcon, WrenchIcon, XCircleIcon, XIcon, } from "lucide-react"; import React from "react"; import { JsonRpcError, mergeToolCalls } from "use-acp"; import { z } from "zod"; import { ReadonlyDiff } from "@/components/editor/code/readonly-diff"; import { JsonOutput } from "@/components/editor/output/JsonOutput"; import { MarkdownRenderer } from "@/components/markdown/markdown-renderer"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { uniqueByTakeLast } from "@/utils/arrays"; import { logNever } from "@/utils/assertNever"; import { cn } from "@/utils/cn"; import { capitalize, Strings } from "@/utils/strings"; import { SimpleAccordion } from "./common"; import type { AgentNotificationEvent, AgentThoughtNotificationEvent, ConnectionChangeNotificationEvent, ContentBlockOf, CurrentModeUpdateNotificationEvent, ErrorNotificationEvent, PlanNotificationEvent, SessionNotificationEventData, ToolCallNotificationEvent, ToolCallUpdateNotificationEvent, UserNotificationEvent, } from "./types"; import { isAgentMessages, isAgentThoughts, isPlans, isToolCalls, isUserMessages, } from "./utils"; /** * Merges consecutive text blocks into a single text block to prevent * fragmented display when agent messages are streamed in chunks. */ function mergeConsecutiveTextBlocks( contentBlocks: ContentBlock[], ): ContentBlock[] { if (contentBlocks.length === 0) { return contentBlocks; } const merged: ContentBlock[] = []; let currentTextBlock: string | null = null; for (const block of contentBlocks) { if (block.type === "text") { // Accumulate text content if (currentTextBlock === null) { currentTextBlock = block.text; } else { currentTextBlock += block.text; } } else { // If we have accumulated text, flush it before adding non-text block if (currentTextBlock !== null) { merged.push({ type: "text", text: currentTextBlock }); currentTextBlock = null; } merged.push(block); } } // Flush any remaining text if (currentTextBlock !== null) { merged.push({ type: "text", text: currentTextBlock }); } return merged; } export const ErrorBlock = (props: { data: ErrorNotificationEvent["data"]; onRetry?: () => void; onDismiss?: () => void; }) => { const error = props.data; let message = props.data.message; // Don't show WebSocket connection errors if (message.includes("WebSocket")) { return null; } if (error instanceof JsonRpcError) { const dataStr = typeof error.data === "string" ? error.data : JSON.stringify(error.data); message = `${dataStr} (code: ${error.code})`; } return (

Agent Error

{message}
{props.onRetry && ( )} {props.onDismiss && ( )}
); }; export const ReadyToChatBlock = () => { return (

Agent is connected

You can start chatting with your agent now

); }; export const ConnectionChangeBlock = (props: { data: ConnectionChangeNotificationEvent["data"]; isConnected: boolean; onRetry?: () => void; timestamp?: number; isOnlyBlock: boolean; }) => { const { status } = props.data; if (props.isConnected && props.isOnlyBlock) { return ; } const getStatusConfig = () => { switch (status) { case "connected": return { icon: , title: "Connected to Agent", message: "Successfully established connection with the AI agent", bgColor: "bg-[var(--blue-2)]", borderColor: "border-[var(--blue-6)]", textColor: "text-[var(--blue-11)]", iconColor: "text-[var(--blue-10)]", }; case "disconnected": return { icon: , title: "Disconnected from Agent", message: "Connection to the AI agent has been lost", bgColor: "bg-[var(--amber-2)]", borderColor: "border-[var(--amber-6)]", textColor: "text-[var(--amber-11)]", iconColor: "text-[var(--amber-10)]", }; case "connecting": return { icon: , title: "Connecting to Agent", message: "Establishing connection with the AI agent...", bgColor: "bg-[var(--gray-2)]", borderColor: "border-[var(--gray-6)]", textColor: "text-[var(--gray-11)]", iconColor: "text-[var(--gray-10)]", }; case "error": return { icon: , title: "Connection Error", message: "Failed to connect to the AI agent", bgColor: "bg-[var(--red-2)]", borderColor: "border-[var(--red-6)]", textColor: "text-[var(--red-11)]", iconColor: "text-[var(--red-10)]", }; default: return { icon: , title: "Connection Status Changed", message: `Agent connection status: ${status}`, bgColor: "bg-[var(--gray-2)]", borderColor: "border-[var(--gray-6)]", textColor: "text-[var(--gray-11)]", iconColor: "text-[var(--gray-10)]", }; } }; const config = getStatusConfig(); const showRetry = status === "disconnected" || status === "error"; return (
{config.icon}

{config.title}

{props.timestamp && ( {new Date(props.timestamp).toLocaleTimeString()} )}
{config.message}
{showRetry && props.onRetry && ( )}
); }; export const AgentThoughtsBlock = (props: { startTimestamp: number; endTimestamp: number; data: AgentThoughtNotificationEvent[]; }) => { const startAsSeconds = props.startTimestamp / 1000; const endAsSeconds = props.endTimestamp / 1000; const totalSeconds = Math.round(endAsSeconds - startAsSeconds) || "1"; return (
item.content)} />
); }; export const PlansBlock = (props: { data: PlanNotificationEvent[] }) => { // Dedupe plans by text, take the last one which may have a status update let plans = props.data.flatMap((item) => item.entries); plans = uniqueByTakeLast(plans, (item) => item.content); return (
To-dos{" "} {plans.length}
    {plans.map((item, index) => (
  • {item.content}
  • ))}
); }; export const UserMessagesBlock = (props: { data: UserNotificationEvent[] }) => { return (
item.content)} />
); }; export const AgentMessagesBlock = (props: { data: AgentNotificationEvent[]; }) => { // Merge consecutive text chunks to prevent fragmented display const mergedContent = mergeConsecutiveTextBlocks( props.data.map((item) => item.content), ); return (
); }; export const ContentBlocks = (props: { data: ContentBlock[] }) => { const renderBlock = (block: ContentBlock) => { if (block.type === "text") { return ; } if (block.type === "image") { return ; } if (block.type === "audio") { return ; } if (block.type === "resource") { return ; } if (block.type === "resource_link") { return ; } logNever(block); return null; }; return (
{props.data.map((item, index) => { return ( {renderBlock(item)} ); })}
); }; export const ImageBlock = (props: { data: ContentBlockOf<"image"> }) => { return ( {props.data.uri ); }; export const AudioBlock = (props: { data: ContentBlockOf<"audio"> }) => { return ( ); }; export const ResourceBlock = (props: { data: ContentBlockOf<"resource"> }) => { if ("text" in props.data.resource) { return ( {props.data.resource.mimeType && ( )} {props.data.resource.uri} Formatted for agents, not humans. {props.data.resource.mimeType === "text/plain" ? (
              {props.data.resource.text}
            
) : ( )}
); } }; export const ResourceLinkBlock = (props: { data: ContentBlockOf<"resource_link">; }) => { if (props.data.uri.startsWith("http")) { return ( {props.data.name} ); } // Show image in popover for image mime types if (props.data.mimeType?.startsWith("image/")) { return (
{props.data.name || props.data.title || props.data.uri} {props.data.name
); } return ( {props.data.mimeType && } {props.data.name || props.data.title || props.data.uri} ); }; export const MimeIcon = (props: { mimeType: string }) => { const classNames = "h-2 w-2 flex-shrink-0"; if (props.mimeType.startsWith("image/")) { return ; } if (props.mimeType.startsWith("audio/")) { return ; } if (props.mimeType.startsWith("video/")) { return ; } if (props.mimeType.startsWith("text/")) { return ; } if (props.mimeType.startsWith("application/")) { return ; } return ; }; export const SessionNotificationsBlock = < T extends SessionNotificationEventData, >(props: { data: T[]; startTimestamp: number; endTimestamp: number; isLastBlock: boolean; }) => { if (props.data.length === 0) { return null; } const kind = props.data[0].sessionUpdate; const renderItems = (items: T[]) => { if (isToolCalls(items)) { return ( ); } if (isAgentThoughts(items)) { return ( ); } if (isUserMessages(items)) { return ; } if (isAgentMessages(items)) { return ; } if (isPlans(items)) { return ; } if (kind === "available_commands_update") { return null; // nothing to show } if (kind === "current_mode_update") { const lastItem = items.at(-1); return lastItem?.sessionUpdate === "current_mode_update" ? ( ) : null; } return ( ); }; return (
{renderItems(props.data)}
); }; export const CurrentModeBlock = (props: { data: CurrentModeUpdateNotificationEvent; }) => { const { currentModeId } = props.data; return
Mode: {currentModeId}
; }; export const ToolNotificationsBlock = (props: { data: (ToolCallNotificationEvent | ToolCallUpdateNotificationEvent)[]; isLastBlock: boolean; }) => { const toolCalls = mergeToolCalls(props.data); return (
{toolCalls.map((item) => ( {handleBackticks(toolTitle(item))} } defaultIcon={} > ))}
); }; export const DiffBlocks = (props: { data: Extract[]; }) => { return (
{props.data.map((item) => { return (
{/* File path header */}
{item.path}
); })}
); }; function toolTitle( item: Pick, ) { let title = item.title; // Hack: sometimes title comes back: "undefined", so lets undo that if (title === '"undefined"') { title = undefined; } const prefix = title || Strings.startCase(item.kind || "") || "Tool call"; const firstLocation = item.locations?.[0]; // Add the first location if it is not in the title already if (firstLocation && !prefix.includes(firstLocation.path)) { return `${prefix}: ${firstLocation.path}`; } return prefix; } function handleBackticks(text: string) { if (text.startsWith("`") && text.endsWith("`")) { return {text.slice(1, -1)}; } return text; } export const LocationsBlock = (props: { data: ToolCallLocation[] }) => { // Only show locations if there are multiple locations, otherwise it is // in the title if (props.data.length <= 1) { return null; } const locations = props.data.map((item) => { if (item.line) { return `${item.path}:${item.line}`; } return item.path; }); return
{locations.join("\n")}
; }; export const ToolBodyBlock = (props: { data: | Omit | Omit; }) => { const { content, locations, status, kind, rawInput } = props.data; const textContent = content ?.filter((item) => item.type === "content") .map((item) => item.content); const diffs = content?.filter((item) => item.type === "diff"); const isFailed = status === "failed"; const hasLocations = locations && locations.length > 0; // Completely empty if (!content && !hasLocations && rawInput) { // HACK: if the raw input is `abs_path`, `old_string`, `new_string` then handle it as if it is a diff const rawDiff = rawDiffSchema.safeParse(rawInput); if (rawDiff.success) { return ( ); } // Show rawInput return (
        
      
); } const noContent = !textContent || textContent.length === 0; const noDiffs = !diffs || diffs.length === 0; if (noContent && noDiffs && hasLocations) { return (
{capitalize(kind || "")}{" "} {locations?.map((item) => item.path).join(", ")}
); } return (
{locations && } {textContent && } {diffs && !isFailed && }
); }; const rawDiffSchema = z.object({ abs_path: z.string(), old_string: z.string(), new_string: z.string(), });