@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.
Files changed (56) hide show
  1. package/.docs/organized/code-examples/waterfall.md +18 -10
  2. package/.docs/organized/code-examples/with-a2a.md +12 -24
  3. package/.docs/organized/code-examples/with-ag-ui.md +14 -11
  4. package/.docs/organized/code-examples/with-ai-sdk-v6.md +12 -12
  5. package/.docs/organized/code-examples/with-artifacts.md +14 -12
  6. package/.docs/organized/code-examples/with-assistant-transport.md +13 -14
  7. package/.docs/organized/code-examples/with-chain-of-thought.md +11 -11
  8. package/.docs/organized/code-examples/with-cloud-standalone.md +17 -14
  9. package/.docs/organized/code-examples/with-cloud.md +12 -13
  10. package/.docs/organized/code-examples/with-custom-thread-list.md +12 -12
  11. package/.docs/organized/code-examples/with-elevenlabs-conversational.md +19 -14
  12. package/.docs/organized/code-examples/with-elevenlabs-scribe.md +15 -15
  13. package/.docs/organized/code-examples/with-expo.md +27 -23
  14. package/.docs/organized/code-examples/with-external-store.md +11 -11
  15. package/.docs/organized/code-examples/with-ffmpeg.md +19 -14
  16. package/.docs/organized/code-examples/with-generative-ui.md +11 -11
  17. package/.docs/organized/code-examples/with-google-adk.md +10 -10
  18. package/.docs/organized/code-examples/with-heat-graph.md +8 -8
  19. package/.docs/organized/code-examples/with-interactables.md +12 -27
  20. package/.docs/organized/code-examples/with-langchain.md +437 -0
  21. package/.docs/organized/code-examples/with-langgraph.md +20 -20
  22. package/.docs/organized/code-examples/with-livekit.md +59 -18
  23. package/.docs/organized/code-examples/with-opencode.md +2392 -0
  24. package/.docs/organized/code-examples/with-parent-id-grouping.md +12 -12
  25. package/.docs/organized/code-examples/with-react-hook-form.md +223 -151
  26. package/.docs/organized/code-examples/with-react-ink.md +3 -3
  27. package/.docs/organized/code-examples/with-react-router.md +15 -15
  28. package/.docs/organized/code-examples/with-store.md +11 -8
  29. package/.docs/organized/code-examples/with-tanstack.md +14 -14
  30. package/.docs/organized/code-examples/with-tap-runtime.md +13 -9
  31. package/.docs/raw/docs/(docs)/guides/mentions.mdx +248 -109
  32. package/.docs/raw/docs/(docs)/guides/slash-commands.mdx +112 -92
  33. package/.docs/raw/docs/(docs)/guides/voice.mdx +10 -3
  34. package/.docs/raw/docs/(docs)/rtl.mdx +79 -0
  35. package/.docs/raw/docs/(reference)/api-reference/primitives/composer.mdx +149 -40
  36. package/.docs/raw/docs/(reference)/api-reference/primitives/message-part.mdx +2 -0
  37. package/.docs/raw/docs/(reference)/api-reference/runtimes/thread-list-runtime.mdx +4 -0
  38. package/.docs/raw/docs/cloud/ai-sdk-assistant-ui.mdx +1 -1
  39. package/.docs/raw/docs/primitives/composer.mdx +94 -62
  40. package/.docs/raw/docs/runtimes/ai-sdk/v6.mdx +57 -0
  41. package/.docs/raw/docs/runtimes/custom/custom-thread-list.mdx +49 -3
  42. package/.docs/raw/docs/runtimes/custom/external-store.mdx +39 -1
  43. package/.docs/raw/docs/runtimes/custom/local.mdx +65 -18
  44. package/.docs/raw/docs/runtimes/google-adk/index.mdx +62 -0
  45. package/.docs/raw/docs/runtimes/langchain/comparison.mdx +60 -0
  46. package/.docs/raw/docs/runtimes/langchain/index.mdx +210 -0
  47. package/.docs/raw/docs/runtimes/langgraph/index.mdx +288 -60
  48. package/.docs/raw/docs/runtimes/pick-a-runtime.mdx +12 -4
  49. package/.docs/raw/docs/ui/composer-trigger-popover.mdx +199 -0
  50. package/.docs/raw/docs/ui/directive-text.mdx +113 -0
  51. package/.docs/raw/docs/ui/reasoning.mdx +13 -9
  52. package/dist/utils/logger.js +1 -1
  53. package/dist/utils/logger.js.map +1 -1
  54. package/package.json +4 -4
  55. package/src/utils/logger.ts +1 -1
  56. 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
+