@assistant-ui/mcp-docs-server 0.1.33 → 0.1.35

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 (102) hide show
  1. package/.docs/organized/code-examples/waterfall.md +7 -7
  2. package/.docs/organized/code-examples/with-a2a.md +8 -8
  3. package/.docs/organized/code-examples/with-ag-ui.md +12 -12
  4. package/.docs/organized/code-examples/with-ai-sdk-v6.md +10 -10
  5. package/.docs/organized/code-examples/with-artifacts.md +40 -34
  6. package/.docs/organized/code-examples/with-assistant-transport.md +11 -11
  7. package/.docs/organized/code-examples/with-browser-extension.md +9 -9
  8. package/.docs/organized/code-examples/with-chain-of-thought.md +72 -51
  9. package/.docs/organized/code-examples/with-cloud-standalone.md +10 -10
  10. package/.docs/organized/code-examples/with-cloud.md +10 -10
  11. package/.docs/organized/code-examples/with-custom-thread-list.md +10 -10
  12. package/.docs/organized/code-examples/with-elevenlabs-conversational.md +12 -12
  13. package/.docs/organized/code-examples/with-elevenlabs-scribe.md +12 -12
  14. package/.docs/organized/code-examples/with-expo.md +66 -31
  15. package/.docs/organized/code-examples/with-external-store.md +8 -8
  16. package/.docs/organized/code-examples/with-ffmpeg.md +13 -13
  17. package/.docs/organized/code-examples/with-generative-ui.md +98 -368
  18. package/.docs/organized/code-examples/with-google-adk.md +9 -9
  19. package/.docs/organized/code-examples/with-heat-graph.md +7 -7
  20. package/.docs/organized/code-examples/with-image-generation.md +10 -10
  21. package/.docs/organized/code-examples/with-interactables.md +10 -10
  22. package/.docs/organized/code-examples/with-langchain.md +10 -10
  23. package/.docs/organized/code-examples/with-langgraph.md +33 -29
  24. package/.docs/organized/code-examples/with-livekit.md +12 -12
  25. package/.docs/organized/code-examples/with-mcp.md +11 -11
  26. package/.docs/organized/code-examples/with-opencode.md +109 -583
  27. package/.docs/organized/code-examples/with-pi.md +2044 -0
  28. package/.docs/organized/code-examples/with-react-hook-form.md +11 -11
  29. package/.docs/organized/code-examples/with-react-ink-web.md +691 -0
  30. package/.docs/organized/code-examples/with-react-ink.md +309 -102
  31. package/.docs/organized/code-examples/with-react-router.md +14 -14
  32. package/.docs/organized/code-examples/with-resumable-stream.md +11 -11
  33. package/.docs/organized/code-examples/with-store.md +70 -66
  34. package/.docs/organized/code-examples/with-tanstack.md +25 -11
  35. package/.docs/organized/code-examples/with-tap-runtime.md +8 -8
  36. package/.docs/organized/code-examples/with-virtualized-thread.md +656 -0
  37. package/.docs/raw/docs/(docs)/architecture.mdx +65 -53
  38. package/.docs/raw/docs/(reference)/api-reference/external-store/message-conversion.mdx +1 -1
  39. package/.docs/raw/docs/(reference)/api-reference/external-store/runtime.mdx +3 -0
  40. package/.docs/raw/docs/(reference)/api-reference/hooks/primitives.mdx +44 -1
  41. package/.docs/raw/docs/(reference)/api-reference/integrations/react-ai-sdk.mdx +14 -3
  42. package/.docs/raw/docs/(reference)/api-reference/model-context/context.mdx +1 -1
  43. package/.docs/raw/docs/(reference)/api-reference/tools/rendering.mdx +2 -23
  44. package/.docs/raw/docs/(reference)/api-reference/tools/status.mdx +22 -0
  45. package/.docs/raw/docs/(reference)/api-reference/tools/toolkits.mdx +93 -9
  46. package/.docs/raw/docs/(reference)/api-reference/utilities/miscellaneous.mdx +21 -86
  47. package/.docs/raw/docs/guides/chain-of-thought.mdx +1 -1
  48. package/.docs/raw/docs/guides/index.mdx +3 -0
  49. package/.docs/raw/docs/guides/input-history.mdx +55 -0
  50. package/.docs/raw/docs/guides/virtualization.mdx +63 -0
  51. package/.docs/raw/docs/ink/adapters.mdx +23 -1
  52. package/.docs/raw/docs/ink/hooks.mdx +22 -19
  53. package/.docs/raw/docs/migrations/toolkit-tools.mdx +14 -8
  54. package/.docs/raw/docs/react-native/hooks.mdx +26 -18
  55. package/.docs/raw/docs/runtimes/ag-ui/runtime-options.mdx +39 -0
  56. package/.docs/raw/docs/runtimes/ai-sdk/v6.mdx +3 -3
  57. package/.docs/raw/docs/runtimes/concepts/architecture.mdx +46 -4
  58. package/.docs/raw/docs/runtimes/concepts/stability.mdx +1 -1
  59. package/.docs/raw/docs/runtimes/custom/assistant-transport.mdx +54 -12
  60. package/.docs/raw/docs/runtimes/custom/data-stream.mdx +1 -1
  61. package/.docs/raw/docs/runtimes/custom/external-store.mdx +62 -4
  62. package/.docs/raw/docs/runtimes/custom/local-runtime.mdx +120 -4
  63. package/.docs/raw/docs/runtimes/google-adk/hooks.mdx +9 -9
  64. package/.docs/raw/docs/runtimes/langgraph/streaming.mdx +13 -0
  65. package/.docs/raw/docs/runtimes/langgraph/tutorial/part-2.mdx +5 -5
  66. package/.docs/raw/docs/runtimes/langgraph/tutorial/part-3.mdx +3 -3
  67. package/.docs/raw/docs/runtimes/pick-a-runtime.mdx +6 -6
  68. package/.docs/raw/docs/tools/backend.mdx +19 -11
  69. package/.docs/raw/docs/tools/defining-tools.mdx +177 -52
  70. package/.docs/raw/docs/tools/index.mdx +7 -12
  71. package/.docs/raw/docs/tools/mcp.mdx +83 -15
  72. package/.docs/raw/docs/tools/multi-agent.mdx +5 -5
  73. package/.docs/raw/docs/tools/tool-ui.mdx +64 -28
  74. package/.docs/raw/docs/tools/user-managed-mcp.mdx +4 -4
  75. package/.docs/raw/docs/ui/dot-matrix.mdx +133 -0
  76. package/.docs/raw/docs/ui/mermaid.mdx +16 -9
  77. package/.docs/raw/docs/ui/model-selector.mdx +219 -52
  78. package/.docs/raw/docs/ui/number-roll.mdx +154 -0
  79. package/.docs/raw/docs/ui/part-grouping.mdx +2 -2
  80. package/.docs/raw/docs/ui/reasoning.mdx +3 -3
  81. package/.docs/raw/docs/ui/streamdown.mdx +2 -0
  82. package/.docs/raw/docs/ui/syntax-highlighting.mdx +1 -1
  83. package/.docs/raw/docs/ui/thread.mdx +52 -0
  84. package/.docs/raw/docs/ui/tool-fallback.mdx +16 -0
  85. package/.docs/raw/docs/utilities/react-o11y.mdx +2 -2
  86. package/dist/constants.js.map +1 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/prepare-docs/code-examples.js.map +1 -1
  89. package/dist/prepare-docs/copy-raw.js.map +1 -1
  90. package/dist/prepare-docs/prepare.js.map +1 -1
  91. package/dist/stdio.js.map +1 -1
  92. package/dist/tools/docs.js.map +1 -1
  93. package/dist/tools/examples.js.map +1 -1
  94. package/dist/tools/tests/test-setup.js.map +1 -1
  95. package/dist/utils/mdx.js.map +1 -1
  96. package/dist/utils/paths.js.map +1 -1
  97. package/package.json +4 -4
  98. /package/.docs/raw/docs/{(docs)/copilots → copilots}/assistant-frame.mdx +0 -0
  99. /package/.docs/raw/docs/{(docs)/copilots → copilots}/make-assistant-visible.mdx +0 -0
  100. /package/.docs/raw/docs/{(docs)/copilots → copilots}/model-context.mdx +0 -0
  101. /package/.docs/raw/docs/{(docs)/copilots → copilots}/motivation.mdx +0 -0
  102. /package/.docs/raw/docs/{(docs)/copilots → copilots}/use-assistant-instructions.mdx +0 -0
