@assistant-ui/mcp-docs-server 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.docs/organized/code-examples/waterfall.md +18 -10
- package/.docs/organized/code-examples/with-a2a.md +12 -24
- package/.docs/organized/code-examples/with-ag-ui.md +14 -11
- package/.docs/organized/code-examples/with-ai-sdk-v6.md +12 -12
- package/.docs/organized/code-examples/with-artifacts.md +14 -12
- package/.docs/organized/code-examples/with-assistant-transport.md +13 -14
- package/.docs/organized/code-examples/with-chain-of-thought.md +11 -11
- package/.docs/organized/code-examples/with-cloud-standalone.md +17 -14
- package/.docs/organized/code-examples/with-cloud.md +12 -13
- package/.docs/organized/code-examples/with-custom-thread-list.md +12 -12
- package/.docs/organized/code-examples/with-elevenlabs-conversational.md +19 -14
- package/.docs/organized/code-examples/with-elevenlabs-scribe.md +15 -15
- package/.docs/organized/code-examples/with-expo.md +27 -23
- package/.docs/organized/code-examples/with-external-store.md +11 -11
- package/.docs/organized/code-examples/with-ffmpeg.md +19 -14
- package/.docs/organized/code-examples/with-generative-ui.md +11 -11
- package/.docs/organized/code-examples/with-google-adk.md +10 -10
- package/.docs/organized/code-examples/with-heat-graph.md +8 -8
- package/.docs/organized/code-examples/with-interactables.md +12 -27
- package/.docs/organized/code-examples/with-langchain.md +437 -0
- package/.docs/organized/code-examples/with-langgraph.md +20 -20
- package/.docs/organized/code-examples/with-livekit.md +59 -18
- package/.docs/organized/code-examples/with-opencode.md +2392 -0
- package/.docs/organized/code-examples/with-parent-id-grouping.md +12 -12
- package/.docs/organized/code-examples/with-react-hook-form.md +223 -151
- package/.docs/organized/code-examples/with-react-ink.md +3 -3
- package/.docs/organized/code-examples/with-react-router.md +15 -15
- package/.docs/organized/code-examples/with-store.md +11 -8
- package/.docs/organized/code-examples/with-tanstack.md +14 -14
- package/.docs/organized/code-examples/with-tap-runtime.md +13 -9
- package/.docs/raw/docs/(docs)/guides/mentions.mdx +248 -109
- package/.docs/raw/docs/(docs)/guides/slash-commands.mdx +112 -92
- package/.docs/raw/docs/(docs)/guides/voice.mdx +10 -3
- package/.docs/raw/docs/(docs)/rtl.mdx +79 -0
- package/.docs/raw/docs/(reference)/api-reference/primitives/composer.mdx +149 -40
- package/.docs/raw/docs/(reference)/api-reference/primitives/message-part.mdx +2 -0
- package/.docs/raw/docs/(reference)/api-reference/runtimes/thread-list-runtime.mdx +4 -0
- package/.docs/raw/docs/cloud/ai-sdk-assistant-ui.mdx +1 -1
- package/.docs/raw/docs/primitives/composer.mdx +94 -62
- package/.docs/raw/docs/runtimes/ai-sdk/v6.mdx +57 -0
- package/.docs/raw/docs/runtimes/custom/custom-thread-list.mdx +49 -3
- package/.docs/raw/docs/runtimes/custom/external-store.mdx +39 -1
- package/.docs/raw/docs/runtimes/custom/local.mdx +65 -18
- package/.docs/raw/docs/runtimes/google-adk/index.mdx +62 -0
- package/.docs/raw/docs/runtimes/langchain/comparison.mdx +60 -0
- package/.docs/raw/docs/runtimes/langchain/index.mdx +210 -0
- package/.docs/raw/docs/runtimes/langgraph/index.mdx +288 -60
- package/.docs/raw/docs/runtimes/pick-a-runtime.mdx +12 -4
- package/.docs/raw/docs/ui/composer-trigger-popover.mdx +199 -0
- package/.docs/raw/docs/ui/directive-text.mdx +113 -0
- package/.docs/raw/docs/ui/reasoning.mdx +13 -9
- package/dist/utils/logger.js +1 -1
- package/dist/utils/logger.js.map +1 -1
- package/package.json +4 -4
- package/src/utils/logger.ts +1 -1
- package/.docs/raw/docs/ui/mention.mdx +0 -168
|
@@ -0,0 +1,2392 @@
|
|
|
1
|
+
# Example: with-opencode
|
|
2
|
+
|
|
3
|
+
## app/globals.css
|
|
4
|
+
|
|
5
|
+
```css
|
|
6
|
+
@import "tailwindcss";
|
|
7
|
+
@import "tw-animate-css";
|
|
8
|
+
|
|
9
|
+
@source "../../../packages/ui/src";
|
|
10
|
+
|
|
11
|
+
@custom-variant dark (&:is(.dark *));
|
|
12
|
+
|
|
13
|
+
@theme inline {
|
|
14
|
+
--color-background: var(--background);
|
|
15
|
+
--color-foreground: var(--foreground);
|
|
16
|
+
--font-sans: var(--font-geist-sans);
|
|
17
|
+
--font-mono: var(--font-geist-mono);
|
|
18
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
19
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
20
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
21
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
22
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
23
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
24
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
25
|
+
--color-sidebar: var(--sidebar);
|
|
26
|
+
--color-chart-5: var(--chart-5);
|
|
27
|
+
--color-chart-4: var(--chart-4);
|
|
28
|
+
--color-chart-3: var(--chart-3);
|
|
29
|
+
--color-chart-2: var(--chart-2);
|
|
30
|
+
--color-chart-1: var(--chart-1);
|
|
31
|
+
--color-ring: var(--ring);
|
|
32
|
+
--color-input: var(--input);
|
|
33
|
+
--color-border: var(--border);
|
|
34
|
+
--color-destructive: var(--destructive);
|
|
35
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
36
|
+
--color-accent: var(--accent);
|
|
37
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
38
|
+
--color-muted: var(--muted);
|
|
39
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
40
|
+
--color-secondary: var(--secondary);
|
|
41
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
42
|
+
--color-primary: var(--primary);
|
|
43
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
44
|
+
--color-popover: var(--popover);
|
|
45
|
+
--color-card-foreground: var(--card-foreground);
|
|
46
|
+
--color-card: var(--card);
|
|
47
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
48
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
49
|
+
--radius-lg: var(--radius);
|
|
50
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
:root {
|
|
54
|
+
--radius: 0.625rem;
|
|
55
|
+
--background: oklch(1 0 0);
|
|
56
|
+
--foreground: oklch(0.141 0.005 285.823);
|
|
57
|
+
--card: oklch(1 0 0);
|
|
58
|
+
--card-foreground: oklch(0.141 0.005 285.823);
|
|
59
|
+
--popover: oklch(1 0 0);
|
|
60
|
+
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
61
|
+
--primary: oklch(0.21 0.006 285.885);
|
|
62
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
63
|
+
--secondary: oklch(0.967 0.001 286.375);
|
|
64
|
+
--secondary-foreground: oklch(0.21 0.006 285.885);
|
|
65
|
+
--muted: oklch(0.967 0.001 286.375);
|
|
66
|
+
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
67
|
+
--accent: oklch(0.967 0.001 286.375);
|
|
68
|
+
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
69
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
70
|
+
--border: oklch(0.92 0.004 286.32);
|
|
71
|
+
--input: oklch(0.92 0.004 286.32);
|
|
72
|
+
--ring: oklch(0.705 0.015 286.067);
|
|
73
|
+
--chart-1: oklch(0.646 0.222 41.116);
|
|
74
|
+
--chart-2: oklch(0.6 0.118 184.704);
|
|
75
|
+
--chart-3: oklch(0.398 0.07 227.392);
|
|
76
|
+
--chart-4: oklch(0.828 0.189 84.429);
|
|
77
|
+
--chart-5: oklch(0.769 0.188 70.08);
|
|
78
|
+
--sidebar: oklch(0.985 0 0);
|
|
79
|
+
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
|
80
|
+
--sidebar-primary: oklch(0.21 0.006 285.885);
|
|
81
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
82
|
+
--sidebar-accent: oklch(0.967 0.001 286.375);
|
|
83
|
+
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
|
84
|
+
--sidebar-border: oklch(0.92 0.004 286.32);
|
|
85
|
+
--sidebar-ring: oklch(0.705 0.015 286.067);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
:root.dark,
|
|
89
|
+
html.dark,
|
|
90
|
+
body.dark,
|
|
91
|
+
.dark {
|
|
92
|
+
--background: oklch(0.141 0.005 285.823);
|
|
93
|
+
--foreground: oklch(0.985 0 0);
|
|
94
|
+
--card: oklch(0.21 0.006 285.885);
|
|
95
|
+
--card-foreground: oklch(0.985 0 0);
|
|
96
|
+
--popover: oklch(0.21 0.006 285.885);
|
|
97
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
98
|
+
--primary: oklch(0.92 0.004 286.32);
|
|
99
|
+
--primary-foreground: oklch(0.21 0.006 285.885);
|
|
100
|
+
--secondary: oklch(0.274 0.006 286.033);
|
|
101
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
102
|
+
--muted: oklch(0.274 0.006 286.033);
|
|
103
|
+
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
104
|
+
--accent: oklch(0.274 0.006 286.033);
|
|
105
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
106
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
107
|
+
--border: oklch(1 0 0 / 10%);
|
|
108
|
+
--input: oklch(1 0 0 / 15%);
|
|
109
|
+
--ring: oklch(0.552 0.016 285.938);
|
|
110
|
+
--chart-1: oklch(0.488 0.243 264.376);
|
|
111
|
+
--chart-2: oklch(0.696 0.17 162.48);
|
|
112
|
+
--chart-3: oklch(0.769 0.188 70.08);
|
|
113
|
+
--chart-4: oklch(0.627 0.265 303.9);
|
|
114
|
+
--chart-5: oklch(0.645 0.246 16.439);
|
|
115
|
+
--sidebar: oklch(0.21 0.006 285.885);
|
|
116
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
117
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
118
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
119
|
+
--sidebar-accent: oklch(0.274 0.006 286.033);
|
|
120
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
121
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
122
|
+
--sidebar-ring: oklch(0.552 0.016 285.938);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@layer base {
|
|
126
|
+
* {
|
|
127
|
+
@apply border-border outline-ring/50;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
:root {
|
|
131
|
+
color-scheme: light;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
:root.dark {
|
|
135
|
+
color-scheme: dark;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
body {
|
|
139
|
+
@apply bg-background text-foreground;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## app/layout.tsx
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
import type { Metadata } from "next";
|
|
149
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
150
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
151
|
+
import "./globals.css";
|
|
152
|
+
|
|
153
|
+
const geistSans = Geist({
|
|
154
|
+
variable: "--font-geist-sans",
|
|
155
|
+
subsets: ["latin"],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const geistMono = Geist_Mono({
|
|
159
|
+
variable: "--font-geist-mono",
|
|
160
|
+
subsets: ["latin"],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const metadata: Metadata = {
|
|
164
|
+
title: "assistant-ui OpenCode Example",
|
|
165
|
+
description: "An OpenCode runtime example built with assistant-ui",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export default function RootLayout({
|
|
169
|
+
children,
|
|
170
|
+
}: Readonly<{
|
|
171
|
+
children: React.ReactNode;
|
|
172
|
+
}>) {
|
|
173
|
+
return (
|
|
174
|
+
<html lang="en" className="dark h-full">
|
|
175
|
+
<body
|
|
176
|
+
className={`${geistSans.variable} ${geistMono.variable} dark h-full overflow-hidden bg-background text-foreground antialiased`}
|
|
177
|
+
>
|
|
178
|
+
<TooltipProvider>{children}</TooltipProvider>
|
|
179
|
+
</body>
|
|
180
|
+
</html>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## app/page.tsx
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
"use client";
|
|
190
|
+
|
|
191
|
+
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
|
192
|
+
import {
|
|
193
|
+
SidebarInset,
|
|
194
|
+
SidebarProvider,
|
|
195
|
+
SidebarTrigger,
|
|
196
|
+
} from "@/components/ui/sidebar";
|
|
197
|
+
import { Separator } from "@/components/ui/separator";
|
|
198
|
+
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
|
|
199
|
+
import {
|
|
200
|
+
useOpenCodeRuntime,
|
|
201
|
+
useOpenCodeSession,
|
|
202
|
+
} from "@assistant-ui/react-opencode";
|
|
203
|
+
import { Thread } from "../components/assistant-ui/thread";
|
|
204
|
+
|
|
205
|
+
export default function Home() {
|
|
206
|
+
const runtime = useOpenCodeRuntime({
|
|
207
|
+
baseUrl:
|
|
208
|
+
process.env.NEXT_PUBLIC_OPENCODE_BASE_URL ?? "http://localhost:4096",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
213
|
+
<SidebarProvider>
|
|
214
|
+
<div className="flex h-dvh w-full overflow-hidden pr-0.5">
|
|
215
|
+
<ThreadListSidebar />
|
|
216
|
+
<SidebarInset>
|
|
217
|
+
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
|
218
|
+
<SidebarTrigger />
|
|
219
|
+
<Separator orientation="vertical" className="mr-2 h-4" />
|
|
220
|
+
<div className="min-w-0">
|
|
221
|
+
<div className="font-medium text-sm">OpenCode</div>
|
|
222
|
+
<SelectedSessionTitle />
|
|
223
|
+
</div>
|
|
224
|
+
</header>
|
|
225
|
+
<div className="min-h-0 flex-1 overflow-hidden">
|
|
226
|
+
<Thread />
|
|
227
|
+
</div>
|
|
228
|
+
</SidebarInset>
|
|
229
|
+
</div>
|
|
230
|
+
</SidebarProvider>
|
|
231
|
+
</AssistantRuntimeProvider>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function SelectedSessionTitle() {
|
|
236
|
+
const session = useOpenCodeSession();
|
|
237
|
+
const title = session?.title?.trim();
|
|
238
|
+
|
|
239
|
+
if (!title) return null;
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div className="truncate text-muted-foreground text-xs leading-tight">
|
|
243
|
+
{title}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## components.json
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
255
|
+
"style": "new-york",
|
|
256
|
+
"rsc": true,
|
|
257
|
+
"tsx": true,
|
|
258
|
+
"tailwind": {
|
|
259
|
+
"config": "",
|
|
260
|
+
"css": "app/globals.css",
|
|
261
|
+
"baseColor": "zinc",
|
|
262
|
+
"cssVariables": true,
|
|
263
|
+
"prefix": ""
|
|
264
|
+
},
|
|
265
|
+
"aliases": {
|
|
266
|
+
"components": "@/components",
|
|
267
|
+
"utils": "@/lib/utils",
|
|
268
|
+
"ui": "@/components/ui",
|
|
269
|
+
"lib": "@/lib",
|
|
270
|
+
"hooks": "@/hooks"
|
|
271
|
+
},
|
|
272
|
+
"iconLibrary": "lucide",
|
|
273
|
+
"registries": {
|
|
274
|
+
"@assistant-ui": "https://r.assistant-ui.com/{name}.json"
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## components/assistant-ui/thread-list.tsx
|
|
281
|
+
|
|
282
|
+
```tsx
|
|
283
|
+
import { Button } from "@/components/ui/button";
|
|
284
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
285
|
+
import {
|
|
286
|
+
AuiIf,
|
|
287
|
+
ThreadListItemMorePrimitive,
|
|
288
|
+
ThreadListItemPrimitive,
|
|
289
|
+
ThreadListPrimitive,
|
|
290
|
+
} from "@assistant-ui/react";
|
|
291
|
+
import { MoreHorizontalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
|
292
|
+
import type { FC } from "react";
|
|
293
|
+
|
|
294
|
+
export const ThreadList: FC = () => {
|
|
295
|
+
return (
|
|
296
|
+
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1 overflow-hidden">
|
|
297
|
+
<ThreadListNew />
|
|
298
|
+
<AuiIf condition={(s) => s.threads.isLoading}>
|
|
299
|
+
<ThreadListSkeleton />
|
|
300
|
+
</AuiIf>
|
|
301
|
+
<AuiIf condition={(s) => !s.threads.isLoading}>
|
|
302
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
303
|
+
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
|
|
304
|
+
</div>
|
|
305
|
+
</AuiIf>
|
|
306
|
+
</ThreadListPrimitive.Root>
|
|
307
|
+
);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const ThreadListNew: FC = () => {
|
|
311
|
+
return (
|
|
312
|
+
<ThreadListPrimitive.New asChild>
|
|
313
|
+
<Button
|
|
314
|
+
variant="outline"
|
|
315
|
+
className="aui-thread-list-new h-9 justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted"
|
|
316
|
+
>
|
|
317
|
+
<PlusIcon className="size-4" />
|
|
318
|
+
New Session
|
|
319
|
+
</Button>
|
|
320
|
+
</ThreadListPrimitive.New>
|
|
321
|
+
);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const ThreadListSkeleton: FC = () => {
|
|
325
|
+
return (
|
|
326
|
+
<div className="flex flex-col gap-1">
|
|
327
|
+
{Array.from({ length: 5 }, (_, i) => (
|
|
328
|
+
<div
|
|
329
|
+
key={i}
|
|
330
|
+
role="status"
|
|
331
|
+
aria-label="Loading threads"
|
|
332
|
+
className="aui-thread-list-skeleton-wrapper flex h-9 items-center px-3"
|
|
333
|
+
>
|
|
334
|
+
<Skeleton className="aui-thread-list-skeleton h-4 w-full" />
|
|
335
|
+
</div>
|
|
336
|
+
))}
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const ThreadListItem: FC = () => {
|
|
342
|
+
return (
|
|
343
|
+
<ThreadListItemPrimitive.Root className="aui-thread-list-item group flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none data-active:bg-muted">
|
|
344
|
+
<ThreadListItemPrimitive.Trigger className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm">
|
|
345
|
+
<ThreadListItemPrimitive.Title fallback="New Session" />
|
|
346
|
+
</ThreadListItemPrimitive.Trigger>
|
|
347
|
+
<ThreadListItemMore />
|
|
348
|
+
</ThreadListItemPrimitive.Root>
|
|
349
|
+
);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const ThreadListItemMore: FC = () => {
|
|
353
|
+
return (
|
|
354
|
+
<ThreadListItemMorePrimitive.Root>
|
|
355
|
+
<ThreadListItemMorePrimitive.Trigger asChild>
|
|
356
|
+
<Button
|
|
357
|
+
variant="ghost"
|
|
358
|
+
size="icon"
|
|
359
|
+
className="aui-thread-list-item-more mr-2 size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:bg-accent data-[state=open]:opacity-100 group-data-active:opacity-100"
|
|
360
|
+
>
|
|
361
|
+
<MoreHorizontalIcon className="size-4" />
|
|
362
|
+
<span className="sr-only">More options</span>
|
|
363
|
+
</Button>
|
|
364
|
+
</ThreadListItemMorePrimitive.Trigger>
|
|
365
|
+
<ThreadListItemMorePrimitive.Content
|
|
366
|
+
side="bottom"
|
|
367
|
+
align="start"
|
|
368
|
+
className="aui-thread-list-item-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
|
369
|
+
>
|
|
370
|
+
<ThreadListItemPrimitive.Delete asChild>
|
|
371
|
+
<ThreadListItemMorePrimitive.Item className="aui-thread-list-item-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
|
372
|
+
<TrashIcon className="size-4" />
|
|
373
|
+
Delete
|
|
374
|
+
</ThreadListItemMorePrimitive.Item>
|
|
375
|
+
</ThreadListItemPrimitive.Delete>
|
|
376
|
+
</ThreadListItemMorePrimitive.Content>
|
|
377
|
+
</ThreadListItemMorePrimitive.Root>
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## components/assistant-ui/thread.tsx
|
|
384
|
+
|
|
385
|
+
```tsx
|
|
386
|
+
import {
|
|
387
|
+
ComposerAddAttachment,
|
|
388
|
+
ComposerAttachments,
|
|
389
|
+
UserMessageAttachments,
|
|
390
|
+
} from "@/components/assistant-ui/attachment";
|
|
391
|
+
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
|
392
|
+
import { Reasoning } from "@/components/assistant-ui/reasoning";
|
|
393
|
+
import {
|
|
394
|
+
AskQuestionInline,
|
|
395
|
+
withOpenCodeToolInteractions,
|
|
396
|
+
} from "@/components/tools/opencode-tool-interactions";
|
|
397
|
+
import { ReasoningGroup } from "@/components/tools/reasoning-ghost";
|
|
398
|
+
import { BashTerminal } from "@/components/tools/tool-ui-bash";
|
|
399
|
+
import { ApplyPatchDiff } from "@/components/tools/tool-ui-apply-patch";
|
|
400
|
+
import {
|
|
401
|
+
EditInline,
|
|
402
|
+
GlobInline,
|
|
403
|
+
GrepInline,
|
|
404
|
+
ReadInline,
|
|
405
|
+
ToolCallFallback,
|
|
406
|
+
WebFetchInline,
|
|
407
|
+
WebSearchInline,
|
|
408
|
+
WriteInline,
|
|
409
|
+
} from "@/components/tools/tool-ui-inline";
|
|
410
|
+
import { ToolGroup } from "@/components/tools/tool-group";
|
|
411
|
+
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
|
412
|
+
import { OpenCodeDataPart } from "@/components/opencode-data-part";
|
|
413
|
+
import { Button } from "@/components/ui/button";
|
|
414
|
+
import { cn } from "@/lib/utils";
|
|
415
|
+
import {
|
|
416
|
+
ActionBarMorePrimitive,
|
|
417
|
+
ActionBarPrimitive,
|
|
418
|
+
AuiIf,
|
|
419
|
+
BranchPickerPrimitive,
|
|
420
|
+
ComposerPrimitive,
|
|
421
|
+
ErrorPrimitive,
|
|
422
|
+
MessagePrimitive,
|
|
423
|
+
SuggestionPrimitive,
|
|
424
|
+
ThreadPrimitive,
|
|
425
|
+
useAuiState,
|
|
426
|
+
} from "@assistant-ui/react";
|
|
427
|
+
import {
|
|
428
|
+
ArrowDownIcon,
|
|
429
|
+
ArrowUpIcon,
|
|
430
|
+
CheckIcon,
|
|
431
|
+
ChevronLeftIcon,
|
|
432
|
+
ChevronRightIcon,
|
|
433
|
+
CopyIcon,
|
|
434
|
+
DownloadIcon,
|
|
435
|
+
MoreHorizontalIcon,
|
|
436
|
+
PencilIcon,
|
|
437
|
+
RefreshCwIcon,
|
|
438
|
+
SquareIcon,
|
|
439
|
+
} from "lucide-react";
|
|
440
|
+
import type { FC } from "react";
|
|
441
|
+
|
|
442
|
+
const ReadTool = withOpenCodeToolInteractions(ReadInline);
|
|
443
|
+
const EditTool = withOpenCodeToolInteractions(EditInline);
|
|
444
|
+
const WriteTool = withOpenCodeToolInteractions(WriteInline);
|
|
445
|
+
const BashTool = withOpenCodeToolInteractions(BashTerminal);
|
|
446
|
+
const GrepTool = withOpenCodeToolInteractions(GrepInline);
|
|
447
|
+
const GlobTool = withOpenCodeToolInteractions(GlobInline);
|
|
448
|
+
const WebSearchTool = withOpenCodeToolInteractions(WebSearchInline);
|
|
449
|
+
const WebFetchTool = withOpenCodeToolInteractions(WebFetchInline);
|
|
450
|
+
const ApplyPatchTool = withOpenCodeToolInteractions(ApplyPatchDiff);
|
|
451
|
+
const AskQuestionTool = withOpenCodeToolInteractions(AskQuestionInline);
|
|
452
|
+
const FallbackTool = withOpenCodeToolInteractions(ToolCallFallback);
|
|
453
|
+
|
|
454
|
+
export const Thread: FC = () => {
|
|
455
|
+
return (
|
|
456
|
+
<ThreadPrimitive.Root
|
|
457
|
+
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
|
|
458
|
+
style={{
|
|
459
|
+
["--thread-max-width" as string]: "44rem",
|
|
460
|
+
["--composer-radius" as string]: "24px",
|
|
461
|
+
["--composer-padding" as string]: "10px",
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
<ThreadPrimitive.Viewport
|
|
465
|
+
turnAnchor="top"
|
|
466
|
+
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
|
467
|
+
>
|
|
468
|
+
<AuiIf condition={(s) => s.thread.isEmpty}>
|
|
469
|
+
<ThreadWelcome />
|
|
470
|
+
</AuiIf>
|
|
471
|
+
|
|
472
|
+
<ThreadPrimitive.Messages>
|
|
473
|
+
{() => <ThreadMessage />}
|
|
474
|
+
</ThreadPrimitive.Messages>
|
|
475
|
+
|
|
476
|
+
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-(--composer-radius) bg-background pb-4 md:pb-6">
|
|
477
|
+
<ThreadScrollToBottom />
|
|
478
|
+
<Composer />
|
|
479
|
+
</ThreadPrimitive.ViewportFooter>
|
|
480
|
+
</ThreadPrimitive.Viewport>
|
|
481
|
+
</ThreadPrimitive.Root>
|
|
482
|
+
);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const ThreadMessage: FC = () => {
|
|
486
|
+
const role = useAuiState((s) => s.message.role);
|
|
487
|
+
const isEditing = useAuiState((s) => s.message.composer.isEditing);
|
|
488
|
+
if (isEditing) return <EditComposer />;
|
|
489
|
+
if (role === "user") return <UserMessage />;
|
|
490
|
+
return <AssistantMessage />;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const ThreadScrollToBottom: FC = () => {
|
|
494
|
+
return (
|
|
495
|
+
<ThreadPrimitive.ScrollToBottom asChild>
|
|
496
|
+
<TooltipIconButton
|
|
497
|
+
tooltip="Scroll to bottom"
|
|
498
|
+
variant="outline"
|
|
499
|
+
className="aui-thread-scroll-to-bottom absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible dark:border-border dark:bg-background dark:hover:bg-accent"
|
|
500
|
+
>
|
|
501
|
+
<ArrowDownIcon />
|
|
502
|
+
</TooltipIconButton>
|
|
503
|
+
</ThreadPrimitive.ScrollToBottom>
|
|
504
|
+
);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const ThreadWelcome: FC = () => {
|
|
508
|
+
return (
|
|
509
|
+
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
|
|
510
|
+
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
|
|
511
|
+
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
|
|
512
|
+
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
|
|
513
|
+
Welcome
|
|
514
|
+
</h1>
|
|
515
|
+
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200">
|
|
516
|
+
How can I help you today?
|
|
517
|
+
</p>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
<ThreadSuggestions />
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const ThreadSuggestions: FC = () => {
|
|
526
|
+
return (
|
|
527
|
+
<div className="aui-thread-welcome-suggestions grid w-full @md:grid-cols-2 gap-2 pb-4">
|
|
528
|
+
<ThreadPrimitive.Suggestions>
|
|
529
|
+
{() => <ThreadSuggestionItem />}
|
|
530
|
+
</ThreadPrimitive.Suggestions>
|
|
531
|
+
</div>
|
|
532
|
+
);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const ThreadSuggestionItem: FC = () => {
|
|
536
|
+
return (
|
|
537
|
+
<div className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 @md:nth-[n+3]:block nth-[n+3]:hidden animate-in fill-mode-both duration-200">
|
|
538
|
+
<SuggestionPrimitive.Trigger send asChild>
|
|
539
|
+
<Button
|
|
540
|
+
variant="ghost"
|
|
541
|
+
className="aui-thread-welcome-suggestion h-auto w-full @md:flex-col flex-wrap items-start justify-start gap-1 rounded-3xl border bg-background px-4 py-3 text-left text-sm transition-colors hover:bg-muted"
|
|
542
|
+
>
|
|
543
|
+
<SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1 font-medium" />
|
|
544
|
+
<SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 text-muted-foreground empty:hidden" />
|
|
545
|
+
</Button>
|
|
546
|
+
</SuggestionPrimitive.Trigger>
|
|
547
|
+
</div>
|
|
548
|
+
);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const Composer: FC = () => {
|
|
552
|
+
return (
|
|
553
|
+
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
|
554
|
+
<ComposerPrimitive.AttachmentDropzone asChild>
|
|
555
|
+
<div
|
|
556
|
+
data-slot="composer-shell"
|
|
557
|
+
className="flex w-full flex-col gap-2 rounded-(--composer-radius) border bg-background p-(--composer-padding) transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50"
|
|
558
|
+
>
|
|
559
|
+
<ComposerAttachments />
|
|
560
|
+
<ComposerPrimitive.Input
|
|
561
|
+
placeholder="Send a message..."
|
|
562
|
+
className="aui-composer-input max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none placeholder:text-muted-foreground/80"
|
|
563
|
+
rows={1}
|
|
564
|
+
autoFocus
|
|
565
|
+
aria-label="Message input"
|
|
566
|
+
/>
|
|
567
|
+
<ComposerAction />
|
|
568
|
+
</div>
|
|
569
|
+
</ComposerPrimitive.AttachmentDropzone>
|
|
570
|
+
</ComposerPrimitive.Root>
|
|
571
|
+
);
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const ComposerAction: FC = () => {
|
|
575
|
+
return (
|
|
576
|
+
<div className="aui-composer-action-wrapper relative flex items-center justify-between">
|
|
577
|
+
<ComposerAddAttachment />
|
|
578
|
+
<AuiIf condition={(s) => !s.thread.isRunning}>
|
|
579
|
+
<ComposerPrimitive.Send asChild>
|
|
580
|
+
<TooltipIconButton
|
|
581
|
+
tooltip="Send message"
|
|
582
|
+
side="bottom"
|
|
583
|
+
type="button"
|
|
584
|
+
variant="default"
|
|
585
|
+
size="icon"
|
|
586
|
+
className="aui-composer-send size-8 rounded-full"
|
|
587
|
+
aria-label="Send message"
|
|
588
|
+
>
|
|
589
|
+
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
|
590
|
+
</TooltipIconButton>
|
|
591
|
+
</ComposerPrimitive.Send>
|
|
592
|
+
</AuiIf>
|
|
593
|
+
<AuiIf condition={(s) => s.thread.isRunning}>
|
|
594
|
+
<ComposerPrimitive.Cancel asChild>
|
|
595
|
+
<Button
|
|
596
|
+
type="button"
|
|
597
|
+
variant="default"
|
|
598
|
+
size="icon"
|
|
599
|
+
className="aui-composer-cancel size-8 rounded-full"
|
|
600
|
+
aria-label="Stop generating"
|
|
601
|
+
>
|
|
602
|
+
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
|
603
|
+
</Button>
|
|
604
|
+
</ComposerPrimitive.Cancel>
|
|
605
|
+
</AuiIf>
|
|
606
|
+
</div>
|
|
607
|
+
);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const MessageError: FC = () => {
|
|
611
|
+
return (
|
|
612
|
+
<MessagePrimitive.Error>
|
|
613
|
+
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-full border border-destructive bg-destructive/10 px-3 py-2 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
|
|
614
|
+
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
|
|
615
|
+
</ErrorPrimitive.Root>
|
|
616
|
+
</MessagePrimitive.Error>
|
|
617
|
+
);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const AssistantMessage: FC = () => {
|
|
621
|
+
return (
|
|
622
|
+
<MessagePrimitive.Root
|
|
623
|
+
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
|
624
|
+
data-role="assistant"
|
|
625
|
+
>
|
|
626
|
+
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
|
627
|
+
<MessagePrimitive.Parts
|
|
628
|
+
components={{
|
|
629
|
+
Text: MarkdownText,
|
|
630
|
+
Reasoning,
|
|
631
|
+
ReasoningGroup,
|
|
632
|
+
tools: {
|
|
633
|
+
by_name: {
|
|
634
|
+
read: ReadTool,
|
|
635
|
+
edit: EditTool,
|
|
636
|
+
write: WriteTool,
|
|
637
|
+
bash: BashTool,
|
|
638
|
+
grep: GrepTool,
|
|
639
|
+
glob: GlobTool,
|
|
640
|
+
webSearch: WebSearchTool,
|
|
641
|
+
webFetch: WebFetchTool,
|
|
642
|
+
apply_patch: ApplyPatchTool,
|
|
643
|
+
ask_question: AskQuestionTool,
|
|
644
|
+
request_user_input: AskQuestionTool,
|
|
645
|
+
requestUserInput: AskQuestionTool,
|
|
646
|
+
},
|
|
647
|
+
Fallback: FallbackTool,
|
|
648
|
+
},
|
|
649
|
+
ToolGroup,
|
|
650
|
+
data: {
|
|
651
|
+
by_name: {
|
|
652
|
+
"opencode-step-start": OpenCodeDataPart,
|
|
653
|
+
"opencode-step-finish": OpenCodeDataPart,
|
|
654
|
+
"opencode-patch": OpenCodeDataPart,
|
|
655
|
+
"opencode-snapshot": OpenCodeDataPart,
|
|
656
|
+
"opencode-retry": OpenCodeDataPart,
|
|
657
|
+
"opencode-compaction": OpenCodeDataPart,
|
|
658
|
+
"opencode-agent": OpenCodeDataPart,
|
|
659
|
+
"opencode-subtask": OpenCodeDataPart,
|
|
660
|
+
},
|
|
661
|
+
Fallback: OpenCodeDataPart,
|
|
662
|
+
},
|
|
663
|
+
}}
|
|
664
|
+
/>
|
|
665
|
+
<MessageError />
|
|
666
|
+
</div>
|
|
667
|
+
|
|
668
|
+
<div className="aui-assistant-message-footer mt-1 ml-2 flex">
|
|
669
|
+
<BranchPicker />
|
|
670
|
+
<AssistantActionBar />
|
|
671
|
+
</div>
|
|
672
|
+
</MessagePrimitive.Root>
|
|
673
|
+
);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const AssistantActionBar: FC = () => {
|
|
677
|
+
return (
|
|
678
|
+
<ActionBarPrimitive.Root
|
|
679
|
+
hideWhenRunning
|
|
680
|
+
autohide="not-last"
|
|
681
|
+
autohideFloat="single-branch"
|
|
682
|
+
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
|
|
683
|
+
>
|
|
684
|
+
<ActionBarPrimitive.Copy asChild>
|
|
685
|
+
<TooltipIconButton tooltip="Copy">
|
|
686
|
+
<AuiIf condition={(s) => s.message.isCopied}>
|
|
687
|
+
<CheckIcon />
|
|
688
|
+
</AuiIf>
|
|
689
|
+
<AuiIf condition={(s) => !s.message.isCopied}>
|
|
690
|
+
<CopyIcon />
|
|
691
|
+
</AuiIf>
|
|
692
|
+
</TooltipIconButton>
|
|
693
|
+
</ActionBarPrimitive.Copy>
|
|
694
|
+
<ActionBarPrimitive.Reload asChild>
|
|
695
|
+
<TooltipIconButton tooltip="Refresh">
|
|
696
|
+
<RefreshCwIcon />
|
|
697
|
+
</TooltipIconButton>
|
|
698
|
+
</ActionBarPrimitive.Reload>
|
|
699
|
+
<ActionBarMorePrimitive.Root>
|
|
700
|
+
<ActionBarMorePrimitive.Trigger asChild>
|
|
701
|
+
<TooltipIconButton
|
|
702
|
+
tooltip="More"
|
|
703
|
+
className="data-[state=open]:bg-accent"
|
|
704
|
+
>
|
|
705
|
+
<MoreHorizontalIcon />
|
|
706
|
+
</TooltipIconButton>
|
|
707
|
+
</ActionBarMorePrimitive.Trigger>
|
|
708
|
+
<ActionBarMorePrimitive.Content
|
|
709
|
+
side="bottom"
|
|
710
|
+
align="start"
|
|
711
|
+
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
|
712
|
+
>
|
|
713
|
+
<ActionBarPrimitive.ExportMarkdown asChild>
|
|
714
|
+
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
|
715
|
+
<DownloadIcon className="size-4" />
|
|
716
|
+
Export as Markdown
|
|
717
|
+
</ActionBarMorePrimitive.Item>
|
|
718
|
+
</ActionBarPrimitive.ExportMarkdown>
|
|
719
|
+
</ActionBarMorePrimitive.Content>
|
|
720
|
+
</ActionBarMorePrimitive.Root>
|
|
721
|
+
</ActionBarPrimitive.Root>
|
|
722
|
+
);
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const UserMessage: FC = () => {
|
|
726
|
+
return (
|
|
727
|
+
<MessagePrimitive.Root
|
|
728
|
+
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
|
|
729
|
+
data-role="user"
|
|
730
|
+
>
|
|
731
|
+
<UserMessageAttachments />
|
|
732
|
+
|
|
733
|
+
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
|
|
734
|
+
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
|
735
|
+
<MessagePrimitive.Parts />
|
|
736
|
+
</div>
|
|
737
|
+
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2">
|
|
738
|
+
<UserActionBar />
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" />
|
|
743
|
+
</MessagePrimitive.Root>
|
|
744
|
+
);
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const UserActionBar: FC = () => {
|
|
748
|
+
return (
|
|
749
|
+
<ActionBarPrimitive.Root
|
|
750
|
+
hideWhenRunning
|
|
751
|
+
autohide="not-last"
|
|
752
|
+
className="aui-user-action-bar-root flex flex-col items-end"
|
|
753
|
+
>
|
|
754
|
+
<ActionBarPrimitive.Edit asChild>
|
|
755
|
+
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
|
756
|
+
<PencilIcon />
|
|
757
|
+
</TooltipIconButton>
|
|
758
|
+
</ActionBarPrimitive.Edit>
|
|
759
|
+
</ActionBarPrimitive.Root>
|
|
760
|
+
);
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const EditComposer: FC = () => {
|
|
764
|
+
return (
|
|
765
|
+
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
|
|
766
|
+
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
|
|
767
|
+
<ComposerPrimitive.Input
|
|
768
|
+
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
|
|
769
|
+
autoFocus
|
|
770
|
+
/>
|
|
771
|
+
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
|
|
772
|
+
<ComposerPrimitive.Cancel asChild>
|
|
773
|
+
<Button variant="ghost" size="sm">
|
|
774
|
+
Cancel
|
|
775
|
+
</Button>
|
|
776
|
+
</ComposerPrimitive.Cancel>
|
|
777
|
+
<ComposerPrimitive.Send asChild>
|
|
778
|
+
<Button size="sm">Update</Button>
|
|
779
|
+
</ComposerPrimitive.Send>
|
|
780
|
+
</div>
|
|
781
|
+
</ComposerPrimitive.Root>
|
|
782
|
+
</MessagePrimitive.Root>
|
|
783
|
+
);
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
|
|
787
|
+
className,
|
|
788
|
+
...rest
|
|
789
|
+
}) => {
|
|
790
|
+
return (
|
|
791
|
+
<BranchPickerPrimitive.Root
|
|
792
|
+
hideWhenSingleBranch
|
|
793
|
+
className={cn(
|
|
794
|
+
"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
|
|
795
|
+
className,
|
|
796
|
+
)}
|
|
797
|
+
{...rest}
|
|
798
|
+
>
|
|
799
|
+
<BranchPickerPrimitive.Previous asChild>
|
|
800
|
+
<TooltipIconButton tooltip="Previous">
|
|
801
|
+
<ChevronLeftIcon />
|
|
802
|
+
</TooltipIconButton>
|
|
803
|
+
</BranchPickerPrimitive.Previous>
|
|
804
|
+
<span className="aui-branch-picker-state font-medium">
|
|
805
|
+
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
|
806
|
+
</span>
|
|
807
|
+
<BranchPickerPrimitive.Next asChild>
|
|
808
|
+
<TooltipIconButton tooltip="Next">
|
|
809
|
+
<ChevronRightIcon />
|
|
810
|
+
</TooltipIconButton>
|
|
811
|
+
</BranchPickerPrimitive.Next>
|
|
812
|
+
</BranchPickerPrimitive.Root>
|
|
813
|
+
);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
## components/opencode-data-part.tsx
|
|
819
|
+
|
|
820
|
+
```tsx
|
|
821
|
+
"use client";
|
|
822
|
+
|
|
823
|
+
import { WrenchIcon } from "lucide-react";
|
|
824
|
+
import type { FC } from "react";
|
|
825
|
+
|
|
826
|
+
type OpenCodeDataPartProps = {
|
|
827
|
+
name: string;
|
|
828
|
+
data: unknown;
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const getSummary = (name: string, data: unknown) => {
|
|
832
|
+
if (
|
|
833
|
+
name === "opencode-unsupported-part" &&
|
|
834
|
+
typeof data === "object" &&
|
|
835
|
+
data !== null &&
|
|
836
|
+
"type" in data &&
|
|
837
|
+
typeof data.type === "string"
|
|
838
|
+
) {
|
|
839
|
+
return `Unsupported part: ${data.type}`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (name === "opencode-snapshot") return "Snapshot";
|
|
843
|
+
if (name === "opencode-retry") return "Retry";
|
|
844
|
+
if (name === "opencode-compaction") return "Compaction";
|
|
845
|
+
if (name === "opencode-agent") return "Agent event";
|
|
846
|
+
if (name === "opencode-subtask") return "Subtask";
|
|
847
|
+
|
|
848
|
+
return name.replace(/^opencode-/, "").replace(/-/g, " ");
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
export const OpenCodeDataPart: FC<OpenCodeDataPartProps> = ({ name, data }) => {
|
|
852
|
+
const shouldHide =
|
|
853
|
+
name === "opencode-step-start" ||
|
|
854
|
+
name === "opencode-step-finish" ||
|
|
855
|
+
name === "opencode-patch";
|
|
856
|
+
|
|
857
|
+
if (shouldHide) {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const summary = getSummary(name, data);
|
|
862
|
+
|
|
863
|
+
return (
|
|
864
|
+
<div className="my-3 flex w-min items-center gap-2 text-nowrap rounded-full border bg-muted/30 px-2 py-1 text-muted-foreground text-xs">
|
|
865
|
+
<WrenchIcon className="size-3" />
|
|
866
|
+
{summary}
|
|
867
|
+
</div>
|
|
868
|
+
);
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
## components/tools/opencode-permission-card.tsx
|
|
874
|
+
|
|
875
|
+
```tsx
|
|
876
|
+
"use client";
|
|
877
|
+
|
|
878
|
+
import { useState } from "react";
|
|
879
|
+
import {
|
|
880
|
+
type OpenCodePermissionRequest,
|
|
881
|
+
type OpenCodePermissionResponse,
|
|
882
|
+
useOpenCodeRuntimeExtras,
|
|
883
|
+
} from "@assistant-ui/react-opencode";
|
|
884
|
+
import {
|
|
885
|
+
CheckCircle2Icon,
|
|
886
|
+
LoaderIcon,
|
|
887
|
+
ShieldAlertIcon,
|
|
888
|
+
XCircleIcon,
|
|
889
|
+
} from "lucide-react";
|
|
890
|
+
import { Badge } from "@/components/ui/badge";
|
|
891
|
+
import { Button } from "@/components/ui/button";
|
|
892
|
+
import {
|
|
893
|
+
Card,
|
|
894
|
+
CardContent,
|
|
895
|
+
CardDescription,
|
|
896
|
+
CardFooter,
|
|
897
|
+
CardHeader,
|
|
898
|
+
CardTitle,
|
|
899
|
+
} from "@/components/ui/card";
|
|
900
|
+
|
|
901
|
+
const formatPermissionReply = (reply: OpenCodePermissionResponse) => {
|
|
902
|
+
switch (reply) {
|
|
903
|
+
case "always":
|
|
904
|
+
return "Always allow";
|
|
905
|
+
case "once":
|
|
906
|
+
return "Allowed once";
|
|
907
|
+
case "reject":
|
|
908
|
+
return "Rejected";
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
export const OpenCodePermissionCard = ({
|
|
913
|
+
request,
|
|
914
|
+
state,
|
|
915
|
+
reply,
|
|
916
|
+
}: {
|
|
917
|
+
request: OpenCodePermissionRequest;
|
|
918
|
+
state: "pending" | "resolved";
|
|
919
|
+
reply?: OpenCodePermissionResponse;
|
|
920
|
+
}) => {
|
|
921
|
+
const { replyToPermission } = useOpenCodeRuntimeExtras();
|
|
922
|
+
const [submittingReply, setSubmittingReply] =
|
|
923
|
+
useState<OpenCodePermissionResponse | null>(null);
|
|
924
|
+
const [error, setError] = useState<string | null>(null);
|
|
925
|
+
|
|
926
|
+
const submitReply = async (nextReply: OpenCodePermissionResponse) => {
|
|
927
|
+
setSubmittingReply(nextReply);
|
|
928
|
+
setError(null);
|
|
929
|
+
|
|
930
|
+
try {
|
|
931
|
+
await replyToPermission(request.id, nextReply);
|
|
932
|
+
} catch (err) {
|
|
933
|
+
setError(err instanceof Error ? err.message : "Failed to submit reply.");
|
|
934
|
+
setSubmittingReply(null);
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
const isPending = state === "pending";
|
|
939
|
+
|
|
940
|
+
return (
|
|
941
|
+
<Card className="gap-4 py-4">
|
|
942
|
+
<CardHeader className="px-4">
|
|
943
|
+
<div className="flex items-start gap-3">
|
|
944
|
+
<div className="rounded-md bg-amber-500/10 p-2 text-amber-700">
|
|
945
|
+
{isPending ? (
|
|
946
|
+
<ShieldAlertIcon className="size-4" />
|
|
947
|
+
) : reply === "reject" ? (
|
|
948
|
+
<XCircleIcon className="size-4" />
|
|
949
|
+
) : (
|
|
950
|
+
<CheckCircle2Icon className="size-4" />
|
|
951
|
+
)}
|
|
952
|
+
</div>
|
|
953
|
+
<div className="min-w-0 flex-1">
|
|
954
|
+
<CardTitle className="text-sm">
|
|
955
|
+
{isPending ? "Approval required" : formatPermissionReply(reply!)}
|
|
956
|
+
</CardTitle>
|
|
957
|
+
<CardDescription className="mt-1 text-xs">
|
|
958
|
+
{request.title ??
|
|
959
|
+
`OpenCode requested approval for ${request.toolName ?? request.permission}.`}
|
|
960
|
+
</CardDescription>
|
|
961
|
+
</div>
|
|
962
|
+
<Badge variant={isPending ? "secondary" : "outline"}>
|
|
963
|
+
{request.toolName ?? request.permission}
|
|
964
|
+
</Badge>
|
|
965
|
+
</div>
|
|
966
|
+
</CardHeader>
|
|
967
|
+
|
|
968
|
+
{(request.patterns.length > 0 || request.toolInput !== undefined) && (
|
|
969
|
+
<CardContent className="space-y-3 px-4">
|
|
970
|
+
{request.patterns.length > 0 ? (
|
|
971
|
+
<div className="flex flex-wrap gap-2">
|
|
972
|
+
{request.patterns.map((pattern) => (
|
|
973
|
+
<Badge key={pattern} variant="outline" className="font-mono">
|
|
974
|
+
{pattern}
|
|
975
|
+
</Badge>
|
|
976
|
+
))}
|
|
977
|
+
</div>
|
|
978
|
+
) : null}
|
|
979
|
+
{request.toolInput !== undefined ? (
|
|
980
|
+
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
|
981
|
+
{JSON.stringify(request.toolInput, null, 2)}
|
|
982
|
+
</pre>
|
|
983
|
+
) : null}
|
|
984
|
+
</CardContent>
|
|
985
|
+
)}
|
|
986
|
+
|
|
987
|
+
{isPending ? (
|
|
988
|
+
<CardFooter className="flex flex-wrap gap-2 px-4">
|
|
989
|
+
<Button
|
|
990
|
+
size="sm"
|
|
991
|
+
onClick={() => submitReply("once")}
|
|
992
|
+
disabled={submittingReply !== null}
|
|
993
|
+
>
|
|
994
|
+
{submittingReply === "once" && (
|
|
995
|
+
<LoaderIcon className="size-4 animate-spin" />
|
|
996
|
+
)}
|
|
997
|
+
Allow once
|
|
998
|
+
</Button>
|
|
999
|
+
<Button
|
|
1000
|
+
size="sm"
|
|
1001
|
+
variant="secondary"
|
|
1002
|
+
onClick={() => submitReply("always")}
|
|
1003
|
+
disabled={submittingReply !== null}
|
|
1004
|
+
>
|
|
1005
|
+
{submittingReply === "always" && (
|
|
1006
|
+
<LoaderIcon className="size-4 animate-spin" />
|
|
1007
|
+
)}
|
|
1008
|
+
Always allow
|
|
1009
|
+
</Button>
|
|
1010
|
+
<Button
|
|
1011
|
+
size="sm"
|
|
1012
|
+
variant="outline"
|
|
1013
|
+
onClick={() => submitReply("reject")}
|
|
1014
|
+
disabled={submittingReply !== null}
|
|
1015
|
+
>
|
|
1016
|
+
{submittingReply === "reject" && (
|
|
1017
|
+
<LoaderIcon className="size-4 animate-spin" />
|
|
1018
|
+
)}
|
|
1019
|
+
Reject
|
|
1020
|
+
</Button>
|
|
1021
|
+
{error ? (
|
|
1022
|
+
<p className="w-full text-destructive text-xs">{error}</p>
|
|
1023
|
+
) : null}
|
|
1024
|
+
</CardFooter>
|
|
1025
|
+
) : null}
|
|
1026
|
+
</Card>
|
|
1027
|
+
);
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
## components/tools/opencode-question-card.tsx
|
|
1033
|
+
|
|
1034
|
+
```tsx
|
|
1035
|
+
"use client";
|
|
1036
|
+
|
|
1037
|
+
import { useMemo, useState } from "react";
|
|
1038
|
+
import {
|
|
1039
|
+
type OpenCodeQuestionRequest,
|
|
1040
|
+
type QuestionAnswer,
|
|
1041
|
+
useOpenCodeRuntimeExtras,
|
|
1042
|
+
} from "@assistant-ui/react-opencode";
|
|
1043
|
+
import {
|
|
1044
|
+
AlertCircleIcon,
|
|
1045
|
+
CheckCircle2Icon,
|
|
1046
|
+
CircleHelpIcon,
|
|
1047
|
+
LoaderIcon,
|
|
1048
|
+
XCircleIcon,
|
|
1049
|
+
} from "lucide-react";
|
|
1050
|
+
import { Badge } from "@/components/ui/badge";
|
|
1051
|
+
import { Button } from "@/components/ui/button";
|
|
1052
|
+
import {
|
|
1053
|
+
Card,
|
|
1054
|
+
CardContent,
|
|
1055
|
+
CardDescription,
|
|
1056
|
+
CardFooter,
|
|
1057
|
+
CardHeader,
|
|
1058
|
+
CardTitle,
|
|
1059
|
+
} from "@/components/ui/card";
|
|
1060
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
1061
|
+
import { Label } from "@/components/ui/label";
|
|
1062
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
1063
|
+
import { cn } from "@/lib/utils";
|
|
1064
|
+
|
|
1065
|
+
type PendingQuestionState = {
|
|
1066
|
+
selectedLabels: string[];
|
|
1067
|
+
customAnswer: string;
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
const buildEmptyQuestionState = (
|
|
1071
|
+
request: OpenCodeQuestionRequest,
|
|
1072
|
+
): PendingQuestionState[] =>
|
|
1073
|
+
request.questions.map(() => ({
|
|
1074
|
+
selectedLabels: [],
|
|
1075
|
+
customAnswer: "",
|
|
1076
|
+
}));
|
|
1077
|
+
|
|
1078
|
+
const encodeQuestionAnswers = (
|
|
1079
|
+
request: OpenCodeQuestionRequest,
|
|
1080
|
+
formState: PendingQuestionState[],
|
|
1081
|
+
): QuestionAnswer[] =>
|
|
1082
|
+
request.questions.map((question, index) => {
|
|
1083
|
+
const current = formState[index];
|
|
1084
|
+
if (!current) return [];
|
|
1085
|
+
|
|
1086
|
+
const answers = [...current.selectedLabels];
|
|
1087
|
+
const customValue = current.customAnswer.trim();
|
|
1088
|
+
const customAllowed = question.custom !== false;
|
|
1089
|
+
|
|
1090
|
+
if (customAllowed && customValue) {
|
|
1091
|
+
answers.push(`Other: ${customValue}`);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return answers;
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
export const getQuestionSummary = (request?: OpenCodeQuestionRequest) => {
|
|
1098
|
+
if (!request || request.questions.length === 0) return "Waiting for input";
|
|
1099
|
+
if (request.questions.length === 1) {
|
|
1100
|
+
return request.questions[0]?.header || "1 question";
|
|
1101
|
+
}
|
|
1102
|
+
return `${request.questions.length} questions`;
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
export const OpenCodeQuestionCard = ({
|
|
1106
|
+
request,
|
|
1107
|
+
state,
|
|
1108
|
+
answers,
|
|
1109
|
+
}: {
|
|
1110
|
+
request: OpenCodeQuestionRequest;
|
|
1111
|
+
state: "pending" | "answered" | "rejected";
|
|
1112
|
+
answers?: readonly QuestionAnswer[];
|
|
1113
|
+
}) => {
|
|
1114
|
+
const { replyToQuestion, rejectQuestion } = useOpenCodeRuntimeExtras();
|
|
1115
|
+
const [formState, setFormState] = useState(() =>
|
|
1116
|
+
buildEmptyQuestionState(request),
|
|
1117
|
+
);
|
|
1118
|
+
const [submissionState, setSubmissionState] = useState<
|
|
1119
|
+
"reply" | "reject" | null
|
|
1120
|
+
>(null);
|
|
1121
|
+
const [error, setError] = useState<string | null>(null);
|
|
1122
|
+
const isPending = state === "pending";
|
|
1123
|
+
|
|
1124
|
+
const encodedAnswers = useMemo(
|
|
1125
|
+
() => encodeQuestionAnswers(request, formState),
|
|
1126
|
+
[formState, request],
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
const canSubmit =
|
|
1130
|
+
isPending && encodedAnswers.every((answer) => answer.length > 0);
|
|
1131
|
+
|
|
1132
|
+
const updateQuestionState = (
|
|
1133
|
+
index: number,
|
|
1134
|
+
updater: (current: PendingQuestionState) => PendingQuestionState,
|
|
1135
|
+
) => {
|
|
1136
|
+
setFormState((current) =>
|
|
1137
|
+
current.map((item, itemIndex) =>
|
|
1138
|
+
itemIndex === index ? updater(item) : item,
|
|
1139
|
+
),
|
|
1140
|
+
);
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
const submitAnswers = async () => {
|
|
1144
|
+
if (!canSubmit) return;
|
|
1145
|
+
setSubmissionState("reply");
|
|
1146
|
+
setError(null);
|
|
1147
|
+
|
|
1148
|
+
try {
|
|
1149
|
+
await replyToQuestion(request.id, encodedAnswers);
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
setError(
|
|
1152
|
+
err instanceof Error ? err.message : "Failed to submit answers.",
|
|
1153
|
+
);
|
|
1154
|
+
setSubmissionState(null);
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
const rejectRequest = async () => {
|
|
1159
|
+
setSubmissionState("reject");
|
|
1160
|
+
setError(null);
|
|
1161
|
+
|
|
1162
|
+
try {
|
|
1163
|
+
await rejectQuestion(request.id);
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
setError(
|
|
1166
|
+
err instanceof Error ? err.message : "Failed to reject request.",
|
|
1167
|
+
);
|
|
1168
|
+
setSubmissionState(null);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
return (
|
|
1173
|
+
<Card className="gap-4 py-4">
|
|
1174
|
+
<CardHeader className="px-4">
|
|
1175
|
+
<div className="flex items-start gap-3">
|
|
1176
|
+
<div
|
|
1177
|
+
className={cn(
|
|
1178
|
+
"rounded-md p-2",
|
|
1179
|
+
isPending
|
|
1180
|
+
? "bg-sky-500/10 text-sky-700"
|
|
1181
|
+
: state === "rejected"
|
|
1182
|
+
? "bg-muted text-muted-foreground"
|
|
1183
|
+
: "bg-emerald-500/10 text-emerald-700",
|
|
1184
|
+
)}
|
|
1185
|
+
>
|
|
1186
|
+
{isPending ? (
|
|
1187
|
+
<CircleHelpIcon className="size-4" />
|
|
1188
|
+
) : state === "rejected" ? (
|
|
1189
|
+
<XCircleIcon className="size-4" />
|
|
1190
|
+
) : (
|
|
1191
|
+
<CheckCircle2Icon className="size-4" />
|
|
1192
|
+
)}
|
|
1193
|
+
</div>
|
|
1194
|
+
<div className="min-w-0 flex-1">
|
|
1195
|
+
<CardTitle className="text-sm">
|
|
1196
|
+
{isPending
|
|
1197
|
+
? "Answer required"
|
|
1198
|
+
: state === "rejected"
|
|
1199
|
+
? "Question dismissed"
|
|
1200
|
+
: "Answers submitted"}
|
|
1201
|
+
</CardTitle>
|
|
1202
|
+
<CardDescription className="mt-1 text-xs">
|
|
1203
|
+
{getQuestionSummary(request)}
|
|
1204
|
+
</CardDescription>
|
|
1205
|
+
</div>
|
|
1206
|
+
<Badge variant={isPending ? "secondary" : "outline"}>
|
|
1207
|
+
{request.questions.length === 1
|
|
1208
|
+
? "1 prompt"
|
|
1209
|
+
: `${request.questions.length} prompts`}
|
|
1210
|
+
</Badge>
|
|
1211
|
+
</div>
|
|
1212
|
+
</CardHeader>
|
|
1213
|
+
|
|
1214
|
+
<CardContent className="space-y-4 px-4">
|
|
1215
|
+
{request.questions.map((question, index) => {
|
|
1216
|
+
const current = formState[index] ?? {
|
|
1217
|
+
selectedLabels: [],
|
|
1218
|
+
customAnswer: "",
|
|
1219
|
+
};
|
|
1220
|
+
const answeredValues = answers?.[index] ?? [];
|
|
1221
|
+
const customAllowed = question.custom !== false;
|
|
1222
|
+
|
|
1223
|
+
return (
|
|
1224
|
+
<section key={`${request.id}-${index}`} className="space-y-3">
|
|
1225
|
+
<div className="space-y-1">
|
|
1226
|
+
<p className="font-medium text-sm">{question.header}</p>
|
|
1227
|
+
<p className="text-muted-foreground text-xs">
|
|
1228
|
+
{question.question}
|
|
1229
|
+
</p>
|
|
1230
|
+
</div>
|
|
1231
|
+
|
|
1232
|
+
{isPending ? (
|
|
1233
|
+
<>
|
|
1234
|
+
{question.options.length > 0 ? (
|
|
1235
|
+
question.multiple ? (
|
|
1236
|
+
<div className="space-y-2">
|
|
1237
|
+
{question.options.map((option) => {
|
|
1238
|
+
const checked = current.selectedLabels.includes(
|
|
1239
|
+
option.label,
|
|
1240
|
+
);
|
|
1241
|
+
const optionId = `${request.id}-${index}-${option.label}`;
|
|
1242
|
+
|
|
1243
|
+
return (
|
|
1244
|
+
<div
|
|
1245
|
+
key={option.label}
|
|
1246
|
+
className="flex items-start gap-3 rounded-lg border p-3 text-sm"
|
|
1247
|
+
>
|
|
1248
|
+
<Checkbox
|
|
1249
|
+
id={optionId}
|
|
1250
|
+
checked={checked}
|
|
1251
|
+
onCheckedChange={(nextChecked) => {
|
|
1252
|
+
updateQuestionState(index, (item) => ({
|
|
1253
|
+
...item,
|
|
1254
|
+
selectedLabels:
|
|
1255
|
+
nextChecked === true
|
|
1256
|
+
? [...item.selectedLabels, option.label]
|
|
1257
|
+
: item.selectedLabels.filter(
|
|
1258
|
+
(label) => label !== option.label,
|
|
1259
|
+
),
|
|
1260
|
+
}));
|
|
1261
|
+
}}
|
|
1262
|
+
/>
|
|
1263
|
+
<div className="space-y-1">
|
|
1264
|
+
<Label
|
|
1265
|
+
htmlFor={optionId}
|
|
1266
|
+
className="font-medium text-sm"
|
|
1267
|
+
>
|
|
1268
|
+
{option.label}
|
|
1269
|
+
</Label>
|
|
1270
|
+
<p className="text-muted-foreground text-xs">
|
|
1271
|
+
{option.description}
|
|
1272
|
+
</p>
|
|
1273
|
+
</div>
|
|
1274
|
+
</div>
|
|
1275
|
+
);
|
|
1276
|
+
})}
|
|
1277
|
+
</div>
|
|
1278
|
+
) : (
|
|
1279
|
+
<div className="flex flex-wrap gap-2">
|
|
1280
|
+
{question.options.map((option) => {
|
|
1281
|
+
const selected = current.selectedLabels.includes(
|
|
1282
|
+
option.label,
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1285
|
+
return (
|
|
1286
|
+
<Button
|
|
1287
|
+
key={option.label}
|
|
1288
|
+
type="button"
|
|
1289
|
+
size="sm"
|
|
1290
|
+
variant={selected ? "default" : "outline"}
|
|
1291
|
+
onClick={() => {
|
|
1292
|
+
updateQuestionState(index, (item) => ({
|
|
1293
|
+
...item,
|
|
1294
|
+
selectedLabels: [option.label],
|
|
1295
|
+
customAnswer: "",
|
|
1296
|
+
}));
|
|
1297
|
+
}}
|
|
1298
|
+
>
|
|
1299
|
+
{option.label}
|
|
1300
|
+
</Button>
|
|
1301
|
+
);
|
|
1302
|
+
})}
|
|
1303
|
+
</div>
|
|
1304
|
+
)
|
|
1305
|
+
) : null}
|
|
1306
|
+
|
|
1307
|
+
{customAllowed ? (
|
|
1308
|
+
<div className="space-y-2">
|
|
1309
|
+
<Label htmlFor={`${request.id}-${index}-custom`}>
|
|
1310
|
+
Other
|
|
1311
|
+
</Label>
|
|
1312
|
+
<Textarea
|
|
1313
|
+
id={`${request.id}-${index}-custom`}
|
|
1314
|
+
value={current.customAnswer}
|
|
1315
|
+
onChange={(event) => {
|
|
1316
|
+
const nextValue = event.target.value;
|
|
1317
|
+
updateQuestionState(index, (item) => ({
|
|
1318
|
+
...item,
|
|
1319
|
+
customAnswer: nextValue,
|
|
1320
|
+
selectedLabels:
|
|
1321
|
+
question.multiple || nextValue.trim().length === 0
|
|
1322
|
+
? item.selectedLabels
|
|
1323
|
+
: [],
|
|
1324
|
+
}));
|
|
1325
|
+
}}
|
|
1326
|
+
placeholder="Add your answer"
|
|
1327
|
+
rows={3}
|
|
1328
|
+
/>
|
|
1329
|
+
</div>
|
|
1330
|
+
) : null}
|
|
1331
|
+
</>
|
|
1332
|
+
) : state === "rejected" ? (
|
|
1333
|
+
<p className="text-muted-foreground text-xs">
|
|
1334
|
+
No answer was submitted.
|
|
1335
|
+
</p>
|
|
1336
|
+
) : (
|
|
1337
|
+
<div className="flex flex-wrap gap-2">
|
|
1338
|
+
{answeredValues.map((answer) => (
|
|
1339
|
+
<Badge key={answer} variant="outline">
|
|
1340
|
+
{answer}
|
|
1341
|
+
</Badge>
|
|
1342
|
+
))}
|
|
1343
|
+
</div>
|
|
1344
|
+
)}
|
|
1345
|
+
</section>
|
|
1346
|
+
);
|
|
1347
|
+
})}
|
|
1348
|
+
</CardContent>
|
|
1349
|
+
|
|
1350
|
+
{isPending ? (
|
|
1351
|
+
<CardFooter className="flex flex-wrap gap-2 px-4">
|
|
1352
|
+
<Button
|
|
1353
|
+
size="sm"
|
|
1354
|
+
onClick={submitAnswers}
|
|
1355
|
+
disabled={!canSubmit || submissionState !== null}
|
|
1356
|
+
>
|
|
1357
|
+
{submissionState === "reply" && (
|
|
1358
|
+
<LoaderIcon className="size-4 animate-spin" />
|
|
1359
|
+
)}
|
|
1360
|
+
Submit answers
|
|
1361
|
+
</Button>
|
|
1362
|
+
<Button
|
|
1363
|
+
size="sm"
|
|
1364
|
+
variant="outline"
|
|
1365
|
+
onClick={rejectRequest}
|
|
1366
|
+
disabled={submissionState !== null}
|
|
1367
|
+
>
|
|
1368
|
+
{submissionState === "reject" && (
|
|
1369
|
+
<LoaderIcon className="size-4 animate-spin" />
|
|
1370
|
+
)}
|
|
1371
|
+
Dismiss
|
|
1372
|
+
</Button>
|
|
1373
|
+
{error ? (
|
|
1374
|
+
<div className="flex w-full items-center gap-2 text-destructive text-xs">
|
|
1375
|
+
<AlertCircleIcon className="size-3.5" />
|
|
1376
|
+
<span>{error}</span>
|
|
1377
|
+
</div>
|
|
1378
|
+
) : null}
|
|
1379
|
+
</CardFooter>
|
|
1380
|
+
) : null}
|
|
1381
|
+
</Card>
|
|
1382
|
+
);
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
## components/tools/opencode-tool-interactions.tsx
|
|
1388
|
+
|
|
1389
|
+
```tsx
|
|
1390
|
+
"use client";
|
|
1391
|
+
|
|
1392
|
+
import { memo, useMemo } from "react";
|
|
1393
|
+
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
|
1394
|
+
import {
|
|
1395
|
+
type OpenCodePermissionRequest,
|
|
1396
|
+
type OpenCodePermissionResponse,
|
|
1397
|
+
type OpenCodeQuestionRequest,
|
|
1398
|
+
type QuestionAnswer,
|
|
1399
|
+
useOpenCodeThreadState,
|
|
1400
|
+
} from "@assistant-ui/react-opencode";
|
|
1401
|
+
import {
|
|
1402
|
+
CheckCircle2Icon,
|
|
1403
|
+
CircleHelpIcon,
|
|
1404
|
+
LoaderIcon,
|
|
1405
|
+
XCircleIcon,
|
|
1406
|
+
} from "lucide-react";
|
|
1407
|
+
import { OpenCodePermissionCard } from "@/components/tools/opencode-permission-card";
|
|
1408
|
+
import {
|
|
1409
|
+
OpenCodeQuestionCard,
|
|
1410
|
+
getQuestionSummary,
|
|
1411
|
+
} from "@/components/tools/opencode-question-card";
|
|
1412
|
+
|
|
1413
|
+
type ToolPermissionInteraction = {
|
|
1414
|
+
request: OpenCodePermissionRequest;
|
|
1415
|
+
state: "pending" | "resolved";
|
|
1416
|
+
reply?: OpenCodePermissionResponse;
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
type ToolQuestionInteraction = {
|
|
1420
|
+
request: OpenCodeQuestionRequest;
|
|
1421
|
+
state: "pending" | "answered" | "rejected";
|
|
1422
|
+
answers?: readonly QuestionAnswer[];
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
const ASK_QUESTION_TOOL_NAMES = [
|
|
1426
|
+
"ask_question",
|
|
1427
|
+
"request_user_input",
|
|
1428
|
+
"requestUserInput",
|
|
1429
|
+
] as const;
|
|
1430
|
+
|
|
1431
|
+
const useOpenCodeToolInteractions = (toolCallId: string) => {
|
|
1432
|
+
const interactions = useOpenCodeThreadState((state) => state.interactions);
|
|
1433
|
+
|
|
1434
|
+
return useMemo(() => {
|
|
1435
|
+
const permissions: ToolPermissionInteraction[] = [];
|
|
1436
|
+
const questions: ToolQuestionInteraction[] = [];
|
|
1437
|
+
|
|
1438
|
+
for (const request of Object.values(interactions.permissions.pending)) {
|
|
1439
|
+
if (request.tool?.callID === toolCallId) {
|
|
1440
|
+
permissions.push({ request, state: "pending" });
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
for (const resolved of Object.values(interactions.permissions.resolved)) {
|
|
1445
|
+
if (resolved.request.tool?.callID === toolCallId) {
|
|
1446
|
+
permissions.push({
|
|
1447
|
+
request: resolved.request,
|
|
1448
|
+
state: "resolved",
|
|
1449
|
+
reply: resolved.reply,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
for (const request of Object.values(interactions.questions.pending)) {
|
|
1455
|
+
if (request.tool?.callID === toolCallId) {
|
|
1456
|
+
questions.push({ request, state: "pending" });
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
for (const answered of Object.values(interactions.questions.answered)) {
|
|
1461
|
+
if (answered.request.tool?.callID === toolCallId) {
|
|
1462
|
+
questions.push({
|
|
1463
|
+
request: answered.request,
|
|
1464
|
+
state: "answered",
|
|
1465
|
+
answers: answered.answers,
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
for (const rejected of Object.values(interactions.questions.rejected)) {
|
|
1471
|
+
if (rejected.request.tool?.callID === toolCallId) {
|
|
1472
|
+
questions.push({
|
|
1473
|
+
request: rejected.request,
|
|
1474
|
+
state: "rejected",
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
return { permissions, questions };
|
|
1480
|
+
}, [interactions, toolCallId]);
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
const ToolInteractionStack = ({ toolCallId }: { toolCallId: string }) => {
|
|
1484
|
+
const { permissions, questions } = useOpenCodeToolInteractions(toolCallId);
|
|
1485
|
+
|
|
1486
|
+
if (permissions.length === 0 && questions.length === 0) {
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
return (
|
|
1491
|
+
<div className="space-y-3">
|
|
1492
|
+
{permissions.map((permission) => (
|
|
1493
|
+
<OpenCodePermissionCard
|
|
1494
|
+
key={`${permission.request.id}-${permission.state}`}
|
|
1495
|
+
request={permission.request}
|
|
1496
|
+
state={permission.state}
|
|
1497
|
+
{...(permission.reply ? { reply: permission.reply } : {})}
|
|
1498
|
+
/>
|
|
1499
|
+
))}
|
|
1500
|
+
{questions.map((question) => (
|
|
1501
|
+
<OpenCodeQuestionCard
|
|
1502
|
+
key={`${question.request.id}-${question.state}`}
|
|
1503
|
+
request={question.request}
|
|
1504
|
+
state={question.state}
|
|
1505
|
+
{...(question.answers ? { answers: question.answers } : {})}
|
|
1506
|
+
/>
|
|
1507
|
+
))}
|
|
1508
|
+
</div>
|
|
1509
|
+
);
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
export const withOpenCodeToolInteractions = <TArgs = any, TResult = any>(
|
|
1513
|
+
BaseComponent: ToolCallMessagePartComponent<TArgs, TResult>,
|
|
1514
|
+
): ToolCallMessagePartComponent<TArgs, TResult> => {
|
|
1515
|
+
const WrappedComponent: ToolCallMessagePartComponent<TArgs, TResult> = memo(
|
|
1516
|
+
(props) => {
|
|
1517
|
+
return (
|
|
1518
|
+
<div className="space-y-3">
|
|
1519
|
+
<BaseComponent {...props} />
|
|
1520
|
+
<ToolInteractionStack toolCallId={props.toolCallId} />
|
|
1521
|
+
</div>
|
|
1522
|
+
);
|
|
1523
|
+
},
|
|
1524
|
+
);
|
|
1525
|
+
|
|
1526
|
+
WrappedComponent.displayName = `withOpenCodeToolInteractions(${
|
|
1527
|
+
BaseComponent.displayName || BaseComponent.name || "Tool"
|
|
1528
|
+
})`;
|
|
1529
|
+
|
|
1530
|
+
return WrappedComponent;
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
export const AskQuestionInline: ToolCallMessagePartComponent = memo(
|
|
1534
|
+
({ toolCallId, toolName, status }) => {
|
|
1535
|
+
const { questions } = useOpenCodeToolInteractions(toolCallId);
|
|
1536
|
+
const primaryQuestion = questions[0]?.request;
|
|
1537
|
+
const label = getQuestionSummary(primaryQuestion);
|
|
1538
|
+
const statusType = status?.type ?? "complete";
|
|
1539
|
+
|
|
1540
|
+
return (
|
|
1541
|
+
<div className="flex items-center gap-2 py-0.5 text-muted-foreground text-sm">
|
|
1542
|
+
{statusType === "requires-action" ? (
|
|
1543
|
+
<CircleHelpIcon className="size-3.5 shrink-0" />
|
|
1544
|
+
) : statusType === "running" ? (
|
|
1545
|
+
<LoaderIcon className="size-3.5 shrink-0 animate-spin" />
|
|
1546
|
+
) : statusType === "incomplete" ? (
|
|
1547
|
+
<XCircleIcon className="size-3.5 shrink-0 text-destructive" />
|
|
1548
|
+
) : (
|
|
1549
|
+
<CheckCircle2Icon className="size-3.5 shrink-0" />
|
|
1550
|
+
)}
|
|
1551
|
+
|
|
1552
|
+
<span className="flex items-center gap-1.5 truncate">
|
|
1553
|
+
<span className="font-medium">
|
|
1554
|
+
{ASK_QUESTION_TOOL_NAMES.includes(
|
|
1555
|
+
toolName as (typeof ASK_QUESTION_TOOL_NAMES)[number],
|
|
1556
|
+
)
|
|
1557
|
+
? "ask_question"
|
|
1558
|
+
: toolName}
|
|
1559
|
+
</span>
|
|
1560
|
+
<span className="opacity-60">{label}</span>
|
|
1561
|
+
</span>
|
|
1562
|
+
</div>
|
|
1563
|
+
);
|
|
1564
|
+
},
|
|
1565
|
+
);
|
|
1566
|
+
AskQuestionInline.displayName = "AskQuestionInline";
|
|
1567
|
+
|
|
1568
|
+
```
|
|
1569
|
+
|
|
1570
|
+
## components/tools/reasoning-ghost.tsx
|
|
1571
|
+
|
|
1572
|
+
```tsx
|
|
1573
|
+
"use client";
|
|
1574
|
+
|
|
1575
|
+
import { memo } from "react";
|
|
1576
|
+
import { BrainIcon } from "lucide-react";
|
|
1577
|
+
import { useAuiState, type ReasoningGroupComponent } from "@assistant-ui/react";
|
|
1578
|
+
import { cn } from "@/lib/utils";
|
|
1579
|
+
|
|
1580
|
+
const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
|
1581
|
+
children,
|
|
1582
|
+
startIndex,
|
|
1583
|
+
endIndex,
|
|
1584
|
+
}) => {
|
|
1585
|
+
const isMultiLine = useAuiState((s) => {
|
|
1586
|
+
let totalLength = 0;
|
|
1587
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
1588
|
+
const part = s.message.parts[i];
|
|
1589
|
+
if (part?.type === "reasoning") {
|
|
1590
|
+
if (part.text.includes("\n\n")) return true;
|
|
1591
|
+
totalLength += part.text.length;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return totalLength > 120;
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
const textAfter = useAuiState((s) => {
|
|
1598
|
+
for (let i = endIndex + 1; i < s.message.parts.length; i++) {
|
|
1599
|
+
const type = s.message.parts[i]?.type;
|
|
1600
|
+
if (type === "data") continue;
|
|
1601
|
+
return type === "text";
|
|
1602
|
+
}
|
|
1603
|
+
return false;
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
return (
|
|
1607
|
+
<div
|
|
1608
|
+
data-slot="reasoning-group"
|
|
1609
|
+
className={cn(
|
|
1610
|
+
"mt-4 mb-2 flex items-start gap-2 text-muted-foreground text-sm leading-relaxed first:mt-0",
|
|
1611
|
+
isMultiLine && "mt-6 mb-4",
|
|
1612
|
+
textAfter && "mb-4",
|
|
1613
|
+
)}
|
|
1614
|
+
>
|
|
1615
|
+
<BrainIcon className="mt-[3.25px] size-3.5 shrink-0" />
|
|
1616
|
+
<div className="space-y-4.5">{children}</div>
|
|
1617
|
+
</div>
|
|
1618
|
+
);
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
export const ReasoningGroup = memo(ReasoningGroupImpl);
|
|
1622
|
+
ReasoningGroup.displayName = "ReasoningGroup";
|
|
1623
|
+
|
|
1624
|
+
```
|
|
1625
|
+
|
|
1626
|
+
## components/tools/tool-group.tsx
|
|
1627
|
+
|
|
1628
|
+
```tsx
|
|
1629
|
+
import { cn } from "@/lib/utils";
|
|
1630
|
+
import { useAuiState } from "@assistant-ui/react";
|
|
1631
|
+
import { memo, type FC, type PropsWithChildren } from "react";
|
|
1632
|
+
|
|
1633
|
+
// ── ToolGroup — lightweight wrapper for spacing ─────────────────────────
|
|
1634
|
+
export const ToolGroupImpl: FC<
|
|
1635
|
+
PropsWithChildren<{ startIndex: number; endIndex: number }>
|
|
1636
|
+
> = ({ children, startIndex, endIndex }) => {
|
|
1637
|
+
const textBefore = useAuiState((s) => {
|
|
1638
|
+
for (let i = startIndex - 1; i >= 0; i--) {
|
|
1639
|
+
const type = s.message.parts[i]?.type;
|
|
1640
|
+
if (type === "data") continue;
|
|
1641
|
+
return type === "text";
|
|
1642
|
+
}
|
|
1643
|
+
return false;
|
|
1644
|
+
});
|
|
1645
|
+
const textAfter = useAuiState((s) => {
|
|
1646
|
+
for (let i = endIndex + 1; i < s.message.parts.length; i++) {
|
|
1647
|
+
const type = s.message.parts[i]?.type;
|
|
1648
|
+
if (type === "data") continue;
|
|
1649
|
+
return type === "text";
|
|
1650
|
+
}
|
|
1651
|
+
return false;
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
return (
|
|
1655
|
+
<div
|
|
1656
|
+
data-slot="tool-group"
|
|
1657
|
+
className={cn(textBefore && "mt-4", textAfter && "mb-4")}
|
|
1658
|
+
>
|
|
1659
|
+
{children}
|
|
1660
|
+
</div>
|
|
1661
|
+
);
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
export const ToolGroup = memo(ToolGroupImpl);
|
|
1665
|
+
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
## components/tools/tool-ui-apply-patch.tsx
|
|
1669
|
+
|
|
1670
|
+
```tsx
|
|
1671
|
+
"use client";
|
|
1672
|
+
|
|
1673
|
+
import { memo, useMemo, useState } from "react";
|
|
1674
|
+
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
|
1675
|
+
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
|
1676
|
+
import {
|
|
1677
|
+
Collapsible,
|
|
1678
|
+
CollapsibleContent,
|
|
1679
|
+
CollapsibleTrigger,
|
|
1680
|
+
} from "@/components/ui/collapsible";
|
|
1681
|
+
import { cn } from "@/lib/utils";
|
|
1682
|
+
import { PatchDiff } from "@pierre/diffs/react";
|
|
1683
|
+
import {
|
|
1684
|
+
ToolStatusIcon,
|
|
1685
|
+
getPatchInfo,
|
|
1686
|
+
isCancelledToolStatus,
|
|
1687
|
+
str,
|
|
1688
|
+
} from "@/components/tools/tool-ui-shared";
|
|
1689
|
+
|
|
1690
|
+
// ── Patch format conversion ──────────────────────────────────────────────
|
|
1691
|
+
// Claude Code's apply_patch uses a custom "v2" format:
|
|
1692
|
+
// *** Begin Patch
|
|
1693
|
+
// *** Update File: path/to/file.ts
|
|
1694
|
+
// @@ context search text @@
|
|
1695
|
+
// context line
|
|
1696
|
+
// -removed
|
|
1697
|
+
// +added
|
|
1698
|
+
// *** End Patch
|
|
1699
|
+
//
|
|
1700
|
+
// The @@ markers are context-search strings, NOT standard unified diff
|
|
1701
|
+
// hunk headers. We convert to proper unified diff for @pierre/diffs.
|
|
1702
|
+
|
|
1703
|
+
interface PatchBlock {
|
|
1704
|
+
type: string;
|
|
1705
|
+
path: string;
|
|
1706
|
+
body: string;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function parseClaudePatchBlocks(patchText: string): PatchBlock[] {
|
|
1710
|
+
// Strip wrapper lines
|
|
1711
|
+
const cleaned = patchText
|
|
1712
|
+
.replace(/^\*\*\*\s*Begin Patch\s*$/gm, "")
|
|
1713
|
+
.replace(/^\*\*\*\s*End Patch\s*$/gm, "")
|
|
1714
|
+
.trim();
|
|
1715
|
+
|
|
1716
|
+
const headerRegex = /^\*\*\*\s+(Update|Add|Delete)\s+File:\s+(.+)$/gm;
|
|
1717
|
+
const blocks: PatchBlock[] = [];
|
|
1718
|
+
let lastIndex = 0;
|
|
1719
|
+
for (const match of cleaned.matchAll(headerRegex)) {
|
|
1720
|
+
if (blocks.length > 0) {
|
|
1721
|
+
blocks[blocks.length - 1]!.body = cleaned.slice(lastIndex, match.index);
|
|
1722
|
+
}
|
|
1723
|
+
blocks.push({ type: match[1]!, path: match[2]!.trim(), body: "" });
|
|
1724
|
+
lastIndex = match.index + match[0].length;
|
|
1725
|
+
}
|
|
1726
|
+
if (blocks.length > 0) {
|
|
1727
|
+
blocks[blocks.length - 1]!.body = cleaned.slice(lastIndex);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
return blocks;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function buildHunkHeader(
|
|
1734
|
+
lines: string[],
|
|
1735
|
+
oldStart: number,
|
|
1736
|
+
newStart: number,
|
|
1737
|
+
): { header: string; oldCount: number; newCount: number } {
|
|
1738
|
+
let oldCount = 0;
|
|
1739
|
+
let newCount = 0;
|
|
1740
|
+
for (const line of lines) {
|
|
1741
|
+
if (line.startsWith("+")) newCount++;
|
|
1742
|
+
else if (line.startsWith("-")) oldCount++;
|
|
1743
|
+
else {
|
|
1744
|
+
// context line (space prefix or empty)
|
|
1745
|
+
oldCount++;
|
|
1746
|
+
newCount++;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return {
|
|
1750
|
+
header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
|
|
1751
|
+
oldCount,
|
|
1752
|
+
newCount,
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function claudeBlockToUnifiedDiff(block: PatchBlock): string {
|
|
1757
|
+
const { type, path, body } = block;
|
|
1758
|
+
const bodyLines = body
|
|
1759
|
+
.split("\n")
|
|
1760
|
+
.filter((l) => l.trim() !== "" || l.startsWith(" "));
|
|
1761
|
+
|
|
1762
|
+
// Filter out empty trailing lines but keep intentional blank context lines
|
|
1763
|
+
const diffLines: string[] = [];
|
|
1764
|
+
for (const line of bodyLines) {
|
|
1765
|
+
// Skip Claude's @@ context search @@ markers (e.g. "@@ function foo() { @@")
|
|
1766
|
+
// These are NOT standard unified diff hunk headers (@@ -N,M +N,M @@)
|
|
1767
|
+
if (/^@@.*@@\s*$/.test(line)) continue;
|
|
1768
|
+
// Keep actual diff lines: +, -, or space-prefixed context
|
|
1769
|
+
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) {
|
|
1770
|
+
diffLines.push(line);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
let gitHeader: string;
|
|
1775
|
+
let fileHeader: string;
|
|
1776
|
+
switch (type) {
|
|
1777
|
+
case "Add":
|
|
1778
|
+
gitHeader = `diff --git a/${path} b/${path}\nnew file mode 100644`;
|
|
1779
|
+
fileHeader = `--- /dev/null\n+++ b/${path}`;
|
|
1780
|
+
break;
|
|
1781
|
+
case "Delete":
|
|
1782
|
+
gitHeader = `diff --git a/${path} b/${path}\ndeleted file mode 100644`;
|
|
1783
|
+
fileHeader = `--- a/${path}\n+++ /dev/null`;
|
|
1784
|
+
break;
|
|
1785
|
+
default:
|
|
1786
|
+
gitHeader = `diff --git a/${path} b/${path}`;
|
|
1787
|
+
fileHeader = `--- a/${path}\n+++ b/${path}`;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
if (diffLines.length === 0) return `${gitHeader}\n${fileHeader}`;
|
|
1791
|
+
|
|
1792
|
+
// For Add files, all lines should be additions
|
|
1793
|
+
if (type === "Add") {
|
|
1794
|
+
const addLines = diffLines.map((l) => (l.startsWith("+") ? l : `+${l}`));
|
|
1795
|
+
const count = addLines.length;
|
|
1796
|
+
return `${gitHeader}\n${fileHeader}\n@@ -0,0 +1,${count} @@\n${addLines.join("\n")}`;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// For Delete files, all lines should be deletions
|
|
1800
|
+
if (type === "Delete") {
|
|
1801
|
+
const delLines = diffLines.map((l) => (l.startsWith("-") ? l : `-${l}`));
|
|
1802
|
+
const count = delLines.length;
|
|
1803
|
+
return `${gitHeader}\n${fileHeader}\n@@ -1,${count} +0,0 @@\n${delLines.join("\n")}`;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// For Update files, synthesize proper hunk headers
|
|
1807
|
+
const { header: hunkHeader } = buildHunkHeader(diffLines, 1, 1);
|
|
1808
|
+
return `${gitHeader}\n${fileHeader}\n${hunkHeader}\n${diffLines.join("\n")}`;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// ── Component ────────────────────────────────────────────────────────────
|
|
1812
|
+
|
|
1813
|
+
export const ApplyPatchDiff: ToolCallMessagePartComponent = memo(
|
|
1814
|
+
({ toolName, args, status }) => {
|
|
1815
|
+
const [open, setOpen] = useState(false);
|
|
1816
|
+
const patchText = str(args?.patchText);
|
|
1817
|
+
|
|
1818
|
+
const patchInfo = useMemo(() => getPatchInfo(patchText), [patchText]);
|
|
1819
|
+
|
|
1820
|
+
const unifiedPatch = useMemo(() => {
|
|
1821
|
+
if (!patchText) return "";
|
|
1822
|
+
const blocks = parseClaudePatchBlocks(patchText);
|
|
1823
|
+
return blocks.map(claudeBlockToUnifiedDiff).join("\n");
|
|
1824
|
+
}, [patchText]);
|
|
1825
|
+
|
|
1826
|
+
const isRunning = status?.type === "running";
|
|
1827
|
+
const isCancelled = isCancelledToolStatus(status);
|
|
1828
|
+
const hasDiff = unifiedPatch.length > 0 && !isRunning;
|
|
1829
|
+
|
|
1830
|
+
return (
|
|
1831
|
+
<Collapsible open={open} onOpenChange={setOpen}>
|
|
1832
|
+
<CollapsibleTrigger asChild>
|
|
1833
|
+
<button
|
|
1834
|
+
type="button"
|
|
1835
|
+
className="group flex w-full items-center gap-2 py-0.5 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
1836
|
+
>
|
|
1837
|
+
<ToolStatusIcon
|
|
1838
|
+
status={status}
|
|
1839
|
+
completeIcon={<CheckIcon className="size-3 shrink-0" />}
|
|
1840
|
+
/>
|
|
1841
|
+
|
|
1842
|
+
<span
|
|
1843
|
+
className={cn(
|
|
1844
|
+
"flex items-center gap-1.5 truncate",
|
|
1845
|
+
isCancelled && "line-through opacity-50",
|
|
1846
|
+
)}
|
|
1847
|
+
>
|
|
1848
|
+
<span className="font-medium">{toolName}</span>
|
|
1849
|
+
{patchInfo.files.length > 0 && (
|
|
1850
|
+
<span className="opacity-60">
|
|
1851
|
+
{patchInfo.files.length === 1
|
|
1852
|
+
? patchInfo.files[0]
|
|
1853
|
+
: `${patchInfo.files.length} files`}
|
|
1854
|
+
</span>
|
|
1855
|
+
)}
|
|
1856
|
+
{(patchInfo.added > 0 || patchInfo.removed > 0) && !isRunning && (
|
|
1857
|
+
<span className="ml-0.5 flex items-center gap-1 font-mono text-xs">
|
|
1858
|
+
{patchInfo.added > 0 && (
|
|
1859
|
+
<span className="text-green-500">+{patchInfo.added}</span>
|
|
1860
|
+
)}
|
|
1861
|
+
{patchInfo.removed > 0 && (
|
|
1862
|
+
<span className="text-red-500">-{patchInfo.removed}</span>
|
|
1863
|
+
)}
|
|
1864
|
+
</span>
|
|
1865
|
+
)}
|
|
1866
|
+
</span>
|
|
1867
|
+
|
|
1868
|
+
{hasDiff && (
|
|
1869
|
+
<>
|
|
1870
|
+
<span className="mt-0.5 min-w-4 flex-1 self-center border-muted-foreground/20 border-b-[0.5px] transition-colors group-hover:border-muted-foreground/50" />
|
|
1871
|
+
<ChevronRightIcon
|
|
1872
|
+
className={cn(
|
|
1873
|
+
"mt-0.5 size-3.75 shrink-0 stroke-muted-foreground/60 transition-[transform,stroke] group-hover:stroke-foreground/60",
|
|
1874
|
+
open && "rotate-90",
|
|
1875
|
+
)}
|
|
1876
|
+
/>
|
|
1877
|
+
</>
|
|
1878
|
+
)}
|
|
1879
|
+
</button>
|
|
1880
|
+
</CollapsibleTrigger>
|
|
1881
|
+
|
|
1882
|
+
{hasDiff && (
|
|
1883
|
+
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down data-[state=closed]:ease-out data-[state=open]:ease-in">
|
|
1884
|
+
<div className="mt-1 ml-5 overflow-hidden rounded-md border">
|
|
1885
|
+
<PatchDiff
|
|
1886
|
+
patch={unifiedPatch}
|
|
1887
|
+
options={{
|
|
1888
|
+
theme: { dark: "pierre-dark", light: "pierre-light" },
|
|
1889
|
+
diffStyle: "unified",
|
|
1890
|
+
overflow: "wrap",
|
|
1891
|
+
}}
|
|
1892
|
+
/>
|
|
1893
|
+
</div>
|
|
1894
|
+
</CollapsibleContent>
|
|
1895
|
+
)}
|
|
1896
|
+
</Collapsible>
|
|
1897
|
+
);
|
|
1898
|
+
},
|
|
1899
|
+
);
|
|
1900
|
+
ApplyPatchDiff.displayName = "ApplyPatchDiff";
|
|
1901
|
+
|
|
1902
|
+
```
|
|
1903
|
+
|
|
1904
|
+
## components/tools/tool-ui-bash.tsx
|
|
1905
|
+
|
|
1906
|
+
```tsx
|
|
1907
|
+
"use client";
|
|
1908
|
+
|
|
1909
|
+
import { memo, useState } from "react";
|
|
1910
|
+
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
|
1911
|
+
import { ChevronRightIcon, DollarSignIcon, XCircleIcon } from "lucide-react";
|
|
1912
|
+
import {
|
|
1913
|
+
Collapsible,
|
|
1914
|
+
CollapsibleContent,
|
|
1915
|
+
CollapsibleTrigger,
|
|
1916
|
+
} from "@/components/ui/collapsible";
|
|
1917
|
+
import { cn } from "@/lib/utils";
|
|
1918
|
+
import {
|
|
1919
|
+
ToolStatusIcon,
|
|
1920
|
+
isCancelledToolStatus,
|
|
1921
|
+
truncate,
|
|
1922
|
+
} from "@/components/tools/tool-ui-shared";
|
|
1923
|
+
|
|
1924
|
+
const parseResult = (result: unknown) => {
|
|
1925
|
+
if (!result) return {};
|
|
1926
|
+
if (typeof result === "string") {
|
|
1927
|
+
try {
|
|
1928
|
+
const parsed = JSON.parse(result);
|
|
1929
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
1930
|
+
} catch {
|
|
1931
|
+
// plain text output
|
|
1932
|
+
}
|
|
1933
|
+
return { stdout: result };
|
|
1934
|
+
}
|
|
1935
|
+
if (typeof result === "object") return result as Record<string, unknown>;
|
|
1936
|
+
return {};
|
|
1937
|
+
};
|
|
1938
|
+
|
|
1939
|
+
export const BashTerminal: ToolCallMessagePartComponent = memo(
|
|
1940
|
+
({ args, result, status }) => {
|
|
1941
|
+
const [open, setOpen] = useState(false);
|
|
1942
|
+
|
|
1943
|
+
const command = typeof args?.command === "string" ? args.command : "";
|
|
1944
|
+
const description =
|
|
1945
|
+
typeof args?.description === "string" ? args.description : "";
|
|
1946
|
+
const isRunning = status?.type === "running";
|
|
1947
|
+
const isCancelled = isCancelledToolStatus(status);
|
|
1948
|
+
|
|
1949
|
+
const parsed = isRunning ? {} : parseResult(result);
|
|
1950
|
+
const stdout =
|
|
1951
|
+
typeof parsed.stdout === "string" ? parsed.stdout : undefined;
|
|
1952
|
+
const stderr =
|
|
1953
|
+
typeof parsed.stderr === "string" ? parsed.stderr : undefined;
|
|
1954
|
+
const exitCode = typeof parsed.exitCode === "number" ? parsed.exitCode : 0;
|
|
1955
|
+
const hasOutput = Boolean(stdout || stderr);
|
|
1956
|
+
const isError = !isRunning && exitCode !== 0;
|
|
1957
|
+
|
|
1958
|
+
const summaryText = description || truncate(command);
|
|
1959
|
+
|
|
1960
|
+
return (
|
|
1961
|
+
<Collapsible open={open} onOpenChange={setOpen}>
|
|
1962
|
+
<CollapsibleTrigger asChild>
|
|
1963
|
+
<button
|
|
1964
|
+
type="button"
|
|
1965
|
+
className="group flex w-full items-center gap-2 py-0.5 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
1966
|
+
>
|
|
1967
|
+
{isRunning ? (
|
|
1968
|
+
<ToolStatusIcon status={status} />
|
|
1969
|
+
) : isError ? (
|
|
1970
|
+
<XCircleIcon className="size-3 shrink-0 text-destructive" />
|
|
1971
|
+
) : (
|
|
1972
|
+
<ToolStatusIcon
|
|
1973
|
+
status={status}
|
|
1974
|
+
completeIcon={<DollarSignIcon className="size-3.5 shrink-0" />}
|
|
1975
|
+
/>
|
|
1976
|
+
)}
|
|
1977
|
+
|
|
1978
|
+
<span
|
|
1979
|
+
className={cn(
|
|
1980
|
+
"flex items-center gap-1.5 truncate",
|
|
1981
|
+
isCancelled && "line-through opacity-50",
|
|
1982
|
+
)}
|
|
1983
|
+
>
|
|
1984
|
+
<span className="font-medium">bash</span>
|
|
1985
|
+
{summaryText && <span className="opacity-60">{summaryText}</span>}
|
|
1986
|
+
</span>
|
|
1987
|
+
|
|
1988
|
+
{hasOutput && (
|
|
1989
|
+
<>
|
|
1990
|
+
<span className="mt-0.5 min-w-4 flex-1 self-center border-muted-foreground/20 border-b-[0.5px] transition-colors group-hover:border-muted-foreground/50" />
|
|
1991
|
+
<ChevronRightIcon
|
|
1992
|
+
className={cn(
|
|
1993
|
+
"mt-0.5 size-3.75 shrink-0 stroke-muted-foreground/60 transition-[transform,stroke] group-hover:stroke-foreground/60",
|
|
1994
|
+
open && "rotate-90",
|
|
1995
|
+
)}
|
|
1996
|
+
/>
|
|
1997
|
+
</>
|
|
1998
|
+
)}
|
|
1999
|
+
</button>
|
|
2000
|
+
</CollapsibleTrigger>
|
|
2001
|
+
|
|
2002
|
+
{hasOutput && (
|
|
2003
|
+
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down data-[state=closed]:ease-out data-[state=open]:ease-in">
|
|
2004
|
+
<div className="mt-1 ml-5 max-h-96 overflow-y-auto rounded-md border bg-muted/50 p-3 font-mono text-xs">
|
|
2005
|
+
{command && (
|
|
2006
|
+
<div className="mb-2 text-muted-foreground">$ {command}</div>
|
|
2007
|
+
)}
|
|
2008
|
+
{stdout && (
|
|
2009
|
+
<pre className="wrap-break-word whitespace-pre-wrap text-foreground">
|
|
2010
|
+
{stdout}
|
|
2011
|
+
</pre>
|
|
2012
|
+
)}
|
|
2013
|
+
{stderr && (
|
|
2014
|
+
<pre
|
|
2015
|
+
className={cn(
|
|
2016
|
+
"wrap-break-word whitespace-pre-wrap text-destructive",
|
|
2017
|
+
stdout && "mt-2",
|
|
2018
|
+
)}
|
|
2019
|
+
>
|
|
2020
|
+
{stderr}
|
|
2021
|
+
</pre>
|
|
2022
|
+
)}
|
|
2023
|
+
</div>
|
|
2024
|
+
</CollapsibleContent>
|
|
2025
|
+
)}
|
|
2026
|
+
</Collapsible>
|
|
2027
|
+
);
|
|
2028
|
+
},
|
|
2029
|
+
);
|
|
2030
|
+
BashTerminal.displayName = "BashTerminal";
|
|
2031
|
+
|
|
2032
|
+
```
|
|
2033
|
+
|
|
2034
|
+
## components/tools/tool-ui-inline.tsx
|
|
2035
|
+
|
|
2036
|
+
```tsx
|
|
2037
|
+
"use client";
|
|
2038
|
+
|
|
2039
|
+
import { memo, useMemo } from "react";
|
|
2040
|
+
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
|
2041
|
+
import { cn } from "@/lib/utils";
|
|
2042
|
+
import {
|
|
2043
|
+
ToolStatusIcon,
|
|
2044
|
+
basename,
|
|
2045
|
+
getPatchInfo,
|
|
2046
|
+
isCancelledToolStatus,
|
|
2047
|
+
str,
|
|
2048
|
+
truncate,
|
|
2049
|
+
type ToolCallStatusLike,
|
|
2050
|
+
} from "@/components/tools/tool-ui-shared";
|
|
2051
|
+
|
|
2052
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
2053
|
+
|
|
2054
|
+
const shortenPath = (filepath: string, depth = 2): string => {
|
|
2055
|
+
const parts = filepath.split("/").filter(Boolean);
|
|
2056
|
+
if (parts.length <= depth) return filepath;
|
|
2057
|
+
return parts.slice(-depth).join("/");
|
|
2058
|
+
};
|
|
2059
|
+
|
|
2060
|
+
// ── ToolCallShell ──────────────────────────────────────────────────────
|
|
2061
|
+
|
|
2062
|
+
const ToolCallShell = ({
|
|
2063
|
+
toolName,
|
|
2064
|
+
status,
|
|
2065
|
+
children,
|
|
2066
|
+
}: {
|
|
2067
|
+
toolName: string;
|
|
2068
|
+
status?: ToolCallStatusLike;
|
|
2069
|
+
children?: React.ReactNode;
|
|
2070
|
+
}) => {
|
|
2071
|
+
const isCancelled = isCancelledToolStatus(status);
|
|
2072
|
+
|
|
2073
|
+
return (
|
|
2074
|
+
<div className="flex items-center gap-2 py-0.5 text-muted-foreground text-sm">
|
|
2075
|
+
<ToolStatusIcon status={status} />
|
|
2076
|
+
<span
|
|
2077
|
+
className={cn(
|
|
2078
|
+
"flex items-center gap-1.5 truncate",
|
|
2079
|
+
isCancelled && "line-through opacity-50",
|
|
2080
|
+
)}
|
|
2081
|
+
>
|
|
2082
|
+
<span className="font-medium">{toolName}</span>
|
|
2083
|
+
{children}
|
|
2084
|
+
</span>
|
|
2085
|
+
</div>
|
|
2086
|
+
);
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
// ── Factory for simple inline tools ────────────────────────────────────
|
|
2090
|
+
|
|
2091
|
+
const inlineTool = (
|
|
2092
|
+
argKeys: string | string[],
|
|
2093
|
+
format: (v: string) => string = truncate,
|
|
2094
|
+
): ToolCallMessagePartComponent => {
|
|
2095
|
+
const keys = Array.isArray(argKeys) ? argKeys : [argKeys];
|
|
2096
|
+
const Component: ToolCallMessagePartComponent = memo(
|
|
2097
|
+
({ toolName, args, status }) => {
|
|
2098
|
+
const value = keys.reduce<string>(
|
|
2099
|
+
(acc, key) => acc || str((args as Record<string, unknown>)?.[key]),
|
|
2100
|
+
"",
|
|
2101
|
+
);
|
|
2102
|
+
return (
|
|
2103
|
+
<ToolCallShell toolName={toolName} status={status}>
|
|
2104
|
+
{value && <span className="opacity-60">{format(value)}</span>}
|
|
2105
|
+
</ToolCallShell>
|
|
2106
|
+
);
|
|
2107
|
+
},
|
|
2108
|
+
);
|
|
2109
|
+
return Component;
|
|
2110
|
+
};
|
|
2111
|
+
|
|
2112
|
+
// ── Per-tool inline components ─────────────────────────────────────────
|
|
2113
|
+
|
|
2114
|
+
export const ReadInline = inlineTool(
|
|
2115
|
+
["file_path", "filePath", "path", "file"],
|
|
2116
|
+
(v) => truncate(shortenPath(v)),
|
|
2117
|
+
);
|
|
2118
|
+
ReadInline.displayName = "ReadInline";
|
|
2119
|
+
|
|
2120
|
+
export const EditInline = inlineTool("file_path", basename);
|
|
2121
|
+
EditInline.displayName = "EditInline";
|
|
2122
|
+
|
|
2123
|
+
export const WriteInline = inlineTool("file_path", basename);
|
|
2124
|
+
WriteInline.displayName = "WriteInline";
|
|
2125
|
+
|
|
2126
|
+
export const GrepInline = inlineTool("pattern");
|
|
2127
|
+
GrepInline.displayName = "GrepInline";
|
|
2128
|
+
|
|
2129
|
+
export const GlobInline = inlineTool("pattern");
|
|
2130
|
+
GlobInline.displayName = "GlobInline";
|
|
2131
|
+
|
|
2132
|
+
export const WebSearchInline = inlineTool("query");
|
|
2133
|
+
WebSearchInline.displayName = "WebSearchInline";
|
|
2134
|
+
|
|
2135
|
+
export const WebFetchInline = inlineTool("url");
|
|
2136
|
+
WebFetchInline.displayName = "WebFetchInline";
|
|
2137
|
+
|
|
2138
|
+
// Kept as a compact reference implementation even though the example
|
|
2139
|
+
// currently uses the richer diff viewer from tool-ui-apply-patch.tsx.
|
|
2140
|
+
export const ApplyPatchInline: ToolCallMessagePartComponent = memo(
|
|
2141
|
+
({ toolName, args, status }) => {
|
|
2142
|
+
const patchText = str(args?.patchText);
|
|
2143
|
+
const patchInfo = useMemo(() => getPatchInfo(patchText), [patchText]);
|
|
2144
|
+
const isRunning = status?.type === "running";
|
|
2145
|
+
|
|
2146
|
+
return (
|
|
2147
|
+
<ToolCallShell toolName={toolName} status={status}>
|
|
2148
|
+
{patchInfo.files.length > 0 && (
|
|
2149
|
+
<span className="opacity-60">
|
|
2150
|
+
{patchInfo.files.length === 1
|
|
2151
|
+
? patchInfo.files[0]
|
|
2152
|
+
: `${patchInfo.files.length} files`}
|
|
2153
|
+
</span>
|
|
2154
|
+
)}
|
|
2155
|
+
{(patchInfo.added > 0 || patchInfo.removed > 0) && !isRunning && (
|
|
2156
|
+
<span className="ml-0.5 flex items-center gap-1 font-mono text-xs">
|
|
2157
|
+
{patchInfo.added > 0 && (
|
|
2158
|
+
<span className="text-green-500">+{patchInfo.added}</span>
|
|
2159
|
+
)}
|
|
2160
|
+
{patchInfo.removed > 0 && (
|
|
2161
|
+
<span className="text-red-500">-{patchInfo.removed}</span>
|
|
2162
|
+
)}
|
|
2163
|
+
</span>
|
|
2164
|
+
)}
|
|
2165
|
+
</ToolCallShell>
|
|
2166
|
+
);
|
|
2167
|
+
},
|
|
2168
|
+
);
|
|
2169
|
+
ApplyPatchInline.displayName = "ApplyPatchInline";
|
|
2170
|
+
|
|
2171
|
+
// ── Fallback — generic, for unknown/MCP tools ─────────────────────────
|
|
2172
|
+
|
|
2173
|
+
const SUMMARY_KEYS = [
|
|
2174
|
+
"file_path",
|
|
2175
|
+
"path",
|
|
2176
|
+
"pattern",
|
|
2177
|
+
"command",
|
|
2178
|
+
"query",
|
|
2179
|
+
"glob",
|
|
2180
|
+
"url",
|
|
2181
|
+
] as const;
|
|
2182
|
+
|
|
2183
|
+
const ToolCallFallbackImpl: ToolCallMessagePartComponent = ({
|
|
2184
|
+
toolName,
|
|
2185
|
+
args,
|
|
2186
|
+
status,
|
|
2187
|
+
}) => {
|
|
2188
|
+
const summary = useMemo(() => {
|
|
2189
|
+
if (!args || typeof args !== "object") return "";
|
|
2190
|
+
for (const key of SUMMARY_KEYS) {
|
|
2191
|
+
const v = (args as Record<string, unknown>)[key];
|
|
2192
|
+
if (typeof v === "string" && v) return truncate(v);
|
|
2193
|
+
}
|
|
2194
|
+
return "";
|
|
2195
|
+
}, [args]);
|
|
2196
|
+
|
|
2197
|
+
return (
|
|
2198
|
+
<ToolCallShell toolName={toolName} status={status}>
|
|
2199
|
+
{summary && <span className="opacity-60">{summary}</span>}
|
|
2200
|
+
</ToolCallShell>
|
|
2201
|
+
);
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
export const ToolCallFallback = memo(ToolCallFallbackImpl);
|
|
2205
|
+
ToolCallFallback.displayName = "ToolCallFallback";
|
|
2206
|
+
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
## components/tools/tool-ui-shared.tsx
|
|
2210
|
+
|
|
2211
|
+
```tsx
|
|
2212
|
+
"use client";
|
|
2213
|
+
|
|
2214
|
+
import type { ReactNode } from "react";
|
|
2215
|
+
import {
|
|
2216
|
+
AlertCircleIcon,
|
|
2217
|
+
CheckIcon,
|
|
2218
|
+
LoaderIcon,
|
|
2219
|
+
XCircleIcon,
|
|
2220
|
+
} from "lucide-react";
|
|
2221
|
+
import { cn } from "@/lib/utils";
|
|
2222
|
+
|
|
2223
|
+
export type ToolCallStatusLike =
|
|
2224
|
+
| {
|
|
2225
|
+
type: string;
|
|
2226
|
+
reason?: string;
|
|
2227
|
+
}
|
|
2228
|
+
| undefined;
|
|
2229
|
+
|
|
2230
|
+
export const truncate = (value: string, max = 80): string =>
|
|
2231
|
+
value.length > max ? `${value.slice(0, max - 3)}...` : value;
|
|
2232
|
+
|
|
2233
|
+
export const str = (value: unknown): string =>
|
|
2234
|
+
typeof value === "string" ? value : "";
|
|
2235
|
+
|
|
2236
|
+
export const basename = (filepath: string): string => {
|
|
2237
|
+
const parts = filepath.split("/").filter(Boolean);
|
|
2238
|
+
return parts[parts.length - 1] ?? filepath;
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2241
|
+
const unique = <T,>(items: readonly T[]) => [...new Set(items)];
|
|
2242
|
+
|
|
2243
|
+
export const getPatchInfo = (patchText: string) => {
|
|
2244
|
+
if (!patchText) return { files: [] as string[], added: 0, removed: 0 };
|
|
2245
|
+
|
|
2246
|
+
const files = unique(
|
|
2247
|
+
[...patchText.matchAll(/^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+)$/gm)]
|
|
2248
|
+
.map((match) => basename(match[1]!.trim()))
|
|
2249
|
+
.filter(Boolean),
|
|
2250
|
+
);
|
|
2251
|
+
|
|
2252
|
+
let added = 0;
|
|
2253
|
+
let removed = 0;
|
|
2254
|
+
for (const line of patchText.split("\n")) {
|
|
2255
|
+
if (line.startsWith("+")) added += 1;
|
|
2256
|
+
else if (line.startsWith("-")) removed += 1;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
return { files, added, removed };
|
|
2260
|
+
};
|
|
2261
|
+
|
|
2262
|
+
export const isCancelledToolStatus = (status?: ToolCallStatusLike) =>
|
|
2263
|
+
status?.type === "incomplete" && status.reason === "cancelled";
|
|
2264
|
+
|
|
2265
|
+
export const ToolStatusIcon = ({
|
|
2266
|
+
status,
|
|
2267
|
+
completeIcon,
|
|
2268
|
+
}: {
|
|
2269
|
+
status?: ToolCallStatusLike;
|
|
2270
|
+
completeIcon?: ReactNode;
|
|
2271
|
+
}) => {
|
|
2272
|
+
const statusType = status?.type ?? "complete";
|
|
2273
|
+
const isCancelled = isCancelledToolStatus(status);
|
|
2274
|
+
|
|
2275
|
+
if (statusType === "running") {
|
|
2276
|
+
return <LoaderIcon className="size-3 shrink-0 animate-spin" />;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
if (statusType === "requires-action") {
|
|
2280
|
+
return <AlertCircleIcon className="size-3 shrink-0 text-amber-600" />;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
if (statusType === "incomplete") {
|
|
2284
|
+
return (
|
|
2285
|
+
<XCircleIcon
|
|
2286
|
+
className={cn("size-3 shrink-0", !isCancelled && "text-destructive")}
|
|
2287
|
+
/>
|
|
2288
|
+
);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
return completeIcon ?? <CheckIcon className="size-3 shrink-0" />;
|
|
2292
|
+
};
|
|
2293
|
+
|
|
2294
|
+
```
|
|
2295
|
+
|
|
2296
|
+
## next.config.ts
|
|
2297
|
+
|
|
2298
|
+
```typescript
|
|
2299
|
+
import type { NextConfig } from "next";
|
|
2300
|
+
|
|
2301
|
+
const nextConfig: NextConfig = {
|
|
2302
|
+
/* config options here */
|
|
2303
|
+
};
|
|
2304
|
+
|
|
2305
|
+
export default nextConfig;
|
|
2306
|
+
|
|
2307
|
+
```
|
|
2308
|
+
|
|
2309
|
+
## package.json
|
|
2310
|
+
|
|
2311
|
+
```json
|
|
2312
|
+
{
|
|
2313
|
+
"name": "with-opencode",
|
|
2314
|
+
"version": "0.0.0",
|
|
2315
|
+
"private": true,
|
|
2316
|
+
"type": "module",
|
|
2317
|
+
"scripts": {
|
|
2318
|
+
"dev": "next dev",
|
|
2319
|
+
"build": "next build",
|
|
2320
|
+
"start": "next start"
|
|
2321
|
+
},
|
|
2322
|
+
"dependencies": {
|
|
2323
|
+
"@assistant-ui/react": "workspace:*",
|
|
2324
|
+
"@assistant-ui/react-markdown": "workspace:*",
|
|
2325
|
+
"@assistant-ui/react-opencode": "workspace:*",
|
|
2326
|
+
"@assistant-ui/ui": "workspace:*",
|
|
2327
|
+
"@pierre/diffs": "^1.1.16",
|
|
2328
|
+
"class-variance-authority": "^0.7.1",
|
|
2329
|
+
"clsx": "^2.1.1",
|
|
2330
|
+
"lucide-react": "^1.8.0",
|
|
2331
|
+
"next": "^16.2.4",
|
|
2332
|
+
"react": "^19.2.5",
|
|
2333
|
+
"react-dom": "^19.2.5",
|
|
2334
|
+
"tailwind-merge": "^3.5.0",
|
|
2335
|
+
"zod": "^4.3.6"
|
|
2336
|
+
},
|
|
2337
|
+
"devDependencies": {
|
|
2338
|
+
"@assistant-ui/x-buildutils": "workspace:*",
|
|
2339
|
+
"@tailwindcss/postcss": "^4.2.4",
|
|
2340
|
+
"@types/node": "^25.6.0",
|
|
2341
|
+
"@types/react": "^19.2.14",
|
|
2342
|
+
"@types/react-dom": "^19.2.3",
|
|
2343
|
+
"postcss": "^8.5.10",
|
|
2344
|
+
"tailwindcss": "^4.2.4",
|
|
2345
|
+
"tw-animate-css": "^1.4.0",
|
|
2346
|
+
"typescript": "^6.0.3"
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
```
|
|
2351
|
+
|
|
2352
|
+
## README.md
|
|
2353
|
+
|
|
2354
|
+
```markdown
|
|
2355
|
+
# with-opencode
|
|
2356
|
+
|
|
2357
|
+
> [!NOTE] This integration/example is experimental, there may be bugs or issues.
|
|
2358
|
+
|
|
2359
|
+
Next.js example for the OpenCode runtime adapter built with assistant-ui.
|
|
2360
|
+
|
|
2361
|
+
Set `NEXT_PUBLIC_OPENCODE_BASE_URL` if your OpenCode server is not running on
|
|
2362
|
+
`http://localhost:4096`.
|
|
2363
|
+
|
|
2364
|
+
```
|
|
2365
|
+
|
|
2366
|
+
## tsconfig.json
|
|
2367
|
+
|
|
2368
|
+
```json
|
|
2369
|
+
{
|
|
2370
|
+
"extends": "@assistant-ui/x-buildutils/ts/next",
|
|
2371
|
+
"compilerOptions": {
|
|
2372
|
+
"paths": {
|
|
2373
|
+
"@/*": ["./*"],
|
|
2374
|
+
"@assistant-ui/react-opencode": [
|
|
2375
|
+
"../../packages/react-opencode/src/index.ts"
|
|
2376
|
+
],
|
|
2377
|
+
"@/components/assistant-ui/*": [
|
|
2378
|
+
"../../packages/ui/src/components/assistant-ui/*"
|
|
2379
|
+
],
|
|
2380
|
+
"@/components/icons/*": ["../../packages/ui/src/components/icons/*"],
|
|
2381
|
+
"@/components/ui/*": ["../../packages/ui/src/components/ui/*"],
|
|
2382
|
+
"@/hooks/*": ["../../packages/ui/src/hooks/*"],
|
|
2383
|
+
"@/lib/utils": ["../../packages/ui/src/lib/utils"],
|
|
2384
|
+
"@assistant-ui/ui/*": ["../../packages/ui/src/*"]
|
|
2385
|
+
}
|
|
2386
|
+
},
|
|
2387
|
+
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
2388
|
+
"exclude": ["node_modules"]
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
```
|
|
2392
|
+
|