mezame 0.8.43

An ACP client that bridges a local agent (Kiro CLI, Claude Agent CLI, Gemini CLI, Codex, ...) to a browser UI over WebSockets.
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { isValidElement, memo } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { CopyButton } from '@/components/CopyButton';
import { cn } from '@/lib/utils';

// Markdown renderer for agent output.
//
// - GitHub-flavoured markdown via remark-gfm (tables, strikethrough,
//   task lists, autolinks).
// - Code highlighting via rehype-highlight (uses highlight.js). Theme CSS
//   imported once at the app level.
// - Custom overrides to keep the terminal aesthetic and add a copy button
//   to every fenced code block.
//
// Memoised by input text so React does not re-run the parser when
// unrelated state changes. During streaming the `text` prop grows, which
// busts the memo on every chunk, which is what we want.

const InlineCode = ({ className, children, ...props }: ComponentPropsWithoutRef<'code'>) => (
  <code
    className={cn('rounded-sm bg-muted px-1 py-0.5 text-[0.9em] text-foreground', className)}
    {...props}
  >
    {children}
  </code>
);

/**
 * Recursively flatten a React node into its plain-text content.
 *
 * Rehype-highlight wraps highlighted tokens in `<span>` elements, so
 * by the time the code block reaches our `FencedCode` renderer the
 * children are a mixed tree of strings (whitespace and punctuation
 * between tokens) and React elements (the tokens themselves). The
 * copy button needs the original source, which means walking every
 * branch.
 *
 * Numbers are coerced to strings so `Date.now()` and similar
 * highlighted literal values still copy. Anything else (booleans,
 * null, undefined, fragments without children) contributes nothing.
 */
const nodeToText = (node: ReactNode): string => {
  if (node === null || node === undefined || typeof node === 'boolean') {
    return '';
  }
  if (typeof node === 'string') {
    return node;
  }
  if (typeof node === 'number') {
    return String(node);
  }
  if (Array.isArray(node)) {
    return node.map(nodeToText).join('');
  }
  if (isValidElement(node)) {
    const { children } = node.props as { children?: ReactNode };
    return nodeToText(children);
  }
  return '';
};

const FencedCode = ({ className, children, ...props }: ComponentPropsWithoutRef<'code'>) => {
  const langMatch = /language-(\w+)/.exec(className ?? '');
  const lang = langMatch?.[1];
  // The copy button needs the raw text of the code block. After
  // `rehype-highlight` runs, `children` is a tree of React elements
  // (highlighted spans like `<span class="hljs-keyword">if</span>`)
  // interleaved with the raw whitespace and punctuation between
  // tokens. A flat join of only the string entries silently drops
  // every highlighted token from the copy buffer; we need to walk
  // the tree and collect every text node.
  const text = nodeToText(children).replace(/\n$/, '');

  // Layout: a top gutter row holds the copy button and the language
  // label on the left; the code element sits below it. The copy
  // button stays permanently visible (no hover gate) so it does not
  // require a mouse hover to discover, and a tooltip is always
  // accessible on touch devices that the previous opacity-on-hover
  // version effectively hid.
  return (
    <span className="block">
      <span className="flex items-center gap-2 pb-1">
        <CopyButton text={text} />
        {lang && (
          <span
            className={cn(
              // Match the copy button's 24 px height with `h-6` plus
              // flex centering so the two sit on the same baseline.
              // The previous `py-0.5` produced a slightly shorter
              // pill that read as a different control class.
              'inline-flex h-6 items-center rounded-sm px-1.5 text-[10px] text-muted-foreground',
              // Brighter than the pre's `#0d1117` background. The
              // previous `bg-card/70` was nearly invisible against
              // it. Using `bg-muted` (zinc-300 in dark theme) gives
              // a clear separation from the code area.
              'bg-muted'
            )}
          >
            {lang}
          </span>
        )}
      </span>
      <code className={cn('block', className)} {...props}>
        {children}
      </code>
    </span>
  );
};

const components: Components = {
  code(props) {
    const className = props.className ?? '';
    // `rehype-highlight` runs first and prepends `hljs` to the
    // className, so a fenced block arrives here as `"hljs
    // language-rust"`, not `"language-rust"`. Match anywhere in the
    // class list. Inline code (single backticks) never carries a
    // `language-` token, so this is unambiguous.
    if (/(?:^|\s)language-\w/.test(className)) {
      return <FencedCode {...props} />;
    }
    return <InlineCode {...props} />;
  },
  pre(props) {
    const { className, ...rest } = props;
    return (
      <pre
        className={cn(
          'my-2 overflow-x-auto rounded-md border border-border bg-[#0d1117] p-3 text-xs leading-relaxed scrollbar-thin',
          className
        )}
        {...rest}
      />
    );
  },
  table({ className, ...rest }) {
    // Wrap tables so wide content scrolls horizontally within the
    // wrapper instead of expanding the page. Without this, a table
    // with long cells would push the body viewport wider than the
    // window on narrow screens.
    return (
      <div className="my-2 w-full overflow-x-auto scrollbar-thin">
        <table className={cn('border-collapse', className)} {...rest} />
      </div>
    );
  },
  a({ className, children, href, ...rest }) {
    return (
      <a
        href={href}
        target="_blank"
        rel="noreferrer noopener"
        className={cn('text-[color:var(--primary)] underline-offset-2 hover:underline', className)}
        {...rest}
      >
        {children}
      </a>
    );
  }
};

type Props = {
  text: string;
};

export const Markdown = memo(({ text }: Props) => (
  <div
    className={cn(
      'leading-relaxed text-foreground',
      '[&_p]:my-1.5 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
      '[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5',
      '[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5',
      '[&_li]:my-0.5',
      '[&_h1]:mt-5 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:border-b [&_h1]:border-border [&_h1]:pb-1',
      '[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:border-b [&_h2]:border-border/60 [&_h2]:pb-0.5',
      '[&_h3]:mt-3 [&_h3]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold',
      '[&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-muted-foreground',
      '[&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0',
      '[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
      '[&_table]:my-2 [&_table]:border-collapse',
      '[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left',
      '[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
      '[&_hr]:my-3 [&_hr]:border-border',
      // KaTeX: display math as its own block, with the same colour as body
      // (KaTeX's default inline span colour would otherwise go unstyled
      // and inherit unpredictably).
      '[&_.katex-display]:my-3 [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden'
    )}
  >
    <ReactMarkdown
      remarkPlugins={[remarkGfm, remarkMath]}
      rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }], rehypeKatex]}
      components={components}
    >
      {text}
    </ReactMarkdown>
  </div>
));

Markdown.displayName = 'Markdown';