chore: import upstream snapshot with attribution
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:40:13 +08:00
commit e30e75b5d4
3893 changed files with 533074 additions and 0 deletions
@@ -0,0 +1,49 @@
"use client";
import { WrenchIcon } from "lucide-react";
import type { FC } from "react";
type OpenCodeDataPartProps = {
name: string;
data: unknown;
};
const getSummary = (name: string, data: unknown) => {
if (
name === "opencode-unsupported-part" &&
typeof data === "object" &&
data !== null &&
"type" in data &&
typeof data.type === "string"
) {
return `Unsupported part: ${data.type}`;
}
if (name === "opencode-snapshot") return "Snapshot";
if (name === "opencode-retry") return "Retry";
if (name === "opencode-compaction") return "Compaction";
if (name === "opencode-agent") return "Agent event";
if (name === "opencode-subtask") return "Subtask";
return name.replace(/^opencode-/, "").replace(/-/g, " ");
};
export const OpenCodeDataPart: FC<OpenCodeDataPartProps> = ({ name, data }) => {
const shouldHide =
name === "opencode-step-start" ||
name === "opencode-step-finish" ||
name === "opencode-patch";
if (shouldHide) {
return null;
}
const summary = getSummary(name, data);
return (
<div className="bg-muted/30 text-muted-foreground my-3 flex w-min items-center gap-2 rounded-full border px-2 py-1 text-xs text-nowrap">
<WrenchIcon className="size-3" />
{summary}
</div>
);
};
@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import {
type OpenCodePermissionRequest,
type OpenCodePermissionResponse,
useOpenCodeRuntimeExtras,
} from "@assistant-ui/react-opencode";
import {
CheckCircle2Icon,
LoaderIcon,
ShieldAlertIcon,
XCircleIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const formatPermissionReply = (reply: OpenCodePermissionResponse) => {
switch (reply) {
case "always":
return "Always allow";
case "once":
return "Allowed once";
case "reject":
return "Rejected";
}
};
export const OpenCodePermissionCard = ({
request,
state,
reply,
}: {
request: OpenCodePermissionRequest;
state: "pending" | "resolved";
reply?: OpenCodePermissionResponse;
}) => {
const { replyToPermission } = useOpenCodeRuntimeExtras();
const [submittingReply, setSubmittingReply] =
useState<OpenCodePermissionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const submitReply = async (nextReply: OpenCodePermissionResponse) => {
setSubmittingReply(nextReply);
setError(null);
try {
await replyToPermission(request.id, nextReply);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit reply.");
setSubmittingReply(null);
}
};
const isPending = state === "pending";
return (
<Card className="gap-4 py-4">
<CardHeader className="px-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-amber-500/10 p-2 text-amber-700">
{isPending ? (
<ShieldAlertIcon className="size-4" />
) : reply === "reject" ? (
<XCircleIcon className="size-4" />
) : (
<CheckCircle2Icon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<CardTitle className="text-sm">
{isPending ? "Approval required" : formatPermissionReply(reply!)}
</CardTitle>
<CardDescription className="mt-1 text-xs">
{request.title ??
`OpenCode requested approval for ${request.toolName ?? request.permission}.`}
</CardDescription>
</div>
<Badge variant={isPending ? "secondary" : "outline"}>
{request.toolName ?? request.permission}
</Badge>
</div>
</CardHeader>
{(request.patterns.length > 0 || request.toolInput !== undefined) && (
<CardContent className="space-y-3 px-4">
{request.patterns.length > 0 ? (
<div className="flex flex-wrap gap-2">
{request.patterns.map((pattern) => (
<Badge key={pattern} variant="outline" className="font-mono">
{pattern}
</Badge>
))}
</div>
) : null}
{request.toolInput !== undefined ? (
<pre className="bg-muted overflow-x-auto rounded-md p-3 text-xs">
{JSON.stringify(request.toolInput, null, 2)}
</pre>
) : null}
</CardContent>
)}
{isPending ? (
<CardFooter className="flex flex-wrap gap-2 px-4">
<Button
size="sm"
onClick={() => submitReply("once")}
disabled={submittingReply !== null}
>
{submittingReply === "once" && (
<LoaderIcon className="size-4 animate-spin" />
)}
Allow once
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => submitReply("always")}
disabled={submittingReply !== null}
>
{submittingReply === "always" && (
<LoaderIcon className="size-4 animate-spin" />
)}
Always allow
</Button>
<Button
size="sm"
variant="outline"
onClick={() => submitReply("reject")}
disabled={submittingReply !== null}
>
{submittingReply === "reject" && (
<LoaderIcon className="size-4 animate-spin" />
)}
Reject
</Button>
{error ? (
<p className="text-destructive w-full text-xs">{error}</p>
) : null}
</CardFooter>
) : null}
</Card>
);
};
@@ -0,0 +1,349 @@
"use client";
import { useMemo, useState } from "react";
import {
type OpenCodeQuestionRequest,
type QuestionAnswer,
useOpenCodeRuntimeExtras,
} from "@assistant-ui/react-opencode";
import {
AlertCircleIcon,
CheckCircle2Icon,
CircleHelpIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
type PendingQuestionState = {
selectedLabels: string[];
customAnswer: string;
};
const buildEmptyQuestionState = (
request: OpenCodeQuestionRequest,
): PendingQuestionState[] =>
request.questions.map(() => ({
selectedLabels: [],
customAnswer: "",
}));
const encodeQuestionAnswers = (
request: OpenCodeQuestionRequest,
formState: PendingQuestionState[],
): QuestionAnswer[] =>
request.questions.map((question, index) => {
const current = formState[index];
if (!current) return [];
const answers = [...current.selectedLabels];
const customValue = current.customAnswer.trim();
const customAllowed = question.custom !== false;
if (customAllowed && customValue) {
answers.push(`Other: ${customValue}`);
}
return answers;
});
export const getQuestionSummary = (request?: OpenCodeQuestionRequest) => {
if (!request || request.questions.length === 0) return "Waiting for input";
if (request.questions.length === 1) {
return request.questions[0]?.header || "1 question";
}
return `${request.questions.length} questions`;
};
export const OpenCodeQuestionCard = ({
request,
state,
answers,
}: {
request: OpenCodeQuestionRequest;
state: "pending" | "answered" | "rejected";
answers?: readonly QuestionAnswer[];
}) => {
const { replyToQuestion, rejectQuestion } = useOpenCodeRuntimeExtras();
const [formState, setFormState] = useState(() =>
buildEmptyQuestionState(request),
);
const [submissionState, setSubmissionState] = useState<
"reply" | "reject" | null
>(null);
const [error, setError] = useState<string | null>(null);
const isPending = state === "pending";
const encodedAnswers = useMemo(
() => encodeQuestionAnswers(request, formState),
[formState, request],
);
const canSubmit =
isPending && encodedAnswers.every((answer) => answer.length > 0);
const updateQuestionState = (
index: number,
updater: (current: PendingQuestionState) => PendingQuestionState,
) => {
setFormState((current) =>
current.map((item, itemIndex) =>
itemIndex === index ? updater(item) : item,
),
);
};
const submitAnswers = async () => {
if (!canSubmit) return;
setSubmissionState("reply");
setError(null);
try {
await replyToQuestion(request.id, encodedAnswers);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to submit answers.",
);
setSubmissionState(null);
}
};
const rejectRequest = async () => {
setSubmissionState("reject");
setError(null);
try {
await rejectQuestion(request.id);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to reject request.",
);
setSubmissionState(null);
}
};
return (
<Card className="gap-4 py-4">
<CardHeader className="px-4">
<div className="flex items-start gap-3">
<div
className={cn(
"rounded-md p-2",
isPending
? "bg-sky-500/10 text-sky-700"
: state === "rejected"
? "bg-muted text-muted-foreground"
: "bg-emerald-500/10 text-emerald-700",
)}
>
{isPending ? (
<CircleHelpIcon className="size-4" />
) : state === "rejected" ? (
<XCircleIcon className="size-4" />
) : (
<CheckCircle2Icon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<CardTitle className="text-sm">
{isPending
? "Answer required"
: state === "rejected"
? "Question dismissed"
: "Answers submitted"}
</CardTitle>
<CardDescription className="mt-1 text-xs">
{getQuestionSummary(request)}
</CardDescription>
</div>
<Badge variant={isPending ? "secondary" : "outline"}>
{request.questions.length === 1
? "1 prompt"
: `${request.questions.length} prompts`}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4">
{request.questions.map((question, index) => {
const current = formState[index] ?? {
selectedLabels: [],
customAnswer: "",
};
const answeredValues = answers?.[index] ?? [];
const customAllowed = question.custom !== false;
return (
<section key={`${request.id}-${index}`} className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">{question.header}</p>
<p className="text-muted-foreground text-xs">
{question.question}
</p>
</div>
{isPending ? (
<>
{question.options.length > 0 ? (
question.multiple ? (
<div className="space-y-2">
{question.options.map((option) => {
const checked = current.selectedLabels.includes(
option.label,
);
const optionId = `${request.id}-${index}-${option.label}`;
return (
<div
key={option.label}
className="flex items-start gap-3 rounded-lg border p-3 text-sm"
>
<Checkbox
id={optionId}
checked={checked}
onCheckedChange={(nextChecked) => {
updateQuestionState(index, (item) => ({
...item,
selectedLabels:
nextChecked === true
? [...item.selectedLabels, option.label]
: item.selectedLabels.filter(
(label) => label !== option.label,
),
}));
}}
/>
<div className="space-y-1">
<Label
htmlFor={optionId}
className="text-sm font-medium"
>
{option.label}
</Label>
<p className="text-muted-foreground text-xs">
{option.description}
</p>
</div>
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap gap-2">
{question.options.map((option) => {
const selected = current.selectedLabels.includes(
option.label,
);
return (
<Button
key={option.label}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => {
updateQuestionState(index, (item) => ({
...item,
selectedLabels: [option.label],
customAnswer: "",
}));
}}
>
{option.label}
</Button>
);
})}
</div>
)
) : null}
{customAllowed ? (
<div className="space-y-2">
<Label htmlFor={`${request.id}-${index}-custom`}>
Other
</Label>
<Textarea
id={`${request.id}-${index}-custom`}
value={current.customAnswer}
onChange={(event) => {
const nextValue = event.target.value;
updateQuestionState(index, (item) => ({
...item,
customAnswer: nextValue,
selectedLabels:
question.multiple || nextValue.trim().length === 0
? item.selectedLabels
: [],
}));
}}
placeholder="Add your answer"
rows={3}
/>
</div>
) : null}
</>
) : state === "rejected" ? (
<p className="text-muted-foreground text-xs">
No answer was submitted.
</p>
) : (
<div className="flex flex-wrap gap-2">
{answeredValues.map((answer) => (
<Badge key={answer} variant="outline">
{answer}
</Badge>
))}
</div>
)}
</section>
);
})}
</CardContent>
{isPending ? (
<CardFooter className="flex flex-wrap gap-2 px-4">
<Button
size="sm"
onClick={submitAnswers}
disabled={!canSubmit || submissionState !== null}
>
{submissionState === "reply" && (
<LoaderIcon className="size-4 animate-spin" />
)}
Submit answers
</Button>
<Button
size="sm"
variant="outline"
onClick={rejectRequest}
disabled={submissionState !== null}
>
{submissionState === "reject" && (
<LoaderIcon className="size-4 animate-spin" />
)}
Dismiss
</Button>
{error ? (
<div className="text-destructive flex w-full items-center gap-2 text-xs">
<AlertCircleIcon className="size-3.5" />
<span>{error}</span>
</div>
) : null}
</CardFooter>
) : null}
</Card>
);
};
@@ -0,0 +1,177 @@
"use client";
import { memo, useMemo } from "react";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import {
type OpenCodePermissionRequest,
type OpenCodePermissionResponse,
type OpenCodeQuestionRequest,
type QuestionAnswer,
useOpenCodeThreadState,
} from "@assistant-ui/react-opencode";
import {
CheckCircle2Icon,
CircleHelpIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import { OpenCodePermissionCard } from "@/components/tools/opencode-permission-card";
import {
OpenCodeQuestionCard,
getQuestionSummary,
} from "@/components/tools/opencode-question-card";
type ToolPermissionInteraction = {
request: OpenCodePermissionRequest;
state: "pending" | "resolved";
reply?: OpenCodePermissionResponse;
};
type ToolQuestionInteraction = {
request: OpenCodeQuestionRequest;
state: "pending" | "answered" | "rejected";
answers?: readonly QuestionAnswer[];
};
const ASK_QUESTION_TOOL_NAMES = [
"ask_question",
"request_user_input",
"requestUserInput",
] as const;
const useOpenCodeToolInteractions = (toolCallId: string) => {
const interactions = useOpenCodeThreadState((state) => state.interactions);
return useMemo(() => {
const permissions: ToolPermissionInteraction[] = [];
const questions: ToolQuestionInteraction[] = [];
for (const request of Object.values(interactions.permissions.pending)) {
if (request.tool?.callID === toolCallId) {
permissions.push({ request, state: "pending" });
}
}
for (const resolved of Object.values(interactions.permissions.resolved)) {
if (resolved.request.tool?.callID === toolCallId) {
permissions.push({
request: resolved.request,
state: "resolved",
reply: resolved.reply,
});
}
}
for (const request of Object.values(interactions.questions.pending)) {
if (request.tool?.callID === toolCallId) {
questions.push({ request, state: "pending" });
}
}
for (const answered of Object.values(interactions.questions.answered)) {
if (answered.request.tool?.callID === toolCallId) {
questions.push({
request: answered.request,
state: "answered",
answers: answered.answers,
});
}
}
for (const rejected of Object.values(interactions.questions.rejected)) {
if (rejected.request.tool?.callID === toolCallId) {
questions.push({
request: rejected.request,
state: "rejected",
});
}
}
return { permissions, questions };
}, [interactions, toolCallId]);
};
const ToolInteractionStack = ({ toolCallId }: { toolCallId: string }) => {
const { permissions, questions } = useOpenCodeToolInteractions(toolCallId);
if (permissions.length === 0 && questions.length === 0) {
return null;
}
return (
<div className="space-y-3">
{permissions.map((permission) => (
<OpenCodePermissionCard
key={`${permission.request.id}-${permission.state}`}
request={permission.request}
state={permission.state}
{...(permission.reply ? { reply: permission.reply } : {})}
/>
))}
{questions.map((question) => (
<OpenCodeQuestionCard
key={`${question.request.id}-${question.state}`}
request={question.request}
state={question.state}
{...(question.answers ? { answers: question.answers } : {})}
/>
))}
</div>
);
};
export const withOpenCodeToolInteractions = <TArgs = any, TResult = any>(
BaseComponent: ToolCallMessagePartComponent<TArgs, TResult>,
): ToolCallMessagePartComponent<TArgs, TResult> => {
const WrappedComponent: ToolCallMessagePartComponent<TArgs, TResult> = memo(
(props) => {
return (
<div className="space-y-3">
<BaseComponent {...props} />
<ToolInteractionStack toolCallId={props.toolCallId} />
</div>
);
},
);
WrappedComponent.displayName = `withOpenCodeToolInteractions(${
BaseComponent.displayName || BaseComponent.name || "Tool"
})`;
return WrappedComponent;
};
export const AskQuestionInline: ToolCallMessagePartComponent = memo(
({ toolCallId, toolName, status }) => {
const { questions } = useOpenCodeToolInteractions(toolCallId);
const primaryQuestion = questions[0]?.request;
const label = getQuestionSummary(primaryQuestion);
const statusType = status?.type ?? "complete";
return (
<div className="text-muted-foreground flex items-center gap-2 py-0.5 text-sm">
{statusType === "requires-action" ? (
<CircleHelpIcon className="size-3.5 shrink-0" />
) : statusType === "running" ? (
<LoaderIcon className="size-3.5 shrink-0 animate-spin" />
) : statusType === "incomplete" ? (
<XCircleIcon className="text-destructive size-3.5 shrink-0" />
) : (
<CheckCircle2Icon className="size-3.5 shrink-0" />
)}
<span className="flex items-center gap-1.5 truncate">
<span className="font-medium">
{ASK_QUESTION_TOOL_NAMES.includes(
toolName as (typeof ASK_QUESTION_TOOL_NAMES)[number],
)
? "ask_question"
: toolName}
</span>
<span className="opacity-60">{label}</span>
</span>
</div>
);
},
);
AskQuestionInline.displayName = "AskQuestionInline";
@@ -0,0 +1,30 @@
"use client";
import {
AskQuestionInline,
withOpenCodeToolInteractions,
} from "./opencode-tool-interactions";
import { ApplyPatchDiff } from "./tool-ui-apply-patch";
import { BashTerminal } from "./tool-ui-bash";
import {
EditInline,
GlobInline,
GrepInline,
ReadInline,
ToolCallFallback,
WebFetchInline,
WebSearchInline,
WriteInline,
} from "./tool-ui-inline";
export const ReadTool = withOpenCodeToolInteractions(ReadInline);
export const EditTool = withOpenCodeToolInteractions(EditInline);
export const WriteTool = withOpenCodeToolInteractions(WriteInline);
export const BashTool = withOpenCodeToolInteractions(BashTerminal);
export const GrepTool = withOpenCodeToolInteractions(GrepInline);
export const GlobTool = withOpenCodeToolInteractions(GlobInline);
export const WebSearchTool = withOpenCodeToolInteractions(WebSearchInline);
export const WebFetchTool = withOpenCodeToolInteractions(WebFetchInline);
export const ApplyPatchTool = withOpenCodeToolInteractions(ApplyPatchDiff);
export const AskQuestionTool = withOpenCodeToolInteractions(AskQuestionInline);
export const FallbackTool = withOpenCodeToolInteractions(ToolCallFallback);
@@ -0,0 +1,55 @@
"use client";
import { memo, type FC, type PropsWithChildren } from "react";
import { BrainIcon } from "lucide-react";
import { useAuiState } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
type GroupedPartsGroup = {
indices: readonly number[];
};
const ReasoningGroupImpl: FC<
PropsWithChildren<{ group: GroupedPartsGroup }>
> = ({ children, group }) => {
const startIndex = group.indices[0]!;
const endIndex = group.indices.at(-1)!;
const isMultiLine = useAuiState((s) => {
let totalLength = 0;
for (let i = startIndex; i <= endIndex; i++) {
const part = s.message.parts[i];
if (part?.type === "reasoning") {
if (part.text.includes("\n\n")) return true;
totalLength += part.text.length;
}
}
return totalLength > 120;
});
const textAfter = useAuiState((s) => {
for (let i = endIndex + 1; i < s.message.parts.length; i++) {
const type = s.message.parts[i]?.type;
if (type === "data") continue;
return type === "text";
}
return false;
});
return (
<div
data-slot="reasoning-group"
className={cn(
"text-muted-foreground mt-4 mb-2 flex items-start gap-2 text-sm leading-relaxed first:mt-0",
isMultiLine && "mt-6 mb-4",
textAfter && "mb-4",
)}
>
<BrainIcon className="mt-[3.25px] size-3.5 shrink-0" />
<div className="space-y-4.5">{children}</div>
</div>
);
};
export const ReasoningGroup = memo(ReasoningGroupImpl);
ReasoningGroup.displayName = "ReasoningGroup";
@@ -0,0 +1,42 @@
import { cn } from "@/lib/utils";
import { useAuiState } from "@assistant-ui/react";
import { memo, type FC, type PropsWithChildren } from "react";
type GroupedPartsGroup = {
indices: readonly number[];
};
export const ToolGroupImpl: FC<
PropsWithChildren<{ group: GroupedPartsGroup }>
> = ({ children, group }) => {
const startIndex = group.indices[0]!;
const endIndex = group.indices.at(-1)!;
const textBefore = useAuiState((s) => {
for (let i = startIndex - 1; i >= 0; i--) {
const type = s.message.parts[i]?.type;
if (type === "data") continue;
return type === "text";
}
return false;
});
const textAfter = useAuiState((s) => {
for (let i = endIndex + 1; i < s.message.parts.length; i++) {
const type = s.message.parts[i]?.type;
if (type === "data") continue;
return type === "text";
}
return false;
});
return (
<div
data-slot="tool-group"
className={cn(textBefore && "mt-4", textAfter && "mb-4")}
>
{children}
</div>
);
};
export const ToolGroup = memo(ToolGroupImpl);
@@ -0,0 +1,230 @@
"use client";
import { memo, useMemo, useState } from "react";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/radix/collapsible";
import { cn } from "@/lib/utils";
import { PatchDiff } from "@pierre/diffs/react";
import {
ToolStatusIcon,
getPatchInfo,
isCancelledToolStatus,
str,
} from "@/components/tools/tool-ui-shared";
// ── Patch format conversion ──────────────────────────────────────────────
// Claude Code's apply_patch uses a custom "v2" format:
// *** Begin Patch
// *** Update File: path/to/file.ts
// @@ context search text @@
// context line
// -removed
// +added
// *** End Patch
//
// The @@ markers are context-search strings, NOT standard unified diff
// hunk headers. We convert to proper unified diff for @pierre/diffs.
interface PatchBlock {
type: string;
path: string;
body: string;
}
function parseClaudePatchBlocks(patchText: string): PatchBlock[] {
// Strip wrapper lines
const cleaned = patchText
.replace(/^\*\*\*\s*Begin Patch\s*$/gm, "")
.replace(/^\*\*\*\s*End Patch\s*$/gm, "")
.trim();
const headerRegex = /^\*\*\*\s+(Update|Add|Delete)\s+File:\s+(.+)$/gm;
const blocks: PatchBlock[] = [];
let lastIndex = 0;
for (const match of cleaned.matchAll(headerRegex)) {
if (blocks.length > 0) {
blocks[blocks.length - 1]!.body = cleaned.slice(lastIndex, match.index);
}
blocks.push({ type: match[1]!, path: match[2]!.trim(), body: "" });
lastIndex = match.index + match[0].length;
}
if (blocks.length > 0) {
blocks[blocks.length - 1]!.body = cleaned.slice(lastIndex);
}
return blocks;
}
function buildHunkHeader(
lines: string[],
oldStart: number,
newStart: number,
): { header: string; oldCount: number; newCount: number } {
let oldCount = 0;
let newCount = 0;
for (const line of lines) {
if (line.startsWith("+")) newCount++;
else if (line.startsWith("-")) oldCount++;
else {
// context line (space prefix or empty)
oldCount++;
newCount++;
}
}
return {
header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
oldCount,
newCount,
};
}
function claudeBlockToUnifiedDiff(block: PatchBlock): string {
const { type, path, body } = block;
const bodyLines = body
.split("\n")
.filter((l) => l.trim() !== "" || l.startsWith(" "));
// Filter out empty trailing lines but keep intentional blank context lines
const diffLines: string[] = [];
for (const line of bodyLines) {
// Skip Claude's @@ context search @@ markers (e.g. "@@ function foo() { @@")
// These are NOT standard unified diff hunk headers (@@ -N,M +N,M @@)
if (/^@@.*@@\s*$/.test(line)) continue;
// Keep actual diff lines: +, -, or space-prefixed context
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) {
diffLines.push(line);
}
}
let gitHeader: string;
let fileHeader: string;
switch (type) {
case "Add":
gitHeader = `diff --git a/${path} b/${path}\nnew file mode 100644`;
fileHeader = `--- /dev/null\n+++ b/${path}`;
break;
case "Delete":
gitHeader = `diff --git a/${path} b/${path}\ndeleted file mode 100644`;
fileHeader = `--- a/${path}\n+++ /dev/null`;
break;
default:
gitHeader = `diff --git a/${path} b/${path}`;
fileHeader = `--- a/${path}\n+++ b/${path}`;
}
if (diffLines.length === 0) return `${gitHeader}\n${fileHeader}`;
// For Add files, all lines should be additions
if (type === "Add") {
const addLines = diffLines.map((l) => (l.startsWith("+") ? l : `+${l}`));
const count = addLines.length;
return `${gitHeader}\n${fileHeader}\n@@ -0,0 +1,${count} @@\n${addLines.join("\n")}`;
}
// For Delete files, all lines should be deletions
if (type === "Delete") {
const delLines = diffLines.map((l) => (l.startsWith("-") ? l : `-${l}`));
const count = delLines.length;
return `${gitHeader}\n${fileHeader}\n@@ -1,${count} +0,0 @@\n${delLines.join("\n")}`;
}
// For Update files, synthesize proper hunk headers
const { header: hunkHeader } = buildHunkHeader(diffLines, 1, 1);
return `${gitHeader}\n${fileHeader}\n${hunkHeader}\n${diffLines.join("\n")}`;
}
// ── Component ────────────────────────────────────────────────────────────
export const ApplyPatchDiff: ToolCallMessagePartComponent = memo(
({ toolName, args, status }) => {
const [open, setOpen] = useState(false);
const patchText = str(args?.patchText);
const patchInfo = useMemo(() => getPatchInfo(patchText), [patchText]);
const unifiedPatch = useMemo(() => {
if (!patchText) return "";
const blocks = parseClaudePatchBlocks(patchText);
return blocks.map(claudeBlockToUnifiedDiff).join("\n");
}, [patchText]);
const isRunning = status?.type === "running";
const isCancelled = isCancelledToolStatus(status);
const hasDiff = unifiedPatch.length > 0 && !isRunning;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="group text-muted-foreground hover:text-foreground flex w-full items-center gap-2 py-0.5 text-sm transition-colors"
>
<ToolStatusIcon
status={status}
completeIcon={<CheckIcon className="size-3 shrink-0" />}
/>
<span
className={cn(
"flex items-center gap-1.5 truncate",
isCancelled && "line-through opacity-50",
)}
>
<span className="font-medium">{toolName}</span>
{patchInfo.files.length > 0 && (
<span className="opacity-60">
{patchInfo.files.length === 1
? patchInfo.files[0]
: `${patchInfo.files.length} files`}
</span>
)}
{(patchInfo.added > 0 || patchInfo.removed > 0) && !isRunning && (
<span className="ml-0.5 flex items-center gap-1 font-mono text-xs">
{patchInfo.added > 0 && (
<span className="text-green-500">+{patchInfo.added}</span>
)}
{patchInfo.removed > 0 && (
<span className="text-red-500">-{patchInfo.removed}</span>
)}
</span>
)}
</span>
{hasDiff && (
<>
<span className="border-muted-foreground/20 group-hover:border-muted-foreground/50 mt-0.5 min-w-4 flex-1 self-center border-b-[0.5px] transition-colors" />
<ChevronRightIcon
className={cn(
"stroke-muted-foreground/60 group-hover:stroke-foreground/60 mt-0.5 size-3.75 shrink-0 transition-[transform,stroke]",
open && "rotate-90",
)}
/>
</>
)}
</button>
</CollapsibleTrigger>
{hasDiff && (
<CollapsibleContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden data-[state=closed]:ease-out data-[state=open]:ease-in">
<div className="mt-1 ml-5 overflow-hidden rounded-md border">
<PatchDiff
patch={unifiedPatch}
options={{
theme: { dark: "pierre-dark", light: "pierre-light" },
diffStyle: "unified",
overflow: "wrap",
}}
/>
</div>
</CollapsibleContent>
)}
</Collapsible>
);
},
);
ApplyPatchDiff.displayName = "ApplyPatchDiff";
@@ -0,0 +1,124 @@
"use client";
import { memo, useState } from "react";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { ChevronRightIcon, DollarSignIcon, XCircleIcon } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/radix/collapsible";
import { cn } from "@/lib/utils";
import {
ToolStatusIcon,
isCancelledToolStatus,
truncate,
} from "@/components/tools/tool-ui-shared";
const parseResult = (result: unknown) => {
if (!result) return {};
if (typeof result === "string") {
try {
const parsed = JSON.parse(result);
if (typeof parsed === "object" && parsed !== null) return parsed;
} catch {
// plain text output
}
return { stdout: result };
}
if (typeof result === "object") return result as Record<string, unknown>;
return {};
};
export const BashTerminal: ToolCallMessagePartComponent = memo(
({ args, result, status }) => {
const [open, setOpen] = useState(false);
const command = typeof args?.command === "string" ? args.command : "";
const description =
typeof args?.description === "string" ? args.description : "";
const isRunning = status?.type === "running";
const isCancelled = isCancelledToolStatus(status);
const parsed = isRunning ? {} : parseResult(result);
const stdout =
typeof parsed.stdout === "string" ? parsed.stdout : undefined;
const stderr =
typeof parsed.stderr === "string" ? parsed.stderr : undefined;
const exitCode = typeof parsed.exitCode === "number" ? parsed.exitCode : 0;
const hasOutput = Boolean(stdout || stderr);
const isError = !isRunning && exitCode !== 0;
const summaryText = description || truncate(command);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="group text-muted-foreground hover:text-foreground flex w-full items-center gap-2 py-0.5 text-sm transition-colors"
>
{isRunning ? (
<ToolStatusIcon status={status} />
) : isError ? (
<XCircleIcon className="text-destructive size-3 shrink-0" />
) : (
<ToolStatusIcon
status={status}
completeIcon={<DollarSignIcon className="size-3.5 shrink-0" />}
/>
)}
<span
className={cn(
"flex items-center gap-1.5 truncate",
isCancelled && "line-through opacity-50",
)}
>
<span className="font-medium">bash</span>
{summaryText && <span className="opacity-60">{summaryText}</span>}
</span>
{hasOutput && (
<>
<span className="border-muted-foreground/20 group-hover:border-muted-foreground/50 mt-0.5 min-w-4 flex-1 self-center border-b-[0.5px] transition-colors" />
<ChevronRightIcon
className={cn(
"stroke-muted-foreground/60 group-hover:stroke-foreground/60 mt-0.5 size-3.75 shrink-0 transition-[transform,stroke]",
open && "rotate-90",
)}
/>
</>
)}
</button>
</CollapsibleTrigger>
{hasOutput && (
<CollapsibleContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden data-[state=closed]:ease-out data-[state=open]:ease-in">
<div className="bg-muted/50 mt-1 ml-5 max-h-96 overflow-y-auto rounded-md border p-3 font-mono text-xs">
{command && (
<div className="text-muted-foreground mb-2">$ {command}</div>
)}
{stdout && (
<pre className="text-foreground wrap-break-word whitespace-pre-wrap">
{stdout}
</pre>
)}
{stderr && (
<pre
className={cn(
"text-destructive wrap-break-word whitespace-pre-wrap",
stdout && "mt-2",
)}
>
{stderr}
</pre>
)}
</div>
</CollapsibleContent>
)}
</Collapsible>
);
},
);
BashTerminal.displayName = "BashTerminal";
@@ -0,0 +1,169 @@
"use client";
import { memo, useMemo } from "react";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import {
ToolStatusIcon,
basename,
getPatchInfo,
isCancelledToolStatus,
str,
truncate,
type ToolCallStatusLike,
} from "@/components/tools/tool-ui-shared";
// ── Helpers ────────────────────────────────────────────────────────────
const shortenPath = (filepath: string, depth = 2): string => {
const parts = filepath.split("/").filter(Boolean);
if (parts.length <= depth) return filepath;
return parts.slice(-depth).join("/");
};
// ── ToolCallShell ──────────────────────────────────────────────────────
const ToolCallShell = ({
toolName,
status,
children,
}: {
toolName: string;
status?: ToolCallStatusLike;
children?: React.ReactNode;
}) => {
const isCancelled = isCancelledToolStatus(status);
return (
<div className="text-muted-foreground flex items-center gap-2 py-0.5 text-sm">
<ToolStatusIcon status={status} />
<span
className={cn(
"flex items-center gap-1.5 truncate",
isCancelled && "line-through opacity-50",
)}
>
<span className="font-medium">{toolName}</span>
{children}
</span>
</div>
);
};
// ── Factory for simple inline tools ────────────────────────────────────
const inlineTool = (
argKeys: string | string[],
format: (v: string) => string = truncate,
): ToolCallMessagePartComponent => {
const keys = Array.isArray(argKeys) ? argKeys : [argKeys];
const Component: ToolCallMessagePartComponent = memo(
({ toolName, args, status }) => {
const value = keys.reduce<string>(
(acc, key) => acc || str((args as Record<string, unknown>)?.[key]),
"",
);
return (
<ToolCallShell toolName={toolName} status={status}>
{value && <span className="opacity-60">{format(value)}</span>}
</ToolCallShell>
);
},
);
return Component;
};
// ── Per-tool inline components ─────────────────────────────────────────
export const ReadInline = inlineTool(
["file_path", "filePath", "path", "file"],
(v) => truncate(shortenPath(v)),
);
ReadInline.displayName = "ReadInline";
export const EditInline = inlineTool("file_path", basename);
EditInline.displayName = "EditInline";
export const WriteInline = inlineTool("file_path", basename);
WriteInline.displayName = "WriteInline";
export const GrepInline = inlineTool("pattern");
GrepInline.displayName = "GrepInline";
export const GlobInline = inlineTool("pattern");
GlobInline.displayName = "GlobInline";
export const WebSearchInline = inlineTool("query");
WebSearchInline.displayName = "WebSearchInline";
export const WebFetchInline = inlineTool("url");
WebFetchInline.displayName = "WebFetchInline";
// Kept as a compact reference implementation even though the example
// currently uses the richer diff viewer from tool-ui-apply-patch.tsx.
export const ApplyPatchInline: ToolCallMessagePartComponent = memo(
({ toolName, args, status }) => {
const patchText = str(args?.patchText);
const patchInfo = useMemo(() => getPatchInfo(patchText), [patchText]);
const isRunning = status?.type === "running";
return (
<ToolCallShell toolName={toolName} status={status}>
{patchInfo.files.length > 0 && (
<span className="opacity-60">
{patchInfo.files.length === 1
? patchInfo.files[0]
: `${patchInfo.files.length} files`}
</span>
)}
{(patchInfo.added > 0 || patchInfo.removed > 0) && !isRunning && (
<span className="ml-0.5 flex items-center gap-1 font-mono text-xs">
{patchInfo.added > 0 && (
<span className="text-green-500">+{patchInfo.added}</span>
)}
{patchInfo.removed > 0 && (
<span className="text-red-500">-{patchInfo.removed}</span>
)}
</span>
)}
</ToolCallShell>
);
},
);
ApplyPatchInline.displayName = "ApplyPatchInline";
// ── Fallback — generic, for unknown/MCP tools ─────────────────────────
const SUMMARY_KEYS = [
"file_path",
"path",
"pattern",
"command",
"query",
"glob",
"url",
] as const;
const ToolCallFallbackImpl: ToolCallMessagePartComponent = ({
toolName,
args,
status,
}) => {
const summary = useMemo(() => {
if (!args || typeof args !== "object") return "";
for (const key of SUMMARY_KEYS) {
const v = (args as Record<string, unknown>)[key];
if (typeof v === "string" && v) return truncate(v);
}
return "";
}, [args]);
return (
<ToolCallShell toolName={toolName} status={status}>
{summary && <span className="opacity-60">{summary}</span>}
</ToolCallShell>
);
};
export const ToolCallFallback = memo(ToolCallFallbackImpl);
ToolCallFallback.displayName = "ToolCallFallback";
@@ -0,0 +1,81 @@
"use client";
import type { ReactNode } from "react";
import {
AlertCircleIcon,
CheckIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type ToolCallStatusLike =
| {
type: string;
reason?: string;
}
| undefined;
export const truncate = (value: string, max = 80): string =>
value.length > max ? `${value.slice(0, max - 3)}...` : value;
export const str = (value: unknown): string =>
typeof value === "string" ? value : "";
export const basename = (filepath: string): string => {
const parts = filepath.split("/").filter(Boolean);
return parts[parts.length - 1] ?? filepath;
};
const unique = <T,>(items: readonly T[]) => [...new Set(items)];
export const getPatchInfo = (patchText: string) => {
if (!patchText) return { files: [] as string[], added: 0, removed: 0 };
const files = unique(
[...patchText.matchAll(/^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+)$/gm)]
.map((match) => basename(match[1]!.trim()))
.filter(Boolean),
);
let added = 0;
let removed = 0;
for (const line of patchText.split("\n")) {
if (line.startsWith("+")) added += 1;
else if (line.startsWith("-")) removed += 1;
}
return { files, added, removed };
};
export const isCancelledToolStatus = (status?: ToolCallStatusLike) =>
status?.type === "incomplete" && status.reason === "cancelled";
export const ToolStatusIcon = ({
status,
completeIcon,
}: {
status?: ToolCallStatusLike;
completeIcon?: ReactNode;
}) => {
const statusType = status?.type ?? "complete";
const isCancelled = isCancelledToolStatus(status);
if (statusType === "running") {
return <LoaderIcon className="size-3 shrink-0 animate-spin" />;
}
if (statusType === "requires-action") {
return <AlertCircleIcon className="size-3 shrink-0 text-amber-600" />;
}
if (statusType === "incomplete") {
return (
<XCircleIcon
className={cn("size-3 shrink-0", !isCancelled && "text-destructive")}
/>
);
}
return completeIcon ?? <CheckIcon className="size-3 shrink-0" />;
};
@@ -0,0 +1,30 @@
"use generative";
import { defineToolkit, externalTool } from "@assistant-ui/react";
import {
ApplyPatchTool,
AskQuestionTool,
BashTool,
EditTool,
GlobTool,
GrepTool,
ReadTool,
WebFetchTool,
WebSearchTool,
WriteTool,
} from "./opencode-tools";
export default defineToolkit({
read: { execute: externalTool(), render: ReadTool },
edit: { execute: externalTool(), render: EditTool },
write: { execute: externalTool(), render: WriteTool },
bash: { execute: externalTool(), render: BashTool },
grep: { execute: externalTool(), render: GrepTool },
glob: { execute: externalTool(), render: GlobTool },
webSearch: { execute: externalTool(), render: WebSearchTool },
webFetch: { execute: externalTool(), render: WebFetchTool },
apply_patch: { execute: externalTool(), render: ApplyPatchTool },
ask_question: { execute: externalTool(), render: AskQuestionTool },
request_user_input: { execute: externalTool(), render: AskQuestionTool },
requestUserInput: { execute: externalTool(), render: AskQuestionTool },
});