shadimage
nexus-ui
uitoolsource: ok · hit

Tool

Tool call component with state-aware header and JSON input/output blocks

npx shadcn add "https://nexus-ui.dev/r/tool.json"
free

Where it goes

navbarstats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@hugeicons/react@hugeicons/core-free-icons@react-symbols/iconsshiki

Registry dependencies

badgecollapsible

Files3 · source cached 10/8/2026, 10:51:05 PM

components/nexus-ui/tool.tsx
"use client";

import {
  createContext,
  useContext,
  type ComponentProps,
  type CSSProperties,
} from "react";
import {
  ArrowDown01Icon,
  CancelCircleIcon,
  CheckmarkCircle01Icon,
  Clock01Icon,
  Loading03Icon,
  ToolsIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";

import { Badge } from "@/components/ui/badge";
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";

import {
  CodeBlock,
  CodeBlockContent,
  CodeblockShiki,
} from "@/components/nexus-ui/codeblock-new";

type ToolStatus = "pending" | "ready" | "running" | "completed" | "error";

type ToolMeta = {
  label: string;
  icon: IconSvgElement;
  color: { bg: string; fg: string };
  iconClassName?: string;
};

const TOOL_META: Record<ToolStatus, ToolMeta> = {
  pending: {
    label: "Pending",
    icon: ToolsIcon,
    color: { bg: "var(--color-gray-100)", fg: "var(--color-gray-500)" },
  },
  ready: {
    label: "Ready",
    icon: Clock01Icon,
    color: { bg: "var(--color-orange-100)", fg: "var(--color-orange-600)" },
  },
  running: {
    label: "Running",
    icon: Loading03Icon,
    color: { bg: "var(--color-blue-100)", fg: "var(--color-blue-600)" },
    iconClassName: "animate-spin",
  },
  completed: {
    label: "Completed",
    icon: CheckmarkCircle01Icon,
    color: { bg: "var(--color-green-100)", fg: "var(--color-green-600)" },
  },
  error: {
    label: "Error",
    icon: CancelCircleIcon,
    color: { bg: "var(--color-red-100)", fg: "var(--color-red-600)" },
  },
};

type ToolContextValue = {
  status: ToolStatus;
  meta: ToolMeta;
};

const ToolContext = createContext<ToolContextValue | null>(null);

function isToolStatus(value: unknown): value is ToolStatus {
  return (
    typeof value === "string" &&
    Object.prototype.hasOwnProperty.call(TOOL_META, value)
  );
}

function useToolContext(component: string): ToolContextValue {
  const context = useContext(ToolContext);
  if (!context) {
    throw new Error(`${component} must be used within <Tool>`);
  }
  return context;
}

function stringifyToolPayload(payload: unknown): string {
  if (typeof payload === "string") return payload;
  if (payload === undefined) return "";

  try {
    return JSON.stringify(payload, null, 2);
  } catch {
    return String(payload);
  }
}

type ToolProps = ComponentProps<typeof Collapsible> & {
  status: ToolStatus;
};

function Tool({ status, className, style, ...props }: ToolProps) {
  const resolvedStatus = isToolStatus(status) ? status : "pending";
  const meta = TOOL_META[resolvedStatus];

  return (
    <ToolContext.Provider value={{ status: resolvedStatus, meta }}>
      <Collapsible
        data-slot="tool"
        className={cn(
          "not-prose w-full max-w-100 border dark:border-accent bg-card",
          "data-[state=closed]:rounded-xl data-[state=open]:rounded-xl",
          className,
        )}
        style={
          {
            "--tool-color": meta.color.fg,
            "--tool-bg": meta.color.bg,
            ...style,
          } as CSSProperties
        }
        {...props}
      />
    </ToolContext.Provider>
  );
}

type ToolTriggerProps = Omit<
  ComponentProps<typeof CollapsibleTrigger>,
  "children"
> & {
  name: string;
};

function ToolTrigger({ name, className, ...props }: ToolTriggerProps) {
  const { meta } = useToolContext("ToolTrigger");

  return (
    <CollapsibleTrigger
      data-slot="tool-trigger"
      className={cn(
        "group flex h-10 w-full cursor-pointer items-center justify-between px-3 py-2",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-2">
        <HugeiconsIcon
          data-slot="tool-trigger-icon"
          icon={meta.icon}
          strokeWidth={2}
          className={cn("size-4 text-(--tool-color)", meta.iconClassName)}
        />
        <span
          data-slot="tool-trigger-name"
          className="text-sm leading-6 font-[450] text-primary"
        >
          {name}
        </span>
        <Badge
          data-slot="tool-trigger-badge"
          className="h-6 bg-(--tool-bg)/60 font-[450] text-(--tool-color) dark:bg-(--tool-color)/10 dark:text-(--tool-color)"
        >
          {meta.label}
        </Badge>
      </div>

      <HugeiconsIcon
        data-slot="tool-trigger-chevron"
        icon={ArrowDown01Icon}
        strokeWidth={1.75}
        className="size-4 transition-transform duration-200 group-data-[state=open]:rotate-180"
      />
    </CollapsibleTrigger>
  );
}

type ToolContentProps = ComponentProps<typeof CollapsibleContent>;

function ToolContent({ className, ...props }: ToolContentProps) {
  return (
    <CollapsibleContent
      data-slot="tool-content"
      className={cn(
        "flex flex-col gap-6 p-3 pt-4",
        "overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
        className,
      )}
      {...props}
    />
  );
}

type ToolPartProps = {
  kind: "input" | "output";
  payload: unknown;
  errorText?: string;
};

function ToolPart({ kind, payload, errorText }: ToolPartProps) {
  const { status } = useToolContext("ToolPart");
  const code = stringifyToolPayload(payload);
  const isOutputError = kind === "output" && status === "error";
  const hasPayload = payload !== undefined && payload !== null;
  const shouldShowCodeblock = !isOutputError || hasPayload;
  const title = kind === "input" ? "Input" : isOutputError ? "Error" : "Output";

  return (
    <div data-slot={`tool-${kind}`} className="flex flex-col gap-3">
      <span
        data-slot={`tool-${kind}-title`}
        className={cn(
          "text-xs leading-4 font-[450] text-muted-foreground uppercase",
          isOutputError && "text-destructive",
        )}
      >
        {title}
      </span>
      {isOutputError ? (
        <div
          data-slot="tool-output-error"
          className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm leading-6 text-destructive dark:bg-destructive/10"
        >
          {errorText ?? "Tool execution failed"}
        </div>
      ) : null}
      {shouldShowCodeblock ? (
        <CodeBlock
          data-slot="tool-output-error-codeblock"
          className="rounded-lg"
          keepBackground
        >
          <CodeBlockContent>
            <CodeblockShiki language="json">{code}</CodeblockShiki>
          </CodeBlockContent>
        </CodeBlock>
      ) : null}
    </div>
  );
}

type ToolPayloadProps = {
  payload: unknown;
};

function ToolInput({ payload }: ToolPayloadProps) {
  return <ToolPart kind="input" payload={payload} />;
}

type ToolOutputProps = ToolPayloadProps & {
  showWhen?: ToolStatus[];
  errorText?: string;
};

function ToolOutput({
  payload,
  showWhen = ["completed"],
  errorText,
}: ToolOutputProps) {
  const { status } = useToolContext("ToolOutput");
  if (!showWhen.includes(status)) return null;

  return <ToolPart kind="output" payload={payload} errorText={errorText} />;
}

export type { ToolStatus };
export { Tool, ToolTrigger, ToolContent, ToolInput, ToolOutput };
components/nexus-ui/codeblock-new.tsx
"use client";

import { FileIcon } from "@react-symbols/icons/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
  createContext,
  useEffect,
  useContext,
  useState,
  type CSSProperties,
  type ComponentProps,
  type ReactNode,
} from "react";

import { cn } from "@/lib/utils";
import { highlight, Themes } from "@/lib/shiki/highlighter";
import type { BundledLanguage } from "shiki/bundle/web";

const highlighterPromise = highlight();
type DivProps = ComponentProps<"div">;
type CodeBlockProps = DivProps & {
  keepBackground?: boolean;
};
type CodeBlockCopyContextValue = {
  content: string;
  setContent: (value: string) => void;
};
const CodeBlockCopyContext = createContext<CodeBlockCopyContextValue | null>(
  null,
);

interface CodeblockClientShikiProps extends DivProps {
  code?: string;
  language?: string;
  lineNumbers?: boolean;
  children?: ReactNode;
}

type ShikiToken = {
  content: string;
  htmlStyle?: Record<string, string>;
};

const buildRawTokenRows = (input: string): ShikiToken[][] =>
  input.split(/\r?\n/).map((line) => [{ content: line || "\u00A0" }]);

const EMPTY_TOKEN_ROW: ShikiToken[] = [{ content: "\u00A0" }];

const resolveCodeToHighlight = (children: ReactNode, code?: string): string =>
  typeof children === "string"
    ? children
    : Array.isArray(children) &&
        children.length === 1 &&
        typeof children[0] === "string"
      ? children[0]
      : (code ?? "");

const CodeBlock = ({
  children,
  className,
  keepBackground = false,
  ...props
}: CodeBlockProps) => {
  const [copyContent, setCopyContent] = useState("");

  return (
    <CodeBlockCopyContext.Provider
      value={{ content: copyContent, setContent: setCopyContent }}
    >
      <div
        className={cn(
          "not-prose",
          "my-0 flex w-full flex-col overflow-hidden rounded-xl",
          keepBackground
            ? "border-none bg-secondary dark:bg-background"
            : "border bg-card dark:border-accent",
          "text-[13px] font-[450]",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </CodeBlockCopyContext.Provider>
  );
};

type CodeBlockHeaderProps = DivProps;

const CodeBlockHeader = ({
  children,
  className,
  ...props
}: CodeBlockHeaderProps) => {
  return (
    <div
      className={cn(
        "not-prose", // Disable Markdown Styles
        "flex h-9.5 items-center justify-between gap-2 px-4",
        "text-fd-muted-foreground",
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
};

interface CodeBlockIconProps extends DivProps {
  language?: string;
}

const CodeBlockIcon = ({ language, className }: CodeBlockIconProps) => {
  return (
    <FileIcon
      width={16}
      height={16}
      fileName={`.${language ?? ""}`}
      autoAssign={true}
      className={cn(className)}
    />
  );
};

type CodeBlockGroupProps = DivProps;

const CodeBlockGroup = ({
  children,
  className,
  ...props
}: CodeBlockGroupProps) => {
  return (
    <div
      className={cn(
        "flex items-center gap-2",
        "text-fd-muted-foreground",
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
};

const CodeBlockContent = ({ className, children, ...props }: DivProps) => {
  return (
    <div
      className={cn(
        "no-scrollbar max-h-96 overflow-auto overscroll-x-none",
        "rounded-xl px-4 text-sm leading-6",
        "font-mono whitespace-pre",
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
};

const CodeblockShiki = ({
  code,
  language = "tsx",
  lineNumbers = false,
  className,
  children,
  ...props
}: CodeblockClientShikiProps) => {
  const setCopyContent = useContext(CodeBlockCopyContext)?.setContent;
  const codeToHighlight = resolveCodeToHighlight(children, code);
  const [tokenRows, setTokenRows] = useState<ShikiToken[][]>(() =>
    buildRawTokenRows(codeToHighlight),
  );

  useEffect(() => {
    setCopyContent?.(codeToHighlight);
  }, [codeToHighlight, setCopyContent]);

  useEffect(() => {
    let cancelled = false;

    async function clientHighlight() {
      const rawRows = buildRawTokenRows(codeToHighlight);
      if (!cancelled) {
        setTokenRows(rawRows);
      }

      if (!codeToHighlight) {
        return;
      }

      try {
        const highlighter = await highlighterPromise;
        const result = await highlighter.codeToTokens(codeToHighlight, {
          lang: language as BundledLanguage,
          themes: {
            light: Themes.light,
            dark: Themes.dark,
          },
        });
        if (!cancelled) {
          setTokenRows((result.tokens ?? rawRows) as ShikiToken[][]);
        }
      } catch {
        if (!cancelled) {
          setTokenRows(rawRows);
        }
      }
    }

    void clientHighlight();

    return () => {
      cancelled = true;
    };
  }, [codeToHighlight, language]);

  return (
    <div
      className={cn(
        "no-scrollbar w-full overflow-auto overscroll-x-none py-0",
        className,
      )}
      {...props}
    >
      <pre
        className={cn(
          "shiki",
          lineNumbers ? "shiki-line-numbers" : "nd-no-line-numbers",
        )}
      >
        <code>
          {tokenRows.map((row, rowIndex) => (
            <span key={`row-${rowIndex}`} className="line">
              {(row.length ? row : EMPTY_TOKEN_ROW).map((token, tokenIndex) => (
                <span
                  key={`token-${rowIndex}-${tokenIndex}`}
                  style={token.htmlStyle as CSSProperties | undefined}
                >
                  {token.content || "\u00A0"}
                </span>
              ))}
              {rowIndex < tokenRows.length - 1 && "\n"}
            </span>
          ))}
        </code>
      </pre>
    </div>
  );
};

type CodeBlockCopyButtonProps = ComponentProps<"button">;

const CodeBlockCopyButton = ({
  className,
  ...props
}: CodeBlockCopyButtonProps) => {
  const content = useContext(CodeBlockCopyContext)?.content ?? "";
  const [isCopied, setIsCopied] = useState<boolean>(false);

  const copyToClipboard = async (text: string) => {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (err) {
      console.error("Failed to copy text: ", err);
      return false;
    }
  };

  useEffect(() => {
    if (!isCopied) return;

    const timeout = setTimeout(() => {
      setIsCopied(false);
    }, 2000);
    return () => clearTimeout(timeout);
  }, [isCopied]);

  const handleCopy = async () => {
    if (!content) return;
    await copyToClipboard(content);
    setIsCopied(true);
  };

  return (
    <button
      title="Copy to clipboard"
      className={cn(
        "relative flex size-7 cursor-pointer items-center justify-center rounded-full text-ring hover:text-primary",
        "",
        className,
      )}
      onClick={handleCopy}
      {...props}
    >
      {isCopied ? (
        <HugeiconsIcon
          icon={Tick02Icon}
          strokeWidth={2}
          className="size-3.5 animate-in text-green-900 duration-200 zoom-in-50 dark:text-green-400"
        />
      ) : (
        <HugeiconsIcon
          icon={Copy01Icon}
          strokeWidth={2}
          className="size-3.5 animate-in duration-200 zoom-in-50"
        />
      )}
    </button>
  );
};

export {
  CodeBlock,
  CodeBlockHeader,
  CodeBlockIcon,
  CodeBlockGroup,
  CodeBlockContent,
  CodeblockShiki,
  CodeBlockCopyButton,
};
lib/shiki/highlighter.ts
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import {
  bundledLanguages,
  type Highlighter,
  type RegexEngine,
  createHighlighter,
} from "shiki/bundle/web";

let jsEngine: RegexEngine | null = null;
let highlighter: Promise<Highlighter> | null = null;

// Settings for UI components
const Themes = {
  light: "github-light",
  dark: "github-dark",
};

const allBundledLanguageIds = Object.keys(bundledLanguages);


const getJsEngine = (): RegexEngine => {
  jsEngine ??= createJavaScriptRegexEngine();
  return jsEngine;
};

const highlight = async (): Promise<Highlighter> => {
  highlighter ??= createHighlighter({
    langs: allBundledLanguageIds,
    themes: ["github-light", "github-dark"],
    engine: getJsEngine(),
  });
  return highlighter;
};
export { highlight, Themes };

More from nexus-ui

Browse all components in this library →