mezame 0.8.42

An ACP client that bridges a local agent (Kiro CLI, Claude Agent CLI, Gemini CLI, Codex, ...) to a browser UI over WebSockets.
import { ChevronDownIcon, ChevronRightIcon, FileIcon, SettingsIcon } from 'lucide-react';
import { useState } from 'react';
import { CopyButton } from '@/components/CopyButton';
import { Markdown } from '@/features/Markdown';
import { cn } from '@/lib/utils';
import type { LogEntry, ToolCallLocation } from '@/types';

// Renders an ACP tool_call as a collapsible summary row. The row itself
// shows title, status pill, and kind; expanded, it reveals arguments
// (as JSON), content (as markdown if present), and the file locations
// the tool touched. Multiple tool_call_update notifications with the
// same toolCallId mutate the underlying store entry in place, so this
// component just re-renders.

type Entry = Extract<LogEntry, { kind: 'tool_call' }>;

type Props = {
  entry: Entry;
};

const statusTone = (status: string | null): string => {
  switch (status) {
    case 'in_progress':
    case 'pending':
      return 'border-[color:var(--primary)]/40 text-[color:var(--primary)]';
    case 'completed':
      return 'border-[color:var(--attn-done)]/40 text-[color:var(--attn-done)]';
    case 'failed':
      return 'border-[color:var(--attn-error)]/40 text-[color:var(--attn-error)]';
    default:
      return 'border-border text-muted-foreground';
  }
};

// Status labels: protocol values are snake_case (`in_progress`); humans
// read "in progress" more naturally.
const statusLabel = (status: string | null): string => {
  if (!status) {
    return 'running';
  }
  return status.replace(/_/g, ' ');
};

const stringifyContent = (content: unknown): string | null => {
  if (content === null || content === undefined) {
    return null;
  }
  if (typeof content === 'string') {
    return content;
  }
  // ACP tool-call content is typically an array of ContentBlocks. We
  // render text blocks verbatim; anything else falls through to a
  // JSON dump so nothing is silently lost.
  if (Array.isArray(content)) {
    const textBlocks: string[] = [];
    let nonText = false;
    for (const block of content) {
      if (block && typeof block === 'object') {
        const b = block as Record<string, unknown>;
        if (b.type === 'content' && b.content && typeof b.content === 'object') {
          const inner = b.content as Record<string, unknown>;
          if (inner.type === 'text' && typeof inner.text === 'string') {
            textBlocks.push(inner.text);
            continue;
          }
        }
        if (b.type === 'text' && typeof b.text === 'string') {
          textBlocks.push(b.text);
          continue;
        }
      }
      nonText = true;
    }
    if (textBlocks.length > 0 && !nonText) {
      return textBlocks.join('\n');
    }
  }
  try {
    return JSON.stringify(content, null, 2);
  } catch {
    return String(content);
  }
};

const stringifyInput = (rawInput: unknown): string | null => {
  if (rawInput === null || rawInput === undefined) {
    return null;
  }
  if (typeof rawInput === 'string') {
    return rawInput;
  }
  try {
    return JSON.stringify(rawInput, null, 2);
  } catch {
    return String(rawInput);
  }
};

const Location = ({ location }: { location: ToolCallLocation }) => (
  <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
    <FileIcon className="size-3 shrink-0 opacity-70" />
    <span className="truncate">
      {location.path ?? 'unknown'}
      {typeof location.line === 'number' && `:${location.line}`}
    </span>
  </div>
);

export const ToolCallCard = ({ entry }: Props) => {
  const [open, setOpen] = useState(false);

  const tone = statusTone(entry.status);
  const input = stringifyInput(entry.rawInput);
  const content = stringifyContent(entry.content);
  const hasDetails =
    input !== null || content !== null || (entry.locations && entry.locations.length > 0);

  return (
    <div
      className={cn(
        'my-2 rounded-sm border border-l-[3px] bg-card/40 text-sm min-w-0',
        tone
      )}
    >
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        disabled={!hasDetails}
        className={cn(
          'flex w-full items-center gap-2 px-2.5 py-1.5 text-left',
          hasDetails ? 'cursor-pointer hover:bg-accent/30' : 'cursor-default'
        )}
        aria-expanded={open}
      >
        {hasDetails ? (
          open ? (
            <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
          ) : (
            <ChevronRightIcon className="size-3.5 shrink-0 opacity-70" />
          )
        ) : (
          <SettingsIcon className="size-3.5 shrink-0 opacity-70" />
        )}
        <span className="flex-1 truncate text-foreground">{entry.title}</span>
        <span
          className={cn(
            'rounded-sm border bg-background/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wide',
            tone
          )}
        >
          {statusLabel(entry.status)}
        </span>
      </button>

      {open && hasDetails && (
        <div className="min-w-0 space-y-3 border-t border-border/50 px-2.5 py-2.5">
          {input !== null && (
            <div>
              <div className="mb-1 flex items-center gap-2 text-[11px] text-muted-foreground">
                <span>Arguments</span>
                <CopyButton text={input} title="Copy arguments" className="size-6" />
              </div>
              <pre className="overflow-x-auto rounded-sm bg-muted/40 p-2 text-[11px] leading-snug text-foreground">
                {input}
              </pre>
            </div>
          )}

          {content !== null && (
            <div>
              <div className="mb-1 flex items-center gap-2 text-[11px] text-muted-foreground">
                <span>Output</span>
                <CopyButton text={content} title="Copy output" className="size-6" />
              </div>
              {/* Render text output as markdown when it looks like prose
                  (agent-authored summaries often are). JSON dumps go
                  through Markdown too, where they render as inline code
                  without fenced highlighting; acceptable. */}
              <Markdown text={content} />
            </div>
          )}

          {entry.locations && entry.locations.length > 0 && (
            <div>
              <div className="mb-1 text-[11px] text-muted-foreground">Locations</div>
              <div className="space-y-0.5">
                {entry.locations.map((loc, i) => (
                  <Location key={i} location={loc} />
                ))}
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
};