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
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:
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user