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,3 @@
|
||||
# Optional. Defaults to http://localhost:4096 when unset.
|
||||
# Point this at your running OpenCode server.
|
||||
NEXT_PUBLIC_OPENCODE_BASE_URL=http://localhost:4096
|
||||
@@ -0,0 +1,8 @@
|
||||
# with-opencode
|
||||
|
||||
> [!NOTE] This integration/example is experimental, there may be bugs or issues.
|
||||
|
||||
Next.js example for the OpenCode runtime adapter built with assistant-ui.
|
||||
|
||||
Set `NEXT_PUBLIC_OPENCODE_BASE_URL` if your OpenCode server is not running on
|
||||
`http://localhost:4096`.
|
||||
@@ -0,0 +1,136 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../../../packages/ui/src";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
:root.dark,
|
||||
html.dark,
|
||||
body.dark,
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "assistant-ui OpenCode Example",
|
||||
description: "An OpenCode runtime example built with assistant-ui",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark h-full">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} dark bg-background text-foreground h-full overflow-hidden antialiased`}
|
||||
>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { AssistantRuntimeProvider, Tools, useAui } from "@assistant-ui/react";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/radix/sidebar";
|
||||
import { Separator } from "@/components/ui/radix/separator";
|
||||
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
|
||||
import {
|
||||
useOpenCodeRuntime,
|
||||
useOpenCodeSession,
|
||||
} from "@assistant-ui/react-opencode";
|
||||
import {
|
||||
Thread,
|
||||
type ThreadComponents,
|
||||
} from "@/components/assistant-ui/thread";
|
||||
import { OpenCodeDataPart } from "@/components/opencode-data-part";
|
||||
import { FallbackTool } from "@/components/tools/opencode-tools";
|
||||
import { ReasoningGroup } from "@/components/tools/reasoning-ghost";
|
||||
import { ToolGroup } from "@/components/tools/tool-group";
|
||||
import toolkit from "@/components/tools/toolkit";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const THREAD_COMPONENTS: ThreadComponents = {
|
||||
ToolFallback: FallbackTool,
|
||||
ToolGroup,
|
||||
ReasoningGroup,
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const runtime = useOpenCodeRuntime({
|
||||
baseUrl:
|
||||
process.env.NEXT_PUBLIC_OPENCODE_BASE_URL ?? "http://localhost:4096",
|
||||
});
|
||||
|
||||
const aui = useAui({
|
||||
tools: Tools({ toolkit }),
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => aui.dataRenderers().setFallbackDataUI(OpenCodeDataPart),
|
||||
[aui],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider aui={aui} runtime={runtime}>
|
||||
<SidebarProvider>
|
||||
<div className="flex h-dvh w-full overflow-hidden pr-0.5">
|
||||
<ThreadListSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">OpenCode</div>
|
||||
<SelectedSessionTitle />
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<Thread components={THREAD_COMPONENTS} />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedSessionTitle() {
|
||||
const session = useOpenCodeSession();
|
||||
const title = session?.title?.trim();
|
||||
|
||||
if (!title) return null;
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground truncate text-xs leading-tight">
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@assistant-ui": "https://r.assistant-ui.com/{name}.json"
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { withAui } from "@assistant-ui/next";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default withAui(nextConfig);
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "with-opencode",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "workspace:*",
|
||||
"@assistant-ui/react-markdown": "workspace:*",
|
||||
"@assistant-ui/react-opencode": "workspace:*",
|
||||
"@assistant-ui/ui": "workspace:*",
|
||||
"@pierre/diffs": "^1.2.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@assistant-ui/next": "workspace:*",
|
||||
"@assistant-ui/x-buildutils": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-7": "npm:typescript@^7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "@assistant-ui/x-buildutils/ts/next",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@assistant-ui/react-opencode": [
|
||||
"../../packages/react-opencode/src/index.ts"
|
||||
],
|
||||
"@/components/assistant-ui/*": [
|
||||
"../../packages/ui/src/components/assistant-ui/*"
|
||||
],
|
||||
"@/components/icons/*": ["../../packages/ui/src/components/icons/*"],
|
||||
"@/components/ui/*": [
|
||||
"../../packages/ui/src/components/ui/base/*",
|
||||
"../../packages/ui/src/components/ui/radix/*"
|
||||
],
|
||||
"@/components/ui/radix/*": [
|
||||
"../../packages/ui/src/components/ui/radix/*"
|
||||
],
|
||||
"@/hooks/*": ["../../packages/ui/src/hooks/*"],
|
||||
"@/lib/utils": ["../../packages/ui/src/lib/utils"],
|
||||
"@assistant-ui/ui/*": ["../../packages/ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user