@@ -0,0 +1,2044 @@
1
+ # Example: with-pi
2
+
3
+ ## app/api/pi/fs/route.ts
4
+
5
+ ```typescript
6
+ import { readdir } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { resolve, dirname, join } from "node:path";
9
+ import { withFail } from "@/lib/http";
10
+
11
+ export const runtime = "nodejs";
12
+ export const dynamic = "force-dynamic";
13
+
14
+ export type FsEntry = { name: string; path: string };
15
+ export type FsListing = {
16
+ path: string;
17
+ parent: string | null;
18
+ entries: FsEntry[];
19
+ };
20
+
21
+ const expand = (p: string): string =>
22
+ p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
23
+
24
+ export const GET = withFail(async (request: Request) => {
25
+ // This lists arbitrary host directories for the local workspace picker —
26
+ // never expose it from a deployed instance.
27
+ if (process.env.NODE_ENV === "production") {
28
+ return new Response(null, { status: 404 });
29
+ }
30
+ const raw = new URL(request.url).searchParams.get("path");
31
+ const dir = resolve(raw ? expand(raw) : homedir());
32
+ const dirents = await readdir(dir, { withFileTypes: true });
33
+ const entries = dirents
34
+ .filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith("."))
35
+ .map((dirent) => ({ name: dirent.name, path: join(dir, dirent.name) }))
36
+ .sort((a, b) => a.name.localeCompare(b.name));
37
+ const parent = dirname(dir);
38
+ return Response.json({
39
+ path: dir,
40
+ parent: parent === dir ? null : parent,
41
+ entries,
42
+ } satisfies FsListing);
43
+ });
44
+
45
+ ```
46
+
47
+ ## app/api/pi/handshake/route.ts
48
+
49
+ ```typescript
50
+ import { getHandshake } from "@/lib/pi-server";
51
+ import { withFail } from "@/lib/http";
52
+
53
+ export const runtime = "nodejs";
54
+ export const dynamic = "force-dynamic";
55
+
56
+ export const GET = withFail(async () => Response.json(await getHandshake()));
57
+
58
+ ```
59
+
60
+ ## app/api/pi/models/route.ts
61
+
62
+ ```typescript
63
+ import { piClient } from "@/lib/pi-server";
64
+ import { withFail } from "@/lib/http";
65
+
66
+ export const runtime = "nodejs";
67
+ export const dynamic = "force-dynamic";
68
+
69
+ export const GET = withFail(async () =>
70
+ Response.json(await piClient.getAvailableModels()),
71
+ );
72
+
73
+ ```
74
+
75
+ ## app/api/pi/threads/[threadId]/archive/route.ts
76
+
77
+ ```typescript
78
+ import type { NextRequest } from "next/server";
79
+ import { piClient } from "@/lib/pi-server";
80
+ import { noContent, withFail } from "@/lib/http";
81
+
82
+ export const runtime = "nodejs";
83
+ export const dynamic = "force-dynamic";
84
+
85
+ type Context = { params: Promise<{ threadId: string }> };
86
+
87
+ export const POST = withFail(async (_req: NextRequest, { params }: Context) => {
88
+ const { threadId } = await params;
89
+ await piClient.archiveThread(threadId);
90
+ return noContent();
91
+ });
92
+
93
+ ```
94
+
95
+ ## app/api/pi/threads/[threadId]/cancel/route.ts
96
+
97
+ ```typescript
98
+ import type { NextRequest } from "next/server";
99
+ import { piClient } from "@/lib/pi-server";
100
+ import { noContent, withFail } from "@/lib/http";
101
+
102
+ export const runtime = "nodejs";
103
+ export const dynamic = "force-dynamic";
104
+
105
+ type Context = { params: Promise<{ threadId: string }> };
106
+
107
+ export const POST = withFail(async (_req: NextRequest, { params }: Context) => {
108
+ const { threadId } = await params;
109
+ await piClient.cancelRun(threadId);
110
+ return noContent();
111
+ });
112
+
113
+ ```
114
+
115
+ ## app/api/pi/threads/[threadId]/events/route.ts
116
+
117
+ ```typescript
118
+ import type { NextRequest } from "next/server";
119
+ import { piClient } from "@/lib/pi-server";
120
+
121
+ export const runtime = "nodejs";
122
+ export const dynamic = "force-dynamic";
123
+
124
+ type Context = { params: Promise<{ threadId: string }> };
125
+
126
+ /**
127
+ * SSE stream of `PiClientEvent`s for one thread. Snapshot-first by default;
128
+ * `?snapshot=false` lets a client that already called `GET /threads/:id` attach
129
+ * only to subsequent live events. A client disconnect unsubscribes but does NOT
130
+ * abort the Pi run — disconnect ≠ abort.
131
+ */
132
+ export async function GET(req: NextRequest, { params }: Context) {
133
+ const { threadId } = await params;
134
+ const encoder = new TextEncoder();
135
+ let unsubscribe: (() => void) | undefined;
136
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
137
+ const stream = new TransformStream<Uint8Array, Uint8Array>();
138
+ const writer = stream.writable.getWriter();
139
+
140
+ const write = (chunk: string) => {
141
+ void writer.write(encoder.encode(chunk)).catch(() => {
142
+ // Writer already closed (client gone); drop.
143
+ });
144
+ };
145
+
146
+ const cleanup = () => {
147
+ unsubscribe?.();
148
+ if (heartbeat) clearInterval(heartbeat);
149
+ void writer.close().catch(() => {
150
+ // Already closed.
151
+ });
152
+ };
153
+
154
+ // Flush headers through any buffering proxy immediately.
155
+ write(": connected\n\n");
156
+ // Keep-alive under typical proxy idle timeouts (30–60s) without the write
157
+ // churn of a tighter interval.
158
+ heartbeat = setInterval(() => write(": ping\n\n"), 20_000);
159
+ unsubscribe = piClient.subscribe(
160
+ threadId,
161
+ (event) => {
162
+ write(`data: ${JSON.stringify(event)}\n\n`);
163
+ },
164
+ { includeSnapshot: req.nextUrl.searchParams.get("snapshot") !== "false" },
165
+ );
166
+ req.signal.addEventListener("abort", cleanup, { once: true });
167
+
168
+ return new Response(stream.readable, {
169
+ headers: {
170
+ "content-type": "text/event-stream; charset=utf-8",
171
+ "cache-control": "no-cache, no-transform",
172
+ connection: "keep-alive",
173
+ },
174
+ });
175
+ }
176
+
177
+ ```
178
+
179
+ ## app/api/pi/threads/[threadId]/host-ui/route.ts
180
+
181
+ ```typescript
182
+ import type { NextRequest } from "next/server";
183
+ import type { PiHostUiResponse } from "@assistant-ui/react-pi";
184
+ import { piClient } from "@/lib/pi-server";
185
+ import { noContent, withFail } from "@/lib/http";
186
+
187
+ export const runtime = "nodejs";
188
+ export const dynamic = "force-dynamic";
189
+
190
+ type Context = { params: Promise<{ threadId: string }> };
191
+
192
+ export const POST = withFail(async (req: NextRequest, { params }: Context) => {
193
+ const { threadId } = await params;
194
+ const { response } = (await req.json()) as { response: PiHostUiResponse };
195
+ await piClient.respondToHostUiRequest(threadId, response);
196
+ return noContent();
197
+ });
198
+
199
+ ```
200
+
201
+ ## app/api/pi/threads/[threadId]/messages/route.ts
202
+
203
+ ```typescript
204
+ import type { NextRequest } from "next/server";
205
+ import type { PiSendMessageInput } from "@assistant-ui/react-pi";
206
+ import { piClient } from "@/lib/pi-server";
207
+ import { noContent, withFail } from "@/lib/http";
208
+
209
+ export const runtime = "nodejs";
210
+ export const dynamic = "force-dynamic";
211
+
212
+ type Context = { params: Promise<{ threadId: string }> };
213
+
214
+ export const POST = withFail(async (req: NextRequest, { params }: Context) => {
215
+ const { threadId } = await params;
216
+ const { input } = (await req.json()) as { input: PiSendMessageInput };
217
+ await piClient.sendMessage(threadId, input);
218
+ return noContent();
219
+ });
220
+
221
+ ```
222
+
223
+ ## app/api/pi/threads/[threadId]/model/route.ts
224
+
225
+ ```typescript
226
+ import type { NextRequest } from "next/server";
227
+ import { piClient } from "@/lib/pi-server";
228
+ import { noContent, withFail } from "@/lib/http";
229
+
230
+ export const runtime = "nodejs";
231
+ export const dynamic = "force-dynamic";
232
+
233
+ type Context = { params: Promise<{ threadId: string }> };
234
+
235
+ export const POST = withFail(async (req: NextRequest, { params }: Context) => {
236
+ const { threadId } = await params;
237
+ const input = (await req.json()) as { provider: string; modelId: string };
238
+ await piClient.setModel(threadId, input);
239
+ return noContent();
240
+ });
241
+
242
+ ```
243
+
244
+ ## app/api/pi/threads/[threadId]/queue/clear/route.ts
245
+
246
+ ```typescript
247
+ import type { NextRequest } from "next/server";
248
+ import { piClient } from "@/lib/pi-server";
249
+ import { withFail } from "@/lib/http";
250
+
251
+ export const runtime = "nodejs";
252
+ export const dynamic = "force-dynamic";
253
+
254
+ type Context = { params: Promise<{ threadId: string }> };
255
+
256
+ export const POST = withFail(async (_req: NextRequest, { params }: Context) => {
257
+ const { threadId } = await params;
258
+ return Response.json(await piClient.clearQueue(threadId));
259
+ });
260
+
261
+ ```
262
+
263
+ ## app/api/pi/threads/[threadId]/route.ts
264
+
265
+ ```typescript
266
+ import type { NextRequest } from "next/server";
267
+ import { piClient } from "@/lib/pi-server";
268
+ import { badRequest, noContent, withFail } from "@/lib/http";
269
+
270
+ export const runtime = "nodejs";
271
+ export const dynamic = "force-dynamic";
272
+
273
+ type Context = { params: Promise<{ threadId: string }> };
274
+
275
+ export const GET = withFail(async (_req: NextRequest, { params }: Context) => {
276
+ const { threadId } = await params;
277
+ return Response.json(await piClient.getThread(threadId));
278
+ });
279
+
280
+ export const PATCH = withFail(async (req: NextRequest, { params }: Context) => {
281
+ const { threadId } = await params;
282
+ const { title } = (await req.json()) as { title?: unknown };
283
+ if (typeof title !== "string" || title.trim() === "") {
284
+ return badRequest("title must be a non-empty string");
285
+ }
286
+ await piClient.renameThread(threadId, title);
287
+ return noContent();
288
+ });
289
+
290
+ export const DELETE = withFail(
291
+ async (_req: NextRequest, { params }: Context) => {
292
+ const { threadId } = await params;
293
+ await piClient.deleteThread(threadId);
294
+ return noContent();
295
+ },
296
+ );
297
+
298
+ ```
299
+
300
+ ## app/api/pi/threads/[threadId]/thinking/route.ts
301
+
302
+ ```typescript
303
+ import type { NextRequest } from "next/server";
304
+ import type { PiThinkingLevel } from "@assistant-ui/react-pi";
305
+ import { piClient } from "@/lib/pi-server";
306
+ import { noContent, withFail } from "@/lib/http";
307
+
308
+ export const runtime = "nodejs";
309
+ export const dynamic = "force-dynamic";
310
+
311
+ type Context = { params: Promise<{ threadId: string }> };
312
+
313
+ export const POST = withFail(async (req: NextRequest, { params }: Context) => {
314
+ const { threadId } = await params;
315
+ const { level } = (await req.json()) as { level: PiThinkingLevel };
316
+ await piClient.setThinkingLevel(threadId, level);
317
+ return noContent();
318
+ });
319
+
320
+ ```
321
+
322
+ ## app/api/pi/threads/[threadId]/unarchive/route.ts
323
+
324
+ ```typescript
325
+ import type { NextRequest } from "next/server";
326
+ import { piClient } from "@/lib/pi-server";
327
+ import { noContent, withFail } from "@/lib/http";
328
+
329
+ export const runtime = "nodejs";
330
+ export const dynamic = "force-dynamic";
331
+
332
+ type Context = { params: Promise<{ threadId: string }> };
333
+
334
+ export const POST = withFail(async (_req: NextRequest, { params }: Context) => {
335
+ const { threadId } = await params;
336
+ await piClient.unarchiveThread(threadId);
337
+ return noContent();
338
+ });
339
+
340
+ ```
341
+
342
+ ## app/api/pi/threads/route.ts
343
+
344
+ ```typescript
345
+ import type { NextRequest } from "next/server";
346
+ import { piClient } from "@/lib/pi-server";
347
+ import { withFail } from "@/lib/http";
348
+
349
+ export const runtime = "nodejs";
350
+ export const dynamic = "force-dynamic";
351
+
352
+ export const GET = withFail(async (req: NextRequest) => {
353
+ const params = new URL(req.url).searchParams;
354
+ const workspacePath = params.get("workspacePath") ?? undefined;
355
+ const includeArchived = params.get("includeArchived") === "true";
356
+ const threads = await piClient.listThreads({
357
+ ...(workspacePath ? { workspacePath } : {}),
358
+ includeArchived,
359
+ });
360
+ return Response.json(threads);
361
+ });
362
+
363
+ export const POST = withFail(async (req: NextRequest) => {
364
+ const body = await req.json().catch(() => ({}));
365
+ return Response.json(await piClient.createThread(body));
366
+ });
367
+
368
+ ```
369
+
370
+ ## app/globals.css
371
+
372
+ ```css
373
+ @import "tailwindcss";
374
+ @import "tw-animate-css";
375
+
376
+ @source "../../../packages/ui/src";
377
+
378
+ @custom-variant dark (&:is(.dark *));
379
+
380
+ @theme inline {
381
+ --radius-sm: calc(var(--radius) - 4px);
382
+ --radius-md: calc(var(--radius) - 2px);
383
+ --radius-lg: var(--radius);
384
+ --radius-xl: calc(var(--radius) + 4px);
385
+ --color-background: var(--background);
386
+ --color-foreground: var(--foreground);
387
+ --color-card: var(--card);
388
+ --color-card-foreground: var(--card-foreground);
389
+ --color-popover: var(--popover);
390
+ --color-popover-foreground: var(--popover-foreground);
391
+ --color-primary: var(--primary);
392
+ --color-primary-foreground: var(--primary-foreground);
393
+ --color-secondary: var(--secondary);
394
+ --color-secondary-foreground: var(--secondary-foreground);
395
+ --color-muted: var(--muted);
396
+ --color-muted-foreground: var(--muted-foreground);
397
+ --color-accent: var(--accent);
398
+ --color-accent-foreground: var(--accent-foreground);
399
+ --color-destructive: var(--destructive);
400
+ --color-border: var(--border);
401
+ --color-input: var(--input);
402
+ --color-ring: var(--ring);
403
+ --color-chart-1: var(--chart-1);
404
+ --color-chart-2: var(--chart-2);
405
+ --color-chart-3: var(--chart-3);
406
+ --color-chart-4: var(--chart-4);
407
+ --color-chart-5: var(--chart-5);
408
+ --color-sidebar: var(--sidebar);
409
+ --color-sidebar-foreground: var(--sidebar-foreground);
410
+ --color-sidebar-primary: var(--sidebar-primary);
411
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
412
+ --color-sidebar-accent: var(--sidebar-accent);
413
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
414
+ --color-sidebar-border: var(--sidebar-border);
415
+ --color-sidebar-ring: var(--sidebar-ring);
416
+ }
417
+
418
+ :root {
419
+ --radius: 0.625rem;
420
+ --background: oklch(1 0 0);
421
+ --foreground: oklch(0.141 0.005 285.823);
422
+ --card: oklch(1 0 0);
423
+ --card-foreground: oklch(0.141 0.005 285.823);
424
+ --popover: oklch(1 0 0);
425
+ --popover-foreground: oklch(0.141 0.005 285.823);
426
+ --primary: oklch(0.21 0.006 285.885);
427
+ --primary-foreground: oklch(0.985 0 0);
428
+ --secondary: oklch(0.967 0.001 286.375);
429
+ --secondary-foreground: oklch(0.21 0.006 285.885);
430
+ --muted: oklch(0.967 0.001 286.375);
431
+ --muted-foreground: oklch(0.552 0.016 285.938);
432
+ --accent: oklch(0.967 0.001 286.375);
433
+ --accent-foreground: oklch(0.21 0.006 285.885);
434
+ --destructive: oklch(0.577 0.245 27.325);
435
+ --border: oklch(0.92 0.004 286.32);
436
+ --input: oklch(0.92 0.004 286.32);
437
+ --ring: oklch(0.705 0.015 286.067);
438
+ --chart-1: oklch(0.646 0.222 41.116);
439
+ --chart-2: oklch(0.6 0.118 184.704);
440
+ --chart-3: oklch(0.398 0.07 227.392);
441
+ --chart-4: oklch(0.828 0.189 84.429);
442
+ --chart-5: oklch(0.769 0.188 70.08);
443
+ --sidebar: oklch(0.985 0 0);
444
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
445
+ --sidebar-primary: oklch(0.21 0.006 285.885);
446
+ --sidebar-primary-foreground: oklch(0.985 0 0);
447
+ --sidebar-accent: oklch(0.967 0.001 286.375);
448
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
449
+ --sidebar-border: oklch(0.92 0.004 286.32);
450
+ --sidebar-ring: oklch(0.705 0.015 286.067);
451
+ }
452
+
453
+ .dark {
454
+ --background: oklch(0.141 0.005 285.823);
455
+ --foreground: oklch(0.985 0 0);
456
+ --card: oklch(0.21 0.006 285.885);
457
+ --card-foreground: oklch(0.985 0 0);
458
+ --popover: oklch(0.21 0.006 285.885);
459
+ --popover-foreground: oklch(0.985 0 0);
460
+ --primary: oklch(0.92 0.004 286.32);
461
+ --primary-foreground: oklch(0.21 0.006 285.885);
462
+ --secondary: oklch(0.274 0.006 286.033);
463
+ --secondary-foreground: oklch(0.985 0 0);
464
+ --muted: oklch(0.274 0.006 286.033);
465
+ --muted-foreground: oklch(0.705 0.015 286.067);
466
+ --accent: oklch(0.274 0.006 286.033);
467
+ --accent-foreground: oklch(0.985 0 0);
468
+ --destructive: oklch(0.704 0.191 22.216);
469
+ --border: oklch(1 0 0 / 10%);
470
+ --input: oklch(1 0 0 / 15%);
471
+ --ring: oklch(0.552 0.016 285.938);
472
+ --chart-1: oklch(0.488 0.243 264.376);
473
+ --chart-2: oklch(0.696 0.17 162.48);
474
+ --chart-3: oklch(0.769 0.188 70.08);
475
+ --chart-4: oklch(0.627 0.265 303.9);
476
+ --chart-5: oklch(0.645 0.246 16.439);
477
+ --sidebar: oklch(0.21 0.006 285.885);
478
+ --sidebar-foreground: oklch(0.985 0 0);
479
+ --sidebar-primary: oklch(0.488 0.243 264.376);
480
+ --sidebar-primary-foreground: oklch(0.985 0 0);
481
+ --sidebar-accent: oklch(0.274 0.006 286.033);
482
+ --sidebar-accent-foreground: oklch(0.985 0 0);
483
+ --sidebar-border: oklch(1 0 0 / 10%);
484
+ --sidebar-ring: oklch(0.552 0.016 285.938);
485
+ }
486
+
487
+ @layer base {
488
+ * {
489
+ @apply border-border outline-ring/50;
490
+ }
491
+ body {
492
+ @apply bg-background text-foreground;
493
+ }
494
+ }
495
+
496
+ ```
497
+
498
+ ## app/layout.tsx
499
+
500
+ ```tsx
501
+ import "./globals.css";
502
+ import type { Metadata } from "next";
503
+ import type { ReactNode } from "react";
504
+
505
+ export const metadata: Metadata = {
506
+ title: "assistant-ui × Pi",
507
+ description: "Pi coding-agent runtime adapter for assistant-ui",
508
+ };
509
+
510
+ export default function RootLayout({ children }: { children: ReactNode }) {
511
+ return (
512
+ <html lang="en" className="h-dvh">
513
+ <body className="h-dvh overflow-hidden">{children}</body>
514
+ </html>
515
+ );
516
+ }
517
+
518
+ ```
519
+
520
+ ## app/page.tsx
521
+
522
+ ```tsx
523
+ "use client";
524
+
525
+ import { useEffect, useState } from "react";
526
+ import { Thread } from "../components/assistant-ui/thread";
527
+ import { ThreadList } from "@/components/assistant-ui/thread-list";
528
+ import { usePiThreadState } from "@assistant-ui/react-pi";
529
+ import type { PiRuntimeReadiness } from "@assistant-ui/react-pi";
530
+ // Type-only import — erased at build time, so no server code reaches the client.
531
+ import type { PiHandshake } from "@/lib/pi-server";
532
+ import { PiHandshakeProvider } from "../components/pi-handshake";
533
+ import { PiRuntimeProvider } from "./PiRuntimeProvider";
534
+ import { WorkspaceBrowser } from "../components/workspace-browser";
535
+
536
+ export default function Home() {
537
+ const [handshake, setHandshake] = useState<PiHandshake | null>(null);
538
+ const [workspacePath, setWorkspacePath] = useState("");
539
+
540
+ useEffect(() => {
541
+ let active = true;
542
+ fetch("/api/pi/handshake")
543
+ .then((response) => response.json())
544
+ .then((data: PiHandshake) => {
545
+ if (!active) return;
546
+ setHandshake(data);
547
+ setWorkspacePath(data.workspacePath ?? "");
548
+ })
549
+ .catch(() => {
550
+ /* handshake is best-effort; the runtime still works on env defaults. */
551
+ });
552
+ return () => {
553
+ active = false;
554
+ };
555
+ }, []);
556
+
557
+ return (
558
+ <PiRuntimeProvider workspacePath={workspacePath || undefined}>
559
+ <PiHandshakeProvider value={handshake}>
560
+ <div className="flex h-dvh flex-col overflow-hidden">
561
+ <Header
562
+ workspacePath={workspacePath}
563
+ onCommitWorkspace={setWorkspacePath}
564
+ />
565
+ <ReadinessBanner fallback={handshake?.readiness} />
566
+ <div className="flex min-h-0 flex-grow">
567
+ <aside className="flex w-72 shrink-0 flex-col overflow-hidden border-r">
568
+ <div className="flex-1 overflow-y-auto p-3">
569
+ <ThreadList />
570
+ </div>
571
+ </aside>
572
+ <main className="flex min-h-0 min-w-0 flex-grow flex-col overflow-hidden">
573
+ <Thread />
574
+ </main>
575
+ </div>
576
+ </div>
577
+ </PiHandshakeProvider>
578
+ </PiRuntimeProvider>
579
+ );
580
+ }
581
+
582
+ function Header({
583
+ workspacePath,
584
+ onCommitWorkspace,
585
+ }: {
586
+ workspacePath: string;
587
+ onCommitWorkspace: (value: string) => void;
588
+ }) {
589
+ return (
590
+ <header className="flex items-center gap-3 border-b px-4 py-2">
591
+ <span className="font-semibold">assistant-ui × Pi</span>
592
+
593
+ <div className="ml-auto flex items-center gap-2">
594
+ <WorkspaceBrowser value={workspacePath} onCommit={onCommitWorkspace} />
595
+ </div>
596
+ </header>
597
+ );
598
+ }
599
+
600
+ function ReadinessBanner({
601
+ fallback,
602
+ }: {
603
+ fallback?: PiRuntimeReadiness | undefined;
604
+ }) {
605
+ const live = usePiThreadState((state) => state.readiness);
606
+ const readiness = live ?? fallback;
607
+ if (!readiness || readiness.state === "ready") return null;
608
+ return (
609
+ <div className="bg-destructive/10 text-destructive border-destructive/20 border-b px-4 py-2 text-sm">
610
+ {readiness.message ?? "The Pi runtime has no usable model selected."}
611
+ </div>
612
+ );
613
+ }
614
+
615
+ ```
616
+
617
+ ## app/PiRuntimeProvider.tsx
618
+
619
+ ```tsx
620
+ "use client";
621
+
622
+ import {
623
+ AssistantRuntimeProvider,
624
+ SimpleImageAttachmentAdapter,
625
+ } from "@assistant-ui/react";
626
+ import { createPiHttpClient, usePiRuntime } from "@assistant-ui/react-pi";
627
+ import { useMemo, type ReactNode } from "react";
628
+
629
+ /**
630
+ * Wires the browser `PiClient` (HTTP/SSE over `/api/pi`) into a Pi runtime. The
631
+ * client is stable for the app's lifetime; `workspacePath` scopes the thread
632
+ * list and seeds new sessions. Image attachments ride along as Pi
633
+ * `ImageContent` (the adapter's data URLs pass through `buildPiSendInput`).
634
+ */
635
+ export function PiRuntimeProvider({
636
+ workspacePath,
637
+ children,
638
+ }: {
639
+ workspacePath?: string | undefined;
640
+ children: ReactNode;
641
+ }) {
642
+ const client = useMemo(() => createPiHttpClient(), []);
643
+ const adapters = useMemo(
644
+ () => ({ attachments: new SimpleImageAttachmentAdapter() }),
645
+ [],
646
+ );
647
+ const runtime = usePiRuntime({
648
+ client,
649
+ adapters,
650
+ ...(workspacePath ? { workspacePath } : {}),
651
+ });
652
+
653
+ return (
654
+ <AssistantRuntimeProvider runtime={runtime}>
655
+ {children}
656
+ </AssistantRuntimeProvider>
657
+ );
658
+ }
659
+
660
+ ```
661
+
662
+ ## components.json
663
+
664
+ ```json
665
+ {
666
+ "$schema": "https://ui.shadcn.com/schema.json",
667
+ "style": "new-york",
668
+ "rsc": true,
669
+ "tsx": true,
670
+ "tailwind": {
671
+ "config": "",
672
+ "css": "app/globals.css",
673
+ "baseColor": "zinc",
674
+ "cssVariables": true,
675
+ "prefix": ""
676
+ },
677
+ "aliases": {
678
+ "components": "@/components",
679
+ "utils": "@/lib/utils",
680
+ "ui": "@/components/ui",
681
+ "lib": "@/lib",
682
+ "hooks": "@/hooks"
683
+ },
684
+ "iconLibrary": "lucide",
685
+ "registries": {
686
+ "@assistant-ui": "https://r.assistant-ui.com/{name}.json"
687
+ }
688
+ }
689
+
690
+ ```
691
+
692
+ ## components/assistant-ui/thinking-level-slider.tsx
693
+
694
+ ```tsx
695
+ "use client";
696
+
697
+ import type { KeyboardEvent } from "react";
698
+ import { useModelSelectorEfforts } from "@/components/assistant-ui/model-selector";
699
+ import {
700
+ Tooltip,
701
+ TooltipContent,
702
+ TooltipTrigger,
703
+ } from "@/components/ui/tooltip";
704
+ import { cn } from "@/lib/utils";
705
+
706
+ /**
707
+ * Line-graph style thinking level picker, composed into ModelSelector.Content
708
+ * via the useModelSelectorEfforts hook. Pi models expose up to six levels,
709
+ * which overflow the built-in horizontal ModelSelector.Effort layout — here
710
+ * each level is a thin vertical line that grows on hover and names itself in
711
+ * a tooltip, with the active level standing taller in the accent color.
712
+ */
713
+ export const ThinkingLevelSlider = ({
714
+ label = "Thinking",
715
+ }: {
716
+ label?: string;
717
+ }) => {
718
+ const { efforts, effort, setEffort } = useModelSelectorEfforts();
719
+
720
+ if (!efforts?.length) return null;
721
+
722
+ const activeIndex = efforts.findIndex((e) => e.id === effort);
723
+ const lastIndex = efforts.length - 1;
724
+
725
+ const selectIndex = (event: KeyboardEvent<HTMLDivElement>, index: number) => {
726
+ const target = Math.min(Math.max(index, 0), lastIndex);
727
+ const next = efforts[target];
728
+ if (!next) return;
729
+ event.preventDefault();
730
+ setEffort(next.id);
731
+ event.currentTarget
732
+ .querySelectorAll<HTMLElement>('[role="radio"]')
733
+ [target]?.focus();
734
+ };
735
+
736
+ return (
737
+ <div className="flex items-center justify-between gap-3 border-t px-3 py-2">
738
+ <span className="text-muted-foreground text-xs">{label}</span>
739
+ <div
740
+ role="radiogroup"
741
+ aria-label={label}
742
+ className="flex items-end"
743
+ onKeyDown={(event) => {
744
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
745
+ selectIndex(event, activeIndex + 1);
746
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
747
+ selectIndex(event, activeIndex - 1);
748
+ } else if (event.key === "Home") {
749
+ selectIndex(event, 0);
750
+ } else if (event.key === "End") {
751
+ selectIndex(event, lastIndex);
752
+ }
753
+ }}
754
+ >
755
+ {efforts.map((option, index) => {
756
+ const isActive = index === activeIndex;
757
+ return (
758
+ <Tooltip key={option.id} disableHoverableContent>
759
+ <TooltipTrigger asChild>
760
+ <button
761
+ type="button"
762
+ role="radio"
763
+ aria-checked={isActive}
764
+ aria-label={option.name}
765
+ tabIndex={index === Math.max(activeIndex, 0) ? 0 : -1}
766
+ data-state={isActive ? "on" : "off"}
767
+ onClick={() => setEffort(option.id)}
768
+ className="group focus-visible:ring-ring/50 flex h-6 w-3.5 items-end justify-center rounded-sm outline-none focus-visible:ring-2"
769
+ >
770
+ <span
771
+ className={cn(
772
+ "rounded-full transition-all duration-150 ease-out",
773
+ isActive
774
+ ? "bg-primary h-4.5 w-0.5"
775
+ : "bg-muted-foreground/40 group-hover:bg-foreground/70 h-2.5 w-0.5 group-hover:h-4 group-hover:w-[3px] group-focus-visible:h-4 group-focus-visible:w-[3px]",
776
+ )}
777
+ />
778
+ </button>
779
+ </TooltipTrigger>
780
+ <TooltipContent side="top" sideOffset={2}>
781
+ {option.name}
782
+ </TooltipContent>
783
+ </Tooltip>
784
+ );
785
+ })}
786
+ </div>
787
+ <span className="text-foreground min-w-12 text-end text-xs">
788
+ {efforts[activeIndex]?.name}
789
+ </span>
790
+ </div>
791
+ );
792
+ };
793
+
794
+ ```
795
+
796
+ ## components/assistant-ui/thread.tsx
797
+
798
+ ```tsx
799
+ "use client";
800
+
801
+ import {
802
+ ComposerAddAttachment,
803
+ ComposerAttachments,
804
+ UserMessageAttachments,
805
+ } from "@/components/assistant-ui/attachment";
806
+ import { ContextDisplay } from "@/components/assistant-ui/context-display";
807
+ import { ModelSelector } from "@/components/assistant-ui/model-selector";
808
+ import { MarkdownText } from "@/components/assistant-ui/markdown-text";
809
+ import {
810
+ isPiSteerQueueItemId,
811
+ responseForRequest,
812
+ usePiHostUiRequests,
813
+ usePiRuntimeExtras,
814
+ usePiThreadState,
815
+ type PiHostUiRequest,
816
+ type PiThinkingLevel,
817
+ } from "@assistant-ui/react-pi";
818
+ import { usePiHandshake } from "../pi-handshake";
819
+ import { modelKey } from "@/lib/model-key";
820
+ import { ThinkingLevelSlider } from "./thinking-level-slider";
821
+ import {
822
+ Reasoning,
823
+ ReasoningContent,
824
+ ReasoningRoot,
825
+ ReasoningText,
826
+ ReasoningTrigger,
827
+ } from "@/components/assistant-ui/reasoning";
828
+ import {
829
+ ToolGroupContent,
830
+ ToolGroupRoot,
831
+ ToolGroupTrigger,
832
+ } from "@/components/assistant-ui/tool-group";
833
+ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
834
+ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
835
+ import { Button } from "@/components/ui/button";
836
+ import { cn } from "@/lib/utils";
837
+ import {
838
+ ActionBarMorePrimitive,
839
+ ActionBarPrimitive,
840
+ AuiIf,
841
+ BranchPickerPrimitive,
842
+ ComposerPrimitive,
843
+ ErrorPrimitive,
844
+ groupPartByType,
845
+ MessagePrimitive,
846
+ SuggestionPrimitive,
847
+ ThreadPrimitive,
848
+ useAui,
849
+ useAuiState,
850
+ } from "@assistant-ui/react";
851
+ import {
852
+ ArrowDownIcon,
853
+ ArrowUpIcon,
854
+ CheckIcon,
855
+ ChevronLeftIcon,
856
+ ChevronRightIcon,
857
+ CopyIcon,
858
+ DownloadIcon,
859
+ ListEndIcon,
860
+ MoreHorizontalIcon,
861
+ PencilIcon,
862
+ RefreshCwIcon,
863
+ SquareIcon,
864
+ Trash2Icon,
865
+ WrenchIcon,
866
+ } from "lucide-react";
867
+ import { useState, type FC } from "react";
868
+
869
+ export const Thread: FC = () => {
870
+ return (
871
+ <ThreadPrimitive.Root
872
+ className="aui-root aui-thread-root bg-background @container flex h-full flex-col"
873
+ style={{
874
+ ["--thread-max-width" as string]: "44rem",
875
+ ["--composer-radius" as string]: "24px",
876
+ ["--composer-padding" as string]: "10px",
877
+ }}
878
+ >
879
+ <ThreadPrimitive.Viewport
880
+ data-slot="aui_thread-viewport"
881
+ className="relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth"
882
+ >
883
+ <div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4">
884
+ <AuiIf condition={(s) => s.thread.isEmpty}>
885
+ <ThreadWelcome />
886
+ </AuiIf>
887
+
888
+ <div
889
+ data-slot="aui_message-group"
890
+ className="mb-10 flex flex-col gap-y-8 empty:hidden"
891
+ >
892
+ <ThreadPrimitive.Messages>
893
+ {() => <ThreadMessage />}
894
+ </ThreadPrimitive.Messages>
895
+ </div>
896
+
897
+ <ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer bg-background sticky bottom-0 mt-auto flex flex-col gap-4 overflow-visible rounded-t-(--composer-radius) pb-4 md:pb-6">
898
+ <ThreadScrollToBottom />
899
+ <HostUiRequests />
900
+ <Composer />
901
+ </ThreadPrimitive.ViewportFooter>
902
+ </div>
903
+ </ThreadPrimitive.Viewport>
904
+ </ThreadPrimitive.Root>
905
+ );
906
+ };
907
+
908
+ const ThreadMessage: FC = () => {
909
+ const role = useAuiState((s) => s.message.role);
910
+ const isEditing = useAuiState((s) => s.message.composer.isEditing);
911
+
912
+ if (isEditing) return <EditComposer />;
913
+ if (role === "user") return <UserMessage />;
914
+ return <AssistantMessage />;
915
+ };
916
+
917
+ const ThreadScrollToBottom: FC = () => {
918
+ return (
919
+ <ThreadPrimitive.ScrollToBottom asChild>
920
+ <TooltipIconButton
921
+ tooltip="Scroll to bottom"
922
+ variant="outline"
923
+ className="aui-thread-scroll-to-bottom dark:border-border dark:bg-background dark:hover:bg-accent absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible"
924
+ >
925
+ <ArrowDownIcon />
926
+ </TooltipIconButton>
927
+ </ThreadPrimitive.ScrollToBottom>
928
+ );
929
+ };
930
+
931
+ const ThreadWelcome: FC = () => {
932
+ return (
933
+ <div className="aui-thread-welcome-root my-auto flex grow flex-col">
934
+ <div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
935
+ <div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
936
+ <h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-2xl font-semibold duration-200">
937
+ Hello there!
938
+ </h1>
939
+ <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">
940
+ How can I help you today?
941
+ </p>
942
+ </div>
943
+ </div>
944
+ <ThreadSuggestions />
945
+ </div>
946
+ );
947
+ };
948
+
949
+ const ThreadSuggestions: FC = () => {
950
+ return (
951
+ <div className="aui-thread-welcome-suggestions grid w-full gap-2 pb-4 @md:grid-cols-2">
952
+ <ThreadPrimitive.Suggestions>
953
+ {() => <ThreadSuggestionItem />}
954
+ </ThreadPrimitive.Suggestions>
955
+ </div>
956
+ );
957
+ };
958
+
959
+ const ThreadSuggestionItem: FC = () => {
960
+ return (
961
+ <div className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 animate-in fill-mode-both duration-200 nth-[n+3]:hidden @md:nth-[n+3]:block">
962
+ <SuggestionPrimitive.Trigger send asChild>
963
+ <Button
964
+ variant="ghost"
965
+ className="aui-thread-welcome-suggestion bg-background hover:bg-muted h-auto w-full flex-wrap items-start justify-start gap-1 rounded-3xl border px-4 py-3 text-start text-sm transition-colors @md:flex-col"
966
+ >
967
+ <SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1 font-medium" />
968
+ <SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 text-muted-foreground empty:hidden" />
969
+ </Button>
970
+ </SuggestionPrimitive.Trigger>
971
+ </div>
972
+ );
973
+ };
974
+
975
+ const HostUiRequests: FC = () => {
976
+ const { requests, respond } = usePiHostUiRequests();
977
+ const request = requests[0];
978
+ if (!request) return null;
979
+ return (
980
+ <HostUiRequestCard key={request.id} request={request} respond={respond} />
981
+ );
982
+ };
983
+
984
+ const HostUiRequestCard: FC<{
985
+ request: PiHostUiRequest;
986
+ respond: ReturnType<typeof usePiHostUiRequests>["respond"];
987
+ }> = ({ request, respond }) => {
988
+ const [value, setValue] = useState(
989
+ request.kind === "editor" ? (request.prefill ?? "") : "",
990
+ );
991
+
992
+ const submitValue = () => {
993
+ void respond(responseForRequest(request, value));
994
+ };
995
+
996
+ return (
997
+ <div className="border-border bg-background mx-2 rounded-2xl border p-3 text-sm shadow-sm">
998
+ <div className="font-medium">{request.title}</div>
999
+ {request.kind === "confirm" ? (
1000
+ <p className="text-muted-foreground mt-1">{request.message}</p>
1001
+ ) : request.kind === "select" ? (
1002
+ <div className="mt-2 flex flex-wrap gap-2">
1003
+ {request.options.map((option) => (
1004
+ <Button
1005
+ key={option}
1006
+ size="sm"
1007
+ variant="outline"
1008
+ onClick={() => void respond(responseForRequest(request, option))}
1009
+ >
1010
+ {option}
1011
+ </Button>
1012
+ ))}
1013
+ </div>
1014
+ ) : request.kind === "editor" ? (
1015
+ <textarea
1016
+ value={value}
1017
+ onChange={(event) => setValue(event.currentTarget.value)}
1018
+ className="border-input mt-2 min-h-24 w-full rounded-md border bg-transparent p-2 font-mono text-xs"
1019
+ />
1020
+ ) : (
1021
+ <input
1022
+ value={value}
1023
+ placeholder={request.placeholder}
1024
+ onChange={(event) => setValue(event.currentTarget.value)}
1025
+ className="border-input mt-2 w-full rounded-md border bg-transparent px-2 py-1"
1026
+ />
1027
+ )}
1028
+ <div className="mt-3 flex justify-end gap-2">
1029
+ <Button
1030
+ size="sm"
1031
+ variant="ghost"
1032
+ onClick={() =>
1033
+ void respond(
1034
+ request.kind === "confirm"
1035
+ ? responseForRequest(request, false)
1036
+ : { requestId: request.id, dismissed: true },
1037
+ )
1038
+ }
1039
+ >
1040
+ {request.kind === "confirm" ? "Deny" : "Dismiss"}
1041
+ </Button>
1042
+ {request.kind !== "select" && (
1043
+ <Button
1044
+ size="sm"
1045
+ onClick={() =>
1046
+ request.kind === "confirm"
1047
+ ? void respond(responseForRequest(request, true))
1048
+ : submitValue()
1049
+ }
1050
+ >
1051
+ {request.kind === "confirm" ? "Approve" : "Submit"}
1052
+ </Button>
1053
+ )}
1054
+ </div>
1055
+ </div>
1056
+ );
1057
+ };
1058
+
1059
+ /** Mid-run sends sit in Pi's queue (not the thread); this card mirrors them
1060
+ * above the composer. Pi only supports clearing the whole queue — no per-item
1061
+ * remove/promote — so the lone action restores all queued text to the composer. */
1062
+ const ComposerQueue: FC = () => {
1063
+ const aui = useAui();
1064
+ const { clearQueue } = usePiRuntimeExtras();
1065
+ const queueLength = useAuiState((s) => s.composer.queue.length);
1066
+ if (queueLength === 0) return null;
1067
+
1068
+ const handleClear = () => {
1069
+ clearQueue()
1070
+ .then(({ steering, followUp }) => {
1071
+ const restored = [...steering, ...followUp].join("\n");
1072
+ if (!restored) return;
1073
+ const composer = aui.composer();
1074
+ const current = composer.getState().text;
1075
+ composer.setText(current ? `${current}\n${restored}` : restored);
1076
+ })
1077
+ .catch((error: unknown) => console.error("Failed to clear queue", error));
1078
+ };
1079
+
1080
+ return (
1081
+ <div className="bg-muted/50 border-border/60 text-muted-foreground -mb-4 flex flex-col gap-1.5 rounded-t-(--composer-radius) border border-b-0 px-4 pt-2.5 pb-7 text-sm">
1082
+ <div className="flex items-center justify-between gap-2">
1083
+ <span className="text-xs font-medium">Queued</span>
1084
+ <Button
1085
+ variant="ghost"
1086
+ size="sm"
1087
+ className="h-6 gap-1 px-2 text-xs"
1088
+ onClick={handleClear}
1089
+ >
1090
+ <Trash2Icon className="size-3" />
1091
+ Clear
1092
+ </Button>
1093
+ </div>
1094
+ <ComposerPrimitive.Queue>
1095
+ {({ queueItem }) => (
1096
+ <div className="flex items-center gap-2">
1097
+ <ListEndIcon className="size-3.5 shrink-0" />
1098
+ <span className="min-w-0 flex-1 truncate">{queueItem.prompt}</span>
1099
+ {isPiSteerQueueItemId(queueItem.id) && (
1100
+ <span className="border-border rounded-full border px-1.5 text-[10px] uppercase">
1101
+ steer
1102
+ </span>
1103
+ )}
1104
+ </div>
1105
+ )}
1106
+ </ComposerPrimitive.Queue>
1107
+ </div>
1108
+ );
1109
+ };
1110
+
1111
+ const Composer: FC = () => {
1112
+ return (
1113
+ <ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
1114
+ <ComposerQueue />
1115
+ <ComposerPrimitive.AttachmentDropzone asChild>
1116
+ <div
1117
+ data-slot="aui_composer-shell"
1118
+ className="bg-background focus-within:border-ring/75 focus-within:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50 flex w-full flex-col gap-2 rounded-(--composer-radius) border p-(--composer-padding) transition-shadow focus-within:ring-2 data-[dragging=true]:border-dashed"
1119
+ >
1120
+ <ComposerAttachments />
1121
+ <ComposerPrimitive.Input
1122
+ placeholder="Send a message..."
1123
+ className="aui-composer-input placeholder:text-muted-foreground/80 max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none"
1124
+ rows={1}
1125
+ autoFocus
1126
+ aria-label="Message input"
1127
+ />
1128
+ <ComposerAction />
1129
+ </div>
1130
+ </ComposerPrimitive.AttachmentDropzone>
1131
+ </ComposerPrimitive.Root>
1132
+ );
1133
+ };
1134
+
1135
+ const ComposerAction: FC = () => {
1136
+ return (
1137
+ <div className="aui-composer-action-wrapper relative flex items-center justify-between">
1138
+ <div className="flex items-center gap-1">
1139
+ <ComposerAddAttachment />
1140
+ <ComposerModelSelector />
1141
+ </div>
1142
+ <div className="flex items-center gap-1.5">
1143
+ <ComposerContextRing />
1144
+ {/* Pi queues mid-run sends (follow-up/steer), so Send stays available
1145
+ while text is typed even during a run; Stop shows when empty. */}
1146
+ <AuiIf condition={(s) => !s.thread.isRunning || !s.composer.isEmpty}>
1147
+ <ComposerPrimitive.Send asChild>
1148
+ <TooltipIconButton
1149
+ tooltip="Send message"
1150
+ side="bottom"
1151
+ type="button"
1152
+ variant="default"
1153
+ size="icon"
1154
+ className="aui-composer-send size-8 rounded-full"
1155
+ aria-label="Send message"
1156
+ >
1157
+ <ArrowUpIcon className="aui-composer-send-icon size-4" />
1158
+ </TooltipIconButton>
1159
+ </ComposerPrimitive.Send>
1160
+ </AuiIf>
1161
+ <AuiIf condition={(s) => s.thread.isRunning && s.composer.isEmpty}>
1162
+ <ComposerPrimitive.Cancel asChild>
1163
+ <Button
1164
+ type="button"
1165
+ variant="default"
1166
+ size="icon"
1167
+ className="aui-composer-cancel size-8 rounded-full"
1168
+ aria-label="Stop generating"
1169
+ >
1170
+ <SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
1171
+ </Button>
1172
+ </ComposerPrimitive.Cancel>
1173
+ </AuiIf>
1174
+ </div>
1175
+ </div>
1176
+ );
1177
+ };
1178
+
1179
+ const selectedModelKey = (provider?: string, modelId?: string) =>
1180
+ provider && modelId ? modelKey(provider, modelId) : undefined;
1181
+
1182
+ /** Model + thinking-level picker seeded from the server handshake and wired to
1183
+ * Pi's per-thread `session.setModel`/`session.setThinkingLevel`. Per-model
1184
+ * thinking levels come from the handshake; Pi clamps unsupported levels and the
1185
+ * effective config is reflected by the next snapshot/event. */
1186
+ const ComposerModelSelector: FC = () => {
1187
+ const handshake = usePiHandshake();
1188
+ const { metadata, setModel, setThinkingLevel, status } = usePiRuntimeExtras();
1189
+ const selected =
1190
+ selectedModelKey(metadata.config?.provider, metadata.config?.modelId) ??
1191
+ handshake?.selectedModelId;
1192
+ const thinkingLevel = metadata.config?.thinkingLevel;
1193
+ if (!handshake || handshake.models.length === 0) return null;
1194
+
1195
+ return (
1196
+ <ModelSelector.Root
1197
+ models={handshake.models}
1198
+ {...(selected ? { value: selected } : {})}
1199
+ onValueChange={(value) => {
1200
+ if (status === "running") return;
1201
+ const model = handshake.models.find((m) => m.id === value);
1202
+ if (!model) return;
1203
+ void setModel({ provider: model.provider, modelId: model.modelId });
1204
+ }}
1205
+ effort={typeof thinkingLevel === "string" ? thinkingLevel : "medium"}
1206
+ onEffortChange={(effort) => {
1207
+ if (status === "running") return;
1208
+ void setThinkingLevel(effort as PiThinkingLevel);
1209
+ }}
1210
+ >
1211
+ <ModelSelector.Trigger variant="ghost" size="sm" />
1212
+ <ModelSelector.Content>
1213
+ <ModelSelector.Search />
1214
+ <ModelSelector.List />
1215
+ {/* Custom slider instead of ModelSelector.Effort: Pi's six thinking
1216
+ levels overflow the horizontal segmented layout. */}
1217
+ <ThinkingLevelSlider />
1218
+ </ModelSelector.Content>
1219
+ </ModelSelector.Root>
1220
+ );
1221
+ };
1222
+
1223
+ /** Context-window usage as a ring beside the send button. Driven by Pi's
1224
+ * `context_usage` events (`usePiThreadState`); hidden until the first run
1225
+ * reports a window. */
1226
+ const ComposerContextRing: FC = () => {
1227
+ const usage = usePiThreadState((s) => s.contextUsage);
1228
+ if (!usage || !usage.contextWindow) return null;
1229
+ return (
1230
+ <ContextDisplay.Ring
1231
+ modelContextWindow={usage.contextWindow}
1232
+ usage={{ totalTokens: usage.tokens ?? 0 }}
1233
+ side="top"
1234
+ />
1235
+ );
1236
+ };
1237
+
1238
+ const MessageError: FC = () => {
1239
+ return (
1240
+ <MessagePrimitive.Error>
1241
+ <ErrorPrimitive.Root className="aui-message-error-root border-destructive bg-destructive/10 text-destructive dark:bg-destructive/5 mt-2 rounded-md border p-3 text-sm dark:text-red-200">
1242
+ <ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
1243
+ </ErrorPrimitive.Root>
1244
+ </MessagePrimitive.Error>
1245
+ );
1246
+ };
1247
+
1248
+ const AssistantMessage: FC = () => {
1249
+ // reserves space for action bar and compensates with `-mb` for consistent msg spacing
1250
+ // keeps hovered action bar from shifting layout (autohide doesn't support absolute positioning well)
1251
+ // for pt-[n] use -mb-[n + 6] & min-h-[n + 6] to preserve compensation
1252
+ const ACTION_BAR_PT = "pt-1.5";
1253
+ const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}`;
1254
+
1255
+ return (
1256
+ <MessagePrimitive.Root
1257
+ data-slot="aui_assistant-message-root"
1258
+ data-role="assistant"
1259
+ className="fade-in slide-in-from-bottom-1 animate-in relative duration-150"
1260
+ >
1261
+ <div
1262
+ data-slot="aui_assistant-message-content"
1263
+ // [contain-intrinsic-size:auto_24px] fixes issue #4104, don't change without checking for regressions
1264
+ className="text-foreground px-2 leading-relaxed wrap-break-word [contain-intrinsic-size:auto_24px] [content-visibility:auto]"
1265
+ >
1266
+ <MessagePrimitive.GroupedParts
1267
+ groupBy={groupPartByType({
1268
+ reasoning: ["group-chainOfThought", "group-reasoning"],
1269
+ "tool-call": ["group-chainOfThought", "group-tool"],
1270
+ "standalone-tool-call": [],
1271
+ })}
1272
+ >
1273
+ {({ part, children }) => {
1274
+ switch (part.type) {
1275
+ case "group-chainOfThought":
1276
+ return <div data-slot="aui_chain-of-thought">{children}</div>;
1277
+ case "group-reasoning": {
1278
+ const running = part.status.type === "running";
1279
+ return (
1280
+ <ReasoningRoot
1281
+ defaultOpen={running}
1282
+ variant="ghost"
1283
+ className="my-1 mb-0"
1284
+ >
1285
+ <ReasoningTrigger active={running} />
1286
+ <ReasoningContent aria-busy={running}>
1287
+ <ReasoningText>{children}</ReasoningText>
1288
+ </ReasoningContent>
1289
+ </ReasoningRoot>
1290
+ );
1291
+ }
1292
+ case "group-tool":
1293
+ if (part.indices.length === 1) return <>{children}</>;
1294
+ return (
1295
+ <ToolGroupRoot variant="ghost" className="my-1">
1296
+ <div className="text-muted-foreground flex items-center gap-2">
1297
+ <WrenchIcon className="size-4 shrink-0" />
1298
+ <ToolGroupTrigger
1299
+ count={part.indices.length}
1300
+ active={part.status.type === "running"}
1301
+ />
1302
+ </div>
1303
+ <ToolGroupContent className="ml-5">
1304
+ {children}
1305
+ </ToolGroupContent>
1306
+ </ToolGroupRoot>
1307
+ );
1308
+ case "text":
1309
+ return <MarkdownText />;
1310
+ case "reasoning":
1311
+ return <Reasoning {...part} />;
1312
+ case "tool-call":
1313
+ return part.toolUI ?? <ToolFallback {...part} />;
1314
+ case "indicator":
1315
+ return (
1316
+ <span
1317
+ data-slot="aui_assistant-message-indicator"
1318
+ className="animate-pulse font-sans"
1319
+ aria-label="Assistant is working"
1320
+ >
1321
+ {"●"}
1322
+ </span>
1323
+ );
1324
+ default:
1325
+ return null;
1326
+ }
1327
+ }}
1328
+ </MessagePrimitive.GroupedParts>
1329
+ <MessageError />
1330
+ </div>
1331
+
1332
+ <div
1333
+ data-slot="aui_assistant-message-footer"
1334
+ className={cn("ms-2 flex items-center", ACTION_BAR_HEIGHT)}
1335
+ >
1336
+ <BranchPicker />
1337
+ <AssistantActionBar />
1338
+ </div>
1339
+ </MessagePrimitive.Root>
1340
+ );
1341
+ };
1342
+
1343
+ const AssistantActionBar: FC = () => {
1344
+ return (
1345
+ <ActionBarPrimitive.Root
1346
+ hideWhenRunning
1347
+ autohide="not-last"
1348
+ className="aui-assistant-action-bar-root text-muted-foreground col-start-3 row-start-2 -ms-1 flex gap-1"
1349
+ >
1350
+ <ActionBarPrimitive.Copy asChild>
1351
+ <TooltipIconButton tooltip="Copy">
1352
+ <AuiIf condition={(s) => s.message.isCopied}>
1353
+ <CheckIcon />
1354
+ </AuiIf>
1355
+ <AuiIf condition={(s) => !s.message.isCopied}>
1356
+ <CopyIcon />
1357
+ </AuiIf>
1358
+ </TooltipIconButton>
1359
+ </ActionBarPrimitive.Copy>
1360
+ <ActionBarPrimitive.Reload asChild>
1361
+ <TooltipIconButton tooltip="Refresh">
1362
+ <RefreshCwIcon />
1363
+ </TooltipIconButton>
1364
+ </ActionBarPrimitive.Reload>
1365
+ <ActionBarMorePrimitive.Root>
1366
+ <ActionBarMorePrimitive.Trigger asChild>
1367
+ <TooltipIconButton
1368
+ tooltip="More"
1369
+ className="data-[state=open]:bg-accent"
1370
+ >
1371
+ <MoreHorizontalIcon />
1372
+ </TooltipIconButton>
1373
+ </ActionBarMorePrimitive.Trigger>
1374
+ <ActionBarMorePrimitive.Content
1375
+ side="bottom"
1376
+ align="start"
1377
+ className="aui-action-bar-more-content bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md"
1378
+ >
1379
+ <ActionBarPrimitive.ExportMarkdown asChild>
1380
+ <ActionBarMorePrimitive.Item className="aui-action-bar-more-item hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none">
1381
+ <DownloadIcon className="size-4" />
1382
+ Export as Markdown
1383
+ </ActionBarMorePrimitive.Item>
1384
+ </ActionBarPrimitive.ExportMarkdown>
1385
+ </ActionBarMorePrimitive.Content>
1386
+ </ActionBarMorePrimitive.Root>
1387
+ </ActionBarPrimitive.Root>
1388
+ );
1389
+ };
1390
+
1391
+ const UserMessage: FC = () => {
1392
+ return (
1393
+ <MessagePrimitive.Root
1394
+ data-slot="aui_user-message-root"
1395
+ className="fade-in slide-in-from-bottom-1 animate-in grid auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 duration-150 [contain-intrinsic-size:auto_60px] [content-visibility:auto] [&:where(>*)]:col-start-2"
1396
+ data-role="user"
1397
+ >
1398
+ <UserMessageAttachments />
1399
+
1400
+ <div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
1401
+ <div className="aui-user-message-content peer bg-muted text-foreground rounded-2xl px-4 py-2.5 wrap-break-word empty:hidden">
1402
+ <MessagePrimitive.Parts />
1403
+ </div>
1404
+ <div className="aui-user-action-bar-wrapper absolute start-0 top-1/2 -translate-x-full -translate-y-1/2 pe-2 peer-empty:hidden rtl:translate-x-full">
1405
+ <UserActionBar />
1406
+ </div>
1407
+ </div>
1408
+
1409
+ <BranchPicker
1410
+ data-slot="aui_user-branch-picker"
1411
+ className="col-span-full col-start-1 row-start-3 -me-1 justify-end"
1412
+ />
1413
+ </MessagePrimitive.Root>
1414
+ );
1415
+ };
1416
+
1417
+ const UserActionBar: FC = () => {
1418
+ return (
1419
+ <ActionBarPrimitive.Root
1420
+ hideWhenRunning
1421
+ autohide="not-last"
1422
+ className="aui-user-action-bar-root flex flex-col items-end"
1423
+ >
1424
+ <ActionBarPrimitive.Edit asChild>
1425
+ <TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
1426
+ <PencilIcon />
1427
+ </TooltipIconButton>
1428
+ </ActionBarPrimitive.Edit>
1429
+ </ActionBarPrimitive.Root>
1430
+ );
1431
+ };
1432
+
1433
+ const EditComposer: FC = () => {
1434
+ return (
1435
+ <MessagePrimitive.Root
1436
+ data-slot="aui_edit-composer-wrapper"
1437
+ className="flex flex-col px-2"
1438
+ >
1439
+ <ComposerPrimitive.Root className="aui-edit-composer-root bg-muted ms-auto flex w-full max-w-[85%] flex-col rounded-2xl">
1440
+ <ComposerPrimitive.Input
1441
+ className="aui-edit-composer-input text-foreground min-h-14 w-full resize-none bg-transparent p-4 text-sm outline-none"
1442
+ autoFocus
1443
+ />
1444
+ <div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
1445
+ <ComposerPrimitive.Cancel asChild>
1446
+ <Button variant="ghost" size="sm">
1447
+ Cancel
1448
+ </Button>
1449
+ </ComposerPrimitive.Cancel>
1450
+ <ComposerPrimitive.Send asChild>
1451
+ <Button size="sm">Update</Button>
1452
+ </ComposerPrimitive.Send>
1453
+ </div>
1454
+ </ComposerPrimitive.Root>
1455
+ </MessagePrimitive.Root>
1456
+ );
1457
+ };
1458
+
1459
+ const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
1460
+ className,
1461
+ ...rest
1462
+ }) => {
1463
+ return (
1464
+ <BranchPickerPrimitive.Root
1465
+ hideWhenSingleBranch
1466
+ className={cn(
1467
+ "aui-branch-picker-root text-muted-foreground -ms-2 me-2 inline-flex items-center text-xs",
1468
+ className,
1469
+ )}
1470
+ {...rest}
1471
+ >
1472
+ <BranchPickerPrimitive.Previous asChild>
1473
+ <TooltipIconButton tooltip="Previous">
1474
+ <ChevronLeftIcon />
1475
+ </TooltipIconButton>
1476
+ </BranchPickerPrimitive.Previous>
1477
+ <span className="aui-branch-picker-state font-medium">
1478
+ <BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
1479
+ </span>
1480
+ <BranchPickerPrimitive.Next asChild>
1481
+ <TooltipIconButton tooltip="Next">
1482
+ <ChevronRightIcon />
1483
+ </TooltipIconButton>
1484
+ </BranchPickerPrimitive.Next>
1485
+ </BranchPickerPrimitive.Root>
1486
+ );
1487
+ };
1488
+
1489
+ ```
1490
+
1491
+ ## components/pi-handshake.tsx
1492
+
1493
+ ```tsx
1494
+ "use client";
1495
+
1496
+ import { createContext, useContext, type ReactNode } from "react";
1497
+ import type { ModelOption } from "@/components/assistant-ui/model-selector";
1498
+
1499
+ /** The slice of the server handshake the composer needs to render its model
1500
+ * selector. Provided by the page (which fetches `/api/pi/handshake`) and
1501
+ * consumed deep inside the composer, so the model picker can live in the
1502
+ * composer action bar without prop-drilling through the Thread. */
1503
+ export type PiModelOption = ModelOption & {
1504
+ provider: string;
1505
+ modelId: string;
1506
+ };
1507
+
1508
+ export type PiHandshakeValue = {
1509
+ models: PiModelOption[];
1510
+ selectedModelId?: string | undefined;
1511
+ };
1512
+
1513
+ const PiHandshakeContext = createContext<PiHandshakeValue | null>(null);
1514
+
1515
+ export function PiHandshakeProvider({
1516
+ value,
1517
+ children,
1518
+ }: {
1519
+ value: PiHandshakeValue | null;
1520
+ children: ReactNode;
1521
+ }) {
1522
+ return (
1523
+ <PiHandshakeContext.Provider value={value}>
1524
+ {children}
1525
+ </PiHandshakeContext.Provider>
1526
+ );
1527
+ }
1528
+
1529
+ export function usePiHandshake(): PiHandshakeValue | null {
1530
+ return useContext(PiHandshakeContext);
1531
+ }
1532
+
1533
+ ```
1534
+
1535
+ ## components/workspace-browser.tsx
1536
+
1537
+ ```tsx
1538
+ "use client";
1539
+
1540
+ import { useRef, useState } from "react";
1541
+ import { ArrowLeft, Folder } from "lucide-react";
1542
+ import type { FsListing } from "@/app/api/pi/fs/route";
1543
+ import {
1544
+ Dialog,
1545
+ DialogClose,
1546
+ DialogContent,
1547
+ DialogDescription,
1548
+ DialogFooter,
1549
+ DialogHeader,
1550
+ DialogTitle,
1551
+ DialogTrigger,
1552
+ } from "@/components/ui/dialog";
1553
+ import { Button } from "@/components/ui/button";
1554
+ import { ScrollArea } from "@/components/ui/scroll-area";
1555
+ import { Spinner } from "@/components/ui/spinner";
1556
+
1557
+ /** Server-backed directory browser. Click a folder to navigate in; the footer
1558
+ * commits whatever directory is currently in view. */
1559
+ export function WorkspaceBrowser({
1560
+ value,
1561
+ onCommit,
1562
+ }: {
1563
+ value: string;
1564
+ onCommit: (path: string) => void;
1565
+ }) {
1566
+ const [open, setOpen] = useState(false);
1567
+ const [listing, setListing] = useState<FsListing | null>(null);
1568
+ const [loading, setLoading] = useState(false);
1569
+ const [error, setError] = useState<string | null>(null);
1570
+ // Monotonic request token: rapid navigation fires overlapping fetches, and a
1571
+ // slow earlier response must not overwrite the directory navigated to last.
1572
+ const requestSeq = useRef(0);
1573
+
1574
+ const load = (path?: string) => {
1575
+ const seq = ++requestSeq.current;
1576
+ setLoading(true);
1577
+ setError(null);
1578
+ const qs = path ? `?path=${encodeURIComponent(path)}` : "";
1579
+ fetch(`/api/pi/fs${qs}`)
1580
+ .then(async (response) => {
1581
+ if (!response.ok) {
1582
+ throw new Error(`Failed to list directory (HTTP ${response.status})`);
1583
+ }
1584
+ return (await response.json()) as FsListing;
1585
+ })
1586
+ .then((data) => {
1587
+ if (seq === requestSeq.current) setListing(data);
1588
+ })
1589
+ .catch((err: unknown) => {
1590
+ if (seq === requestSeq.current) {
1591
+ setError(err instanceof Error ? err.message : String(err));
1592
+ }
1593
+ })
1594
+ .finally(() => {
1595
+ if (seq === requestSeq.current) setLoading(false);
1596
+ });
1597
+ };
1598
+
1599
+ const currentName = value.split("/").filter(Boolean).at(-1);
1600
+
1601
+ return (
1602
+ <Dialog
1603
+ open={open}
1604
+ onOpenChange={(next) => {
1605
+ setOpen(next);
1606
+ if (next) load(value || undefined);
1607
+ }}
1608
+ >
1609
+ <div className="flex items-center gap-2">
1610
+ {currentName && (
1611
+ <span
1612
+ className="text-muted-foreground max-w-72 truncate font-mono text-xs"
1613
+ title={value}
1614
+ >
1615
+ …/{currentName}
1616
+ </span>
1617
+ )}
1618
+ <DialogTrigger asChild>
1619
+ <Button variant="outline" size="sm">
1620
+ Browse…
1621
+ </Button>
1622
+ </DialogTrigger>
1623
+ </div>
1624
+
1625
+ <DialogContent className="sm:max-w-lg">
1626
+ <DialogHeader className="min-w-0">
1627
+ <DialogTitle>Select workspace</DialogTitle>
1628
+ <DialogDescription className="truncate font-mono text-xs">
1629
+ {listing?.path ?? "…"}
1630
+ </DialogDescription>
1631
+ </DialogHeader>
1632
+
1633
+ <div className="flex items-center gap-2">
1634
+ <Button
1635
+ variant="ghost"
1636
+ size="xs"
1637
+ disabled={!listing?.parent || loading}
1638
+ onClick={() => listing?.parent && load(listing.parent)}
1639
+ >
1640
+ <ArrowLeft /> Back
1641
+ </Button>
1642
+ {loading && <Spinner className="size-4" />}
1643
+ </div>
1644
+
1645
+ <ScrollArea className="h-72 min-w-0 rounded-md border [&_[data-radix-scroll-area-viewport]>div]:!block">
1646
+ {error && (
1647
+ <p className="text-destructive px-3 py-2 text-xs">{error}</p>
1648
+ )}
1649
+ {listing?.entries.map((entry) => (
1650
+ <button
1651
+ key={entry.path}
1652
+ type="button"
1653
+ onClick={() => load(entry.path)}
1654
+ className="hover:bg-accent flex w-full min-w-0 items-center gap-2 px-3 py-1.5 text-left font-mono text-xs"
1655
+ >
1656
+ <Folder className="size-3.5 shrink-0" />
1657
+ <span className="truncate">{entry.name}</span>
1658
+ </button>
1659
+ ))}
1660
+ {listing && listing.entries.length === 0 && (
1661
+ <p className="text-muted-foreground px-3 py-2 text-xs">
1662
+ No subfolders
1663
+ </p>
1664
+ )}
1665
+ </ScrollArea>
1666
+
1667
+ <DialogFooter>
1668
+ <DialogClose asChild>
1669
+ <Button variant="ghost" size="sm">
1670
+ Cancel
1671
+ </Button>
1672
+ </DialogClose>
1673
+ <Button
1674
+ size="sm"
1675
+ disabled={!listing}
1676
+ onClick={() => {
1677
+ if (listing) onCommit(listing.path);
1678
+ setOpen(false);
1679
+ }}
1680
+ >
1681
+ Select this folder
1682
+ </Button>
1683
+ </DialogFooter>
1684
+ </DialogContent>
1685
+ </Dialog>
1686
+ );
1687
+ }
1688
+
1689
+ ```
1690
+
1691
+ ## lib/http.ts
1692
+
1693
+ ```typescript
1694
+ /** Shared response helpers for the Pi example routes. */
1695
+
1696
+ /** 500 with a JSON `{ error }` body — `createPiHttpClient` surfaces the text. */
1697
+ export const fail = (error: unknown): Response =>
1698
+ Response.json(
1699
+ { error: error instanceof Error ? error.message : String(error) },
1700
+ { status: 500 },
1701
+ );
1702
+
1703
+ /** 400 with a JSON `{ error }` body, for invalid request input. */
1704
+ export const badRequest = (message: string): Response =>
1705
+ Response.json({ error: message }, { status: 400 });
1706
+
1707
+ /** 204 for write endpoints that return nothing. */
1708
+ export const noContent = (): Response => new Response(null, { status: 204 });
1709
+
1710
+ /** Wrap a route handler so any thrown error becomes a `fail()` response. */
1711
+ export const withFail =
1712
+ <Args extends unknown[]>(
1713
+ handler: (...args: Args) => Response | Promise<Response>,
1714
+ ) =>
1715
+ async (...args: Args): Promise<Response> => {
1716
+ try {
1717
+ return await handler(...args);
1718
+ } catch (error) {
1719
+ return fail(error);
1720
+ }
1721
+ };
1722
+
1723
+ ```
1724
+
1725
+ ## lib/model-key.ts
1726
+
1727
+ ```typescript
1728
+ /** Stable, collision-free model option id — model ids repeat across providers.
1729
+ * Shared by the server handshake and the composer's model selector; the two
1730
+ * must agree for the seeded selection to preselect in the UI. */
1731
+ export const modelKey = (provider: string, modelId: string): string =>
1732
+ `${provider}:${modelId}`;
1733
+
1734
+ ```
1735
+
1736
+ ## lib/pi-server.ts
1737
+
1738
+ ```typescript
1739
+ /**
1740
+ * Server-only Pi wiring for the example.
1741
+ *
1742
+ * Owns the process-singleton `createPiNodeClient` (the in-process SDK host) and
1743
+ * the env-seeded model resolution. Imported only from route
1744
+ * handlers — never from a client component — so the Pi SDK and its file I/O stay
1745
+ * on the server.
1746
+ *
1747
+ * Model resolution mirrors what Pi's own `createAgentSession` does: an explicit
1748
+ * PI_PROVIDER + PI_MODEL_ID wins, otherwise we fall back to Pi's configured
1749
+ * default (`settings.json`'s `defaultProvider`/`defaultModel`, read via
1750
+ * `SettingsManager`). The resolved `Model` is handed to the supervisor as the
1751
+ * default for every new session, and powers the model-selector catalog and the
1752
+ * readiness banner. So a user who is simply authenticated with `pi` (and has a
1753
+ * default model picked) needs no env at all.
1754
+ *
1755
+ * `PI_CODING_AGENT_DIR` is Pi's own env var for the agent config dir
1756
+ * (~/.pi/agent by default); when set, every Pi config source below points at it.
1757
+ */
1758
+ import {
1759
+ AuthStorage,
1760
+ ModelRegistry,
1761
+ SettingsManager,
1762
+ } from "@earendil-works/pi-coding-agent";
1763
+ import {
1764
+ createPiNodeClient,
1765
+ type PiModelInfo,
1766
+ type PiRuntimeReadiness,
1767
+ type PiThinkingLevel,
1768
+ } from "@assistant-ui/react-pi/node";
1769
+ import { modelKey } from "./model-key";
1770
+
1771
+ const env = (key: string): string | undefined => {
1772
+ const value = process.env[key];
1773
+ return value && value.trim() ? value.trim() : undefined;
1774
+ };
1775
+
1776
+ const workspacePath = env("PI_WORKSPACE_PATH") ?? process.cwd();
1777
+ const agentDir = env("PI_CODING_AGENT_DIR");
1778
+
1779
+ const authStorage = AuthStorage.create(
1780
+ agentDir ? `${agentDir}/auth.json` : undefined,
1781
+ );
1782
+ const modelRegistry = ModelRegistry.create(authStorage);
1783
+ const settingsManager = SettingsManager.create(workspacePath, agentDir);
1784
+
1785
+ // Env wins; otherwise fall back to Pi's configured default (settings.json).
1786
+ const provider = env("PI_PROVIDER") ?? settingsManager.getDefaultProvider();
1787
+ const modelId = env("PI_MODEL_ID") ?? settingsManager.getDefaultModel();
1788
+ const modelSource: "env" | "pi-default" =
1789
+ env("PI_PROVIDER") && env("PI_MODEL_ID") ? "env" : "pi-default";
1790
+
1791
+ const seededModel =
1792
+ provider && modelId ? modelRegistry.find(provider, modelId) : undefined;
1793
+
1794
+ export const piClient = createPiNodeClient({
1795
+ workspacePath,
1796
+ ...(agentDir ? { agentDir } : {}),
1797
+ ...(seededModel ? { model: seededModel } : {}),
1798
+ });
1799
+
1800
+ export type PiModelOption = {
1801
+ id: string;
1802
+ name: string;
1803
+ description?: string;
1804
+ provider: string;
1805
+ modelId: string;
1806
+ /** Thinking levels the model supports; omitted for non-reasoning models. */
1807
+ efforts?: { id: string; name: string }[];
1808
+ };
1809
+
1810
+ /** Display names for Pi's thinking levels (the UI-facing half of `efforts`;
1811
+ * which levels a model supports comes from `PiModelInfo`). */
1812
+ const THINKING_LEVEL_NAMES: Record<PiThinkingLevel, string> = {
1813
+ off: "Off",
1814
+ minimal: "Minimal",
1815
+ low: "Low",
1816
+ medium: "Medium",
1817
+ high: "High",
1818
+ xhigh: "xHigh",
1819
+ };
1820
+
1821
+ const ALL_THINKING_LEVELS = Object.keys(
1822
+ THINKING_LEVEL_NAMES,
1823
+ ) as PiThinkingLevel[];
1824
+
1825
+ const thinkingEfforts = (
1826
+ model: PiModelInfo,
1827
+ ): { id: string; name: string }[] | undefined => {
1828
+ if (!model.supportsThinking) return undefined;
1829
+ // No `availableThinkingLevels` means the model declares no per-level map;
1830
+ // every level stays selectable (provider defaults apply).
1831
+ return (model.availableThinkingLevels ?? ALL_THINKING_LEVELS).map((id) => ({
1832
+ id,
1833
+ name: THINKING_LEVEL_NAMES[id],
1834
+ }));
1835
+ };
1836
+
1837
+ export type PiHandshake = {
1838
+ workspacePath: string;
1839
+ /** Models Pi can run here (auth-configured first, else the full catalog). */
1840
+ models: PiModelOption[];
1841
+ /** The env-seeded selection, shown as the selector's default. */
1842
+ selectedModelId: string | undefined;
1843
+ readiness: PiRuntimeReadiness;
1844
+ };
1845
+
1846
+ const computeReadiness = (): PiRuntimeReadiness => {
1847
+ if (!provider || !modelId) {
1848
+ return {
1849
+ state: "missing-model",
1850
+ message:
1851
+ "No default model configured. Pick one with `pi`, or set PI_PROVIDER and PI_MODEL_ID.",
1852
+ };
1853
+ }
1854
+ if (!seededModel) {
1855
+ return {
1856
+ state: "unavailable-model",
1857
+ selection: { provider, modelId },
1858
+ message: `${provider}/${modelId} is not in Pi's model registry.`,
1859
+ };
1860
+ }
1861
+ if (!modelRegistry.hasConfiguredAuth(seededModel)) {
1862
+ return {
1863
+ state: "missing-credentials",
1864
+ provider,
1865
+ message: `No credentials configured for ${provider}. Authenticate with \`pi\`, then restart the server.`,
1866
+ };
1867
+ }
1868
+ return {
1869
+ state: "ready",
1870
+ selection: { provider, modelId },
1871
+ source: modelSource,
1872
+ };
1873
+ };
1874
+
1875
+ export const getHandshake = async (): Promise<PiHandshake> => {
1876
+ const catalog = await piClient.getAvailableModels();
1877
+ return {
1878
+ workspacePath,
1879
+ models: catalog.map((model) => {
1880
+ const efforts = thinkingEfforts(model);
1881
+ return {
1882
+ id: modelKey(model.provider, model.modelId),
1883
+ name: model.name ?? model.modelId,
1884
+ description: model.provider,
1885
+ provider: model.provider,
1886
+ modelId: model.modelId,
1887
+ ...(efforts ? { efforts } : {}),
1888
+ };
1889
+ }),
1890
+ selectedModelId: seededModel
1891
+ ? modelKey(String(seededModel.provider), seededModel.id)
1892
+ : undefined,
1893
+ readiness: computeReadiness(),
1894
+ };
1895
+ };
1896
+
1897
+ ```
1898
+
1899
+ ## next.config.ts
1900
+
1901
+ ```typescript
1902
+ import { withAui } from "@assistant-ui/next";
1903
+ import type { NextConfig } from "next";
1904
+
1905
+ const nextConfig: NextConfig = {
1906
+ // The Pi SDK is a native Node dependency; keep it external to the server
1907
+ // bundle so Next doesn't try to trace/bundle it.
1908
+ serverExternalPackages: ["@earendil-works/pi-coding-agent"],
1909
+ };
1910
+
1911
+ export default withAui(nextConfig);
1912
+
1913
+ ```
1914
+
1915
+ ## package.json
1916
+
1917
+ ```json
1918
+ {
1919
+ "name": "with-pi",
1920
+ "version": "0.0.0",
1921
+ "private": true,
1922
+ "type": "module",
1923
+ "scripts": {
1924
+ "dev": "next dev",
1925
+ "build": "next build",
1926
+ "start": "next start"
1927
+ },
1928
+ "dependencies": {
1929
+ "@assistant-ui/react": "workspace:*",
1930
+ "@assistant-ui/react-markdown": "workspace:*",
1931
+ "@assistant-ui/react-pi": "workspace:*",
1932
+ "@assistant-ui/ui": "workspace:*",
1933
+ "@earendil-works/pi-coding-agent": "^0.79.1",
1934
+ "class-variance-authority": "^0.7.1",
1935
+ "clsx": "^2.1.1",
1936
+ "lucide-react": "^1.18.0",
1937
+ "next": "^16.2.9",
1938
+ "radix-ui": "^1.5.0",
1939
+ "react": "^19.2.7",
1940
+ "react-dom": "^19.2.7",
1941
+ "tailwind-merge": "^3.6.0"
1942
+ },
1943
+ "devDependencies": {
1944
+ "@assistant-ui/next": "workspace:*",
1945
+ "@assistant-ui/x-buildutils": "workspace:*",
1946
+ "@tailwindcss/postcss": "^4.3.1",
1947
+ "@types/node": "^25.9.3",
1948
+ "@types/react": "^19.2.17",
1949
+ "@types/react-dom": "^19.2.3",
1950
+ "postcss": "^8.5.15",
1951
+ "tailwindcss": "^4.3.1",
1952
+ "tw-animate-css": "^1.4.0",
1953
+ "typescript": "^6.0.3"
1954
+ }
1955
+ }
1956
+
1957
+ ```
1958
+
1959
+ ## README.md
1960
+
1961
+ ```markdown
1962
+ # assistant-ui × Pi
1963
+
1964
+ A minimal local harness for the [`@assistant-ui/react-pi`](../../packages/react-pi)
1965
+ adapter, driving the [Pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
1966
+ in-process.
1967
+
1968
+ It demonstrates the MVP surface: a thread list, new sessions, a composer with
1969
+ steer / follow-up / stop, streaming assistant responses (text + reasoning), tool
1970
+ cards with streaming output, the blocking host-UI dialogs (confirm / input /
1971
+ select / editor), a model + credential readiness banner, and a context-usage
1972
+ indicator. It proves the adapter surface, not a full desktop app shell.
1973
+
1974
+ ## How it fits together
1975
+
1976
+ ```
1977
+ browser server (Next.js, Node runtime)
1978
+ ───────── ──────────────────────────────
1979
+ usePiRuntime app/api/pi/** ← route layer
1980
+ └ createPiHttpClient ──HTTP──▶ piClient = createPiNodeClient(...)
1981
+ (fetch + SSE) └ PiThreadSupervisor → Pi SDK (AgentSession)
1982
+ ```
1983
+
1984
+ `createPiHttpClient` and the route layer are two halves of one `PiClient`
1985
+ contract: the browser talks HTTP/SSE, the server runs the SDK in-process behind
1986
+ the process-singleton supervisor.
1987
+
1988
+ ## Model resolution
1989
+
1990
+ Resolution mirrors Pi's own `createAgentSession` (PI_MVP_PLAN §5): if you set
1991
+ `PI_PROVIDER` + `PI_MODEL_ID` they win; otherwise the server falls back to Pi's
1992
+ configured default (`settings.json`'s `defaultProvider`/`defaultModel`). So if
1993
+ you're authenticated with `pi` and have a default model picked, **no env is
1994
+ required**. The resolved model is handed to every new session and seeds the
1995
+ in-app **model selector** (via `@assistant-ui/ui`'s `ModelSelector`) and the
1996
+ readiness banner's credential check. The selector is wired to Pi's per-thread
1997
+ `setModel`, and the composer exposes Pi's thinking-level control. Set
1998
+ `PI_CODING_AGENT_DIR` to point at a non-default agent config dir (defaults to
1999
+ `~/.pi/agent`).
2000
+
2001
+ ## Run it
2002
+
2003
+ 1. Authenticate Pi once and pick a default model (writes `~/.pi/agent/`):
2004
+
2005
+ ```bash
2006
+ pnpm dlx @earendil-works/pi-coding-agent
2007
+ ```
2008
+
2009
+ 2. Start it (no env needed if Pi has a default model):
2010
+
2011
+ ```bash
2012
+ pnpm dev
2013
+ ```
2014
+
2015
+ To override the model or workspace, copy `.env.example` to `.env.local` and
2016
+ set `PI_PROVIDER` / `PI_MODEL_ID` / `PI_WORKSPACE_PATH`.
2017
+
2018
+ The agent reads and writes files and runs shell commands in `PI_WORKSPACE_PATH`.
2019
+ Point it at a scratch directory.
2020
+
2021
+ ```
2022
+
2023
+ ## tsconfig.json
2024
+
2025
+ ```json
2026
+ {
2027
+ "extends": "@assistant-ui/x-buildutils/ts/next",
2028
+ "compilerOptions": {
2029
+ "paths": {
2030
+ "@/*": ["./*"],
2031
+ "@/components/assistant-ui/*": [
2032
+ "../../packages/ui/src/components/assistant-ui/*"
2033
+ ],
2034
+ "@/components/ui/*": ["../../packages/ui/src/components/ui/*"],
2035
+ "@/lib/utils": ["../../packages/ui/src/lib/utils"],
2036
+ "@assistant-ui/ui/*": ["../../packages/ui/src/*"]
2037
+ }
2038
+ },
2039
+ "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
2040
+ "exclude": ["node_modules"]
2041
+ }
2042
+
2043
+ ```
2044
+