@overmux/pi 0.0.1

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 (70) hide show
  1. package/README.md +118 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +40 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/config-2jNv4tol.d.ts +34 -0
  6. package/dist/config-2jNv4tol.d.ts.map +1 -0
  7. package/dist/config.d.ts +2 -0
  8. package/dist/config.js +47 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/extension.d.ts +19 -0
  11. package/dist/extension.d.ts.map +1 -0
  12. package/dist/extension.js +265 -0
  13. package/dist/extension.js.map +1 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +16 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/jsonl-tail.d.ts +13 -0
  19. package/dist/jsonl-tail.d.ts.map +1 -0
  20. package/dist/jsonl-tail.js +83 -0
  21. package/dist/jsonl-tail.js.map +1 -0
  22. package/dist/live-events-ClhFOMGW.js +497 -0
  23. package/dist/live-events-ClhFOMGW.js.map +1 -0
  24. package/dist/live-events-DAmf6RRx.d.ts +107 -0
  25. package/dist/live-events-DAmf6RRx.d.ts.map +1 -0
  26. package/dist/live-events.d.ts +2 -0
  27. package/dist/live-events.js +2 -0
  28. package/dist/notification-DzOd9cRc.d.ts +20 -0
  29. package/dist/notification-DzOd9cRc.d.ts.map +1 -0
  30. package/dist/notification.d.ts +2 -0
  31. package/dist/notification.js +54 -0
  32. package/dist/notification.js.map +1 -0
  33. package/dist/plugin.d.ts +90 -0
  34. package/dist/plugin.d.ts.map +1 -0
  35. package/dist/plugin.js +213 -0
  36. package/dist/plugin.js.map +1 -0
  37. package/dist/projection.d.ts +101 -0
  38. package/dist/projection.d.ts.map +1 -0
  39. package/dist/projection.js +550 -0
  40. package/dist/projection.js.map +1 -0
  41. package/dist/protocol-CsrnSPOv.d.ts +115 -0
  42. package/dist/protocol-CsrnSPOv.d.ts.map +1 -0
  43. package/dist/protocol.d.ts +2 -0
  44. package/dist/protocol.js +365 -0
  45. package/dist/protocol.js.map +1 -0
  46. package/dist/react.d.ts +139 -0
  47. package/dist/react.d.ts.map +1 -0
  48. package/dist/react.js +796 -0
  49. package/dist/react.js.map +1 -0
  50. package/dist/server.d.ts +5 -0
  51. package/dist/server.js +4 -0
  52. package/dist/session-status-CZLTo8Km.d.ts +71 -0
  53. package/dist/session-status-CZLTo8Km.d.ts.map +1 -0
  54. package/dist/styles.css +619 -0
  55. package/docs/index.md +10 -0
  56. package/package.json +115 -0
  57. package/src/cli.ts +59 -0
  58. package/src/config.ts +87 -0
  59. package/src/extension.ts +486 -0
  60. package/src/index.ts +14 -0
  61. package/src/jsonl-tail.ts +110 -0
  62. package/src/live-events.ts +578 -0
  63. package/src/notification.ts +109 -0
  64. package/src/plugin.ts +369 -0
  65. package/src/projection.ts +995 -0
  66. package/src/protocol.ts +805 -0
  67. package/src/react.tsx +1293 -0
  68. package/src/server.ts +15 -0
  69. package/src/session-status.ts +379 -0
  70. package/src/styles.css +619 -0
package/src/react.tsx ADDED
@@ -0,0 +1,1293 @@
1
+ import "./styles.css";
2
+
3
+ import { PierrePatchDiff } from "@overmux/git/react";
4
+ import { ChevronRight, Send, Square } from "lucide-react";
5
+ import {
6
+ type CSSProperties,
7
+ type FormEvent,
8
+ type KeyboardEvent,
9
+ type ReactNode,
10
+ useCallback,
11
+ useEffect,
12
+ useId,
13
+ useLayoutEffect,
14
+ useRef,
15
+ useState,
16
+ } from "react";
17
+ import ReactMarkdown, { type Components } from "react-markdown";
18
+
19
+ type OvermuxStyle = CSSProperties &
20
+ Record<`--${string}`, string | number | undefined>;
21
+
22
+ type PiContentBlock = {
23
+ arguments?: unknown;
24
+ data?: string;
25
+ id?: string;
26
+ mimeType?: string;
27
+ name?: string;
28
+ text?: string;
29
+ thinking?: string;
30
+ tool?: PiToolProjection;
31
+ type: string;
32
+ };
33
+
34
+ type PiToolResultProjection = {
35
+ content: PiContentBlock[];
36
+ details?: unknown;
37
+ isError: boolean;
38
+ toolCallId: string;
39
+ toolName: string;
40
+ };
41
+
42
+ type PiToolProjection = {
43
+ result?: PiToolResultProjection;
44
+ status: "error" | "pending" | "success";
45
+ };
46
+
47
+ type PiConversationEntry = {
48
+ content: PiContentBlock[];
49
+ errorMessage?: string;
50
+ id: string;
51
+ role: "assistant" | "toolResult" | "user";
52
+ source: "canonical" | "live";
53
+ status: "complete" | "error" | "pending";
54
+ toolName?: string;
55
+ };
56
+
57
+ type PiSessionMetadata = {
58
+ contextUsage?: {
59
+ tokens: number;
60
+ contextWindow: number;
61
+ percent: number;
62
+ };
63
+ model?: {
64
+ provider: string;
65
+ id: string;
66
+ name: string;
67
+ };
68
+ modelOptions?: { provider: string; id: string; name: string }[];
69
+ thinkingLevel: string;
70
+ };
71
+
72
+ type PiConversationSnapshot = {
73
+ agentAvailable: boolean;
74
+ entries: PiConversationEntry[];
75
+ sessionMetadata?: PiSessionMetadata;
76
+ status: "busy" | "degraded" | "idle" | "offline";
77
+ };
78
+
79
+ type PiConversationStream = {
80
+ error?: Error;
81
+ status: "closed" | "open" | "opening";
82
+ subscribe: (
83
+ listener: (snapshot: PiConversationSnapshot) => void,
84
+ ) => () => void;
85
+ };
86
+
87
+ type PiConversationSendRequest = {
88
+ deliverAs: "followUp" | "steer";
89
+ message: string;
90
+ };
91
+
92
+ type PiConversationSendResult = {
93
+ delivery?: "followUp" | "immediate" | "steer";
94
+ requestId?: string;
95
+ };
96
+
97
+ type PiConversationSend = (
98
+ request: PiConversationSendRequest,
99
+ ) => Promise<PiConversationSendResult | void>;
100
+ type PiConversationStop = () => Promise<void>;
101
+ type PiConversationSetModel = (model: {
102
+ provider: string;
103
+ id: string;
104
+ }) => Promise<void>;
105
+ type PiConversationSetThinkingLevel = (
106
+ level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max",
107
+ ) => Promise<void>;
108
+
109
+ type PiConversationClassNames = Partial<{
110
+ composer: string;
111
+ content: string;
112
+ entry: string;
113
+ root: string;
114
+ scroller: string;
115
+ status: string;
116
+ }>;
117
+
118
+ type PiConversationBlockRender = (input: {
119
+ block: PiContentBlock;
120
+ entry: PiConversationEntry;
121
+ index: number;
122
+ }) => ReactNode | undefined;
123
+
124
+ export type ToolStatus = "error" | "pending" | "success";
125
+
126
+ export type ToolRendererResult = {
127
+ content: { data?: string; text?: string; thinking?: string }[];
128
+ details?: unknown;
129
+ isError: boolean;
130
+ };
131
+
132
+ export type ToolRendererInput = {
133
+ arguments?: unknown;
134
+ result?: ToolRendererResult;
135
+ status: ToolStatus;
136
+ toolName: string;
137
+ };
138
+
139
+ export type ToolRenderer = (input: ToolRendererInput) => ReactNode;
140
+
141
+ export type ToolRendererRegistry = Record<string, ToolRenderer>;
142
+
143
+ export type PiConversationProps = {
144
+ autoFocus?: boolean;
145
+ className?: string;
146
+ classNames?: PiConversationClassNames;
147
+ conversation: PiConversationStream;
148
+ emptyState?: ReactNode;
149
+ renderBlock?: PiConversationBlockRender;
150
+ renderers?: ToolRendererRegistry;
151
+ sendMessage: PiConversationSend;
152
+ setModel?: PiConversationSetModel;
153
+ setThinkingLevel?: PiConversationSetThinkingLevel;
154
+ stop?: PiConversationStop;
155
+ style?: OvermuxStyle;
156
+ };
157
+
158
+ const contextWindowOverrides = new Map([
159
+ ["openai-codex/gpt-5.6-luna", 372_000],
160
+ ["openai-codex/gpt-5.6-sol", 372_000],
161
+ ["openai-codex/gpt-5.6-terra", 372_000],
162
+ ]);
163
+
164
+ const formatTokenCount = (tokens: number) =>
165
+ tokens < 1000 ? `${tokens}` : `${(tokens / 1000).toFixed(1)}k`;
166
+
167
+ const contextUsageLabel = ({ contextUsage, model }: PiSessionMetadata) => {
168
+ if (!contextUsage) {
169
+ return undefined;
170
+ }
171
+ const contextWindow =
172
+ contextWindowOverrides.get(`${model?.provider}/${model?.id}`) ??
173
+ contextUsage.contextWindow;
174
+ return `ctx ${formatTokenCount(contextUsage.tokens)}/${formatTokenCount(contextWindow)} ${((contextUsage.tokens / contextWindow) * 100).toFixed(1)}%`;
175
+ };
176
+
177
+ const thinkingLevels = [
178
+ "off",
179
+ "minimal",
180
+ "low",
181
+ "medium",
182
+ "high",
183
+ "xhigh",
184
+ "max",
185
+ ];
186
+
187
+ const PiConversationMetadata = ({
188
+ busy,
189
+ metadata,
190
+ setModel,
191
+ setThinkingLevel,
192
+ }: {
193
+ busy: boolean;
194
+ metadata: PiSessionMetadata;
195
+ setModel?: PiConversationSetModel;
196
+ setThinkingLevel?: PiConversationSetThinkingLevel;
197
+ }) => {
198
+ const [changing, setChanging] = useState(false);
199
+ const [error, setError] = useState("");
200
+ const update = async (action: () => Promise<void>) => {
201
+ setChanging(true);
202
+ try {
203
+ await action();
204
+ setError("");
205
+ } catch (cause) {
206
+ setError(cause instanceof Error ? cause.message : String(cause));
207
+ } finally {
208
+ setChanging(false);
209
+ }
210
+ };
211
+ const disabled = busy || changing;
212
+ const currentModel = metadata.model;
213
+ return (
214
+ <div className="om-pi-conversation-metadata om-pi-muted">
215
+ {currentModel && metadata.modelOptions?.length && setModel ? (
216
+ <select
217
+ aria-label="Pi model"
218
+ className="om-pi-metadata-select"
219
+ disabled={disabled}
220
+ onChange={(event) =>
221
+ void update(() => setModel(JSON.parse(event.target.value)))
222
+ }
223
+ value={JSON.stringify({
224
+ provider: currentModel.provider,
225
+ id: currentModel.id,
226
+ })}
227
+ >
228
+ {metadata.modelOptions.map((model) => (
229
+ <option
230
+ key={`${model.provider}\0${model.id}`}
231
+ value={JSON.stringify({ provider: model.provider, id: model.id })}
232
+ >
233
+ {model.id}
234
+ </option>
235
+ ))}
236
+ </select>
237
+ ) : (
238
+ <span>{currentModel?.id ?? "no-model"}</span>
239
+ )}
240
+ <span aria-hidden="true"> · </span>
241
+ {setThinkingLevel ? (
242
+ <select
243
+ aria-label="Pi thinking level"
244
+ className="om-pi-metadata-select"
245
+ disabled={disabled}
246
+ onChange={(event) =>
247
+ void update(() =>
248
+ setThinkingLevel(
249
+ event.target
250
+ .value as Parameters<PiConversationSetThinkingLevel>[0],
251
+ ),
252
+ )
253
+ }
254
+ value={metadata.thinkingLevel}
255
+ >
256
+ {thinkingLevels.map((level) => (
257
+ <option key={level} value={level}>
258
+ {level}
259
+ </option>
260
+ ))}
261
+ </select>
262
+ ) : (
263
+ <span>{metadata.thinkingLevel}</span>
264
+ )}
265
+ {contextUsageLabel(metadata) ? (
266
+ <>
267
+ <span aria-hidden="true"> · </span>
268
+ <span>{contextUsageLabel(metadata)}</span>
269
+ </>
270
+ ) : null}
271
+ {error ? (
272
+ <span className="om-pi-metadata-error" role="alert">
273
+ {error}
274
+ </span>
275
+ ) : null}
276
+ </div>
277
+ );
278
+ };
279
+
280
+ const languageLoaders = {
281
+ bash: () => import("@shikijs/langs/bash"),
282
+ css: () => import("@shikijs/langs/css"),
283
+ diff: () => import("@shikijs/langs/diff"),
284
+ html: () => import("@shikijs/langs/html"),
285
+ javascript: () => import("@shikijs/langs/javascript"),
286
+ json: () => import("@shikijs/langs/json"),
287
+ markdown: () => import("@shikijs/langs/markdown"),
288
+ nix: () => import("@shikijs/langs/nix"),
289
+ typescript: () => import("@shikijs/langs/typescript"),
290
+ tsx: () => import("@shikijs/langs/tsx"),
291
+ yaml: () => import("@shikijs/langs/yaml"),
292
+ };
293
+
294
+ type CodeLanguage = keyof typeof languageLoaders;
295
+
296
+ const languageAliases: Record<string, CodeLanguage> = {
297
+ js: "javascript",
298
+ md: "markdown",
299
+ sh: "bash",
300
+ shell: "bash",
301
+ ts: "typescript",
302
+ yml: "yaml",
303
+ };
304
+
305
+ type HighlightedLine = { color?: string; content: string }[];
306
+
307
+ type Highlighter = {
308
+ codeToTokens: (
309
+ code: string,
310
+ options: { lang: string; theme: string },
311
+ ) => {
312
+ tokens: HighlightedLine[];
313
+ };
314
+ };
315
+
316
+ const maxHighlightedCodeEntries = 64;
317
+
318
+ let highlighterPromise: Promise<Highlighter> | undefined;
319
+ const highlightedCode = new Map<string, Promise<HighlightedLine[]>>();
320
+
321
+ const loadHighlighter = () => {
322
+ if (!highlighterPromise) {
323
+ highlighterPromise = Promise.all([
324
+ import("shiki/core"),
325
+ import("shiki/engine/javascript"),
326
+ import("@shikijs/themes/tokyo-night"),
327
+ ]).then(async ([core, engine, theme]) =>
328
+ core.createHighlighterCore({
329
+ engine: engine.createJavaScriptRegexEngine(),
330
+ langs: await Promise.all(
331
+ Object.values(languageLoaders).map(async (loadLanguage) =>
332
+ loadLanguage(),
333
+ ),
334
+ ),
335
+ themes: [theme.default],
336
+ }),
337
+ );
338
+ }
339
+ return highlighterPromise;
340
+ };
341
+
342
+ void loadHighlighter();
343
+
344
+ const highlighted = (code: string, language: string) => {
345
+ const key = `${language}\u0000${code}`;
346
+ const cached = highlightedCode.get(key);
347
+ if (cached) {
348
+ return cached;
349
+ }
350
+ const next = loadHighlighter().then(
351
+ (highlighter) =>
352
+ highlighter.codeToTokens(code, { lang: language, theme: "tokyo-night" })
353
+ .tokens,
354
+ );
355
+ highlightedCode.set(key, next);
356
+ if (highlightedCode.size > maxHighlightedCodeEntries) {
357
+ const oldest = highlightedCode.keys().next().value;
358
+ if (oldest) {
359
+ highlightedCode.delete(oldest);
360
+ }
361
+ }
362
+ return next;
363
+ };
364
+
365
+ const textOf = (block: PiContentBlock) =>
366
+ block.text ??
367
+ block.thinking ??
368
+ (typeof block.data === "string" ? block.data : "");
369
+
370
+ const formatted = (value: unknown) => {
371
+ if (typeof value === "string") {
372
+ try {
373
+ return JSON.stringify(JSON.parse(value), null, 2);
374
+ } catch {
375
+ return value;
376
+ }
377
+ }
378
+ try {
379
+ return JSON.stringify(value, null, 2) ?? "";
380
+ } catch {
381
+ return String(value);
382
+ }
383
+ };
384
+
385
+ const codeLanguage = (className?: string): CodeLanguage | undefined => {
386
+ const match = /language-([^\s]+)/.exec(className ?? "");
387
+ const language = match?.[1]?.toLowerCase();
388
+ if (!language) {
389
+ return undefined;
390
+ }
391
+ const normalized = languageAliases[language] ?? language;
392
+ return Object.hasOwn(languageLoaders, normalized)
393
+ ? (normalized as CodeLanguage)
394
+ : undefined;
395
+ };
396
+
397
+ const FencedCode = ({
398
+ children,
399
+ className,
400
+ node: _node,
401
+ ...props
402
+ }: Components["code"] extends infer Component
403
+ ? Component extends (props: infer Props) => unknown
404
+ ? Props
405
+ : never
406
+ : never) => {
407
+ const code = String(children).replace(/\n$/, "");
408
+ const language = codeLanguage(className);
409
+ const highlightKey = language ? `${language}\u0000${code}` : undefined;
410
+ const [highlightedState, setHighlightedState] = useState<{
411
+ key: string;
412
+ lines: HighlightedLine[];
413
+ }>();
414
+ const lines =
415
+ highlightedState && highlightedState.key === highlightKey
416
+ ? highlightedState.lines
417
+ : undefined;
418
+
419
+ useEffect(() => {
420
+ if (!language || !highlightKey) {
421
+ return;
422
+ }
423
+ let active = true;
424
+ void highlighted(code, language).then((value) => {
425
+ if (active) {
426
+ setHighlightedState({ key: highlightKey, lines: value });
427
+ }
428
+ });
429
+ return () => {
430
+ active = false;
431
+ };
432
+ }, [code, highlightKey, language]);
433
+
434
+ if (!language || !lines) {
435
+ return (
436
+ <code className={className} {...props}>
437
+ {children}
438
+ </code>
439
+ );
440
+ }
441
+ return (
442
+ <code className="om-pi-code-highlighted" {...props}>
443
+ {lines.map((line, index) => (
444
+ <span className="om-pi-code-line" key={index}>
445
+ {line.map((token, tokenIndex) => (
446
+ <span key={tokenIndex} style={{ color: token.color }}>
447
+ {token.content}
448
+ </span>
449
+ ))}
450
+ {index < lines.length - 1 ? "\n" : null}
451
+ </span>
452
+ ))}
453
+ </code>
454
+ );
455
+ };
456
+
457
+ const markdownComponents: Components = { code: FencedCode };
458
+
459
+ const MarkdownPart = ({
460
+ block,
461
+ subdued = false,
462
+ }: {
463
+ block: PiContentBlock;
464
+ subdued?: boolean;
465
+ }) => (
466
+ <div
467
+ className={["om-pi-markdown", subdued && "om-pi-muted"]
468
+ .filter(Boolean)
469
+ .join(" ")}
470
+ >
471
+ <ReactMarkdown components={markdownComponents}>
472
+ {textOf(block)}
473
+ </ReactMarkdown>
474
+ </div>
475
+ );
476
+
477
+ const preview = (value: string, maxLength = 120) =>
478
+ value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
479
+
480
+ const bounded = (value: string, maxLines = 200, maxLength = 20_000) => {
481
+ const lines = value.split("\n").slice(0, maxLines).join("\n");
482
+ return lines.length > maxLength ? `${lines.slice(0, maxLength - 1)}…` : lines;
483
+ };
484
+
485
+ const detailsRecord = (details: unknown) =>
486
+ typeof details === "object" && details !== null
487
+ ? (details as Record<string, unknown>)
488
+ : undefined;
489
+
490
+ const resultText = (result: ToolRendererResult | undefined) =>
491
+ result?.content
492
+ .map((block) => block.text ?? block.thinking ?? block.data ?? "")
493
+ .filter(Boolean)
494
+ .join("\n") ?? "";
495
+
496
+ const truncationSummary = (details: unknown) => {
497
+ const truncation = detailsRecord(details)?.truncation;
498
+ const record = detailsRecord(truncation);
499
+ if (!record?.truncated) {
500
+ return undefined;
501
+ }
502
+ const totalLines =
503
+ typeof record.totalLines === "number" ? record.totalLines : undefined;
504
+ const outputLines =
505
+ typeof record.outputLines === "number" ? record.outputLines : undefined;
506
+ return totalLines && outputLines
507
+ ? `${totalLines - outputLines} lines omitted`
508
+ : "Output truncated";
509
+ };
510
+
511
+ const ToolDisclosure = ({
512
+ children,
513
+ collapsible = true,
514
+ indicator,
515
+ label,
516
+ status,
517
+ }: {
518
+ children?: ReactNode;
519
+ collapsible?: boolean;
520
+ indicator?: ReactNode;
521
+ label: ReactNode;
522
+ status: ToolStatus;
523
+ }) => {
524
+ const [open, setOpen] = useState(status !== "success");
525
+ const explicit = useRef(false);
526
+ useEffect(() => {
527
+ if (!explicit.current) {
528
+ setOpen(status !== "success");
529
+ }
530
+ }, [status]);
531
+ const header = (
532
+ <>
533
+ <span
534
+ className={`om-pi-tool-status${indicator ? " om-pi-tool-status-label" : ""}`}
535
+ aria-label={`Tool ${status}`}
536
+ >
537
+ {indicator ??
538
+ (status === "success" ? "✓" : status === "error" ? "×" : "●")}
539
+ </span>
540
+ {label}
541
+ </>
542
+ );
543
+ if (!collapsible) {
544
+ return (
545
+ <div className="om-pi-tool-details" data-om-tool-status={status}>
546
+ <div className="om-pi-tool-summary">{header}</div>
547
+ {children ? <div className="om-pi-tool-body">{children}</div> : null}
548
+ </div>
549
+ );
550
+ }
551
+ return (
552
+ <details
553
+ className="om-pi-tool-details"
554
+ data-om-tool-status={status}
555
+ onToggle={(event) => setOpen(event.currentTarget.open)}
556
+ open={open}
557
+ >
558
+ <summary
559
+ onClick={() => {
560
+ explicit.current = true;
561
+ }}
562
+ >
563
+ {header}
564
+ </summary>
565
+ {children ? <div className="om-pi-tool-body">{children}</div> : null}
566
+ </details>
567
+ );
568
+ };
569
+
570
+ const ThinkingPart = ({
571
+ block,
572
+ status,
573
+ }: {
574
+ block: PiContentBlock;
575
+ status: PiConversationEntry["status"];
576
+ }) => {
577
+ const thinking = textOf(block);
578
+ const [open, setOpen] = useState(status === "pending");
579
+ const explicit = useRef(false);
580
+ useEffect(() => {
581
+ if (!explicit.current) {
582
+ setOpen(status === "pending");
583
+ }
584
+ }, [status]);
585
+ return (
586
+ <details
587
+ className="om-pi-thinking"
588
+ onToggle={(event) => setOpen(event.currentTarget.open)}
589
+ open={open}
590
+ >
591
+ <summary
592
+ onClick={() => {
593
+ explicit.current = true;
594
+ }}
595
+ >
596
+ {status === "pending" && thinking ? preview(thinking) : "Thinking…"}
597
+ </summary>
598
+ <div className="om-pi-content-block">
599
+ <MarkdownPart block={block} subdued />
600
+ </div>
601
+ </details>
602
+ );
603
+ };
604
+
605
+ const ImagePart = ({ block }: { block: PiContentBlock }) => (
606
+ <p className="om-pi-muted">Image attachment {block.mimeType ?? ""}</p>
607
+ );
608
+
609
+ const toolStatus = (tool: PiToolProjection): ToolStatus =>
610
+ tool.result?.isError ? "error" : tool.result ? "success" : tool.status;
611
+
612
+ const argumentsRecord = (arguments_: unknown) =>
613
+ typeof arguments_ === "object" && arguments_ !== null
614
+ ? (arguments_ as Record<string, unknown>)
615
+ : undefined;
616
+
617
+ const stringArgument = (arguments_: unknown, name: string) => {
618
+ const value = argumentsRecord(arguments_)?.[name];
619
+ return typeof value === "string" ? value : undefined;
620
+ };
621
+
622
+ const numberArgument = (arguments_: unknown, name: string) => {
623
+ const value = argumentsRecord(arguments_)?.[name];
624
+ return typeof value === "number" ? value : undefined;
625
+ };
626
+
627
+ const Output = ({ result }: { result?: ToolRendererResult }) => {
628
+ const content = resultText(result);
629
+ if (!content && !result?.isError) {
630
+ return null;
631
+ }
632
+ return (
633
+ <pre className={result?.isError ? "om-pi-tool-error" : "om-pi-tool-output"}>
634
+ {bounded(content || formatted(result?.details))}
635
+ </pre>
636
+ );
637
+ };
638
+
639
+ const toolFrame = (
640
+ { result, status }: ToolRendererInput,
641
+ label: ReactNode,
642
+ children?: ReactNode,
643
+ ) => (
644
+ <ToolDisclosure label={label} status={status}>
645
+ {children}
646
+ <Output result={result} />
647
+ </ToolDisclosure>
648
+ );
649
+
650
+ const ToolSection = ({
651
+ children,
652
+ defaultOpen,
653
+ label,
654
+ status,
655
+ }: {
656
+ children?: ReactNode;
657
+ defaultOpen?: boolean;
658
+ label: ReactNode;
659
+ status: ToolStatus;
660
+ }) => {
661
+ const shouldOpen = defaultOpen ?? status !== "success";
662
+ const [open, setOpen] = useState(shouldOpen);
663
+ const explicit = useRef(false);
664
+ useEffect(() => {
665
+ if (!explicit.current) {
666
+ setOpen(shouldOpen);
667
+ }
668
+ }, [shouldOpen]);
669
+ return (
670
+ <details
671
+ className="om-pi-tool-section"
672
+ data-om-tool-status={status}
673
+ onToggle={(event) => setOpen(event.currentTarget.open)}
674
+ open={open}
675
+ >
676
+ <summary
677
+ onClick={() => {
678
+ explicit.current = true;
679
+ }}
680
+ >
681
+ <ChevronRight aria-hidden="true" />
682
+ <span>{label}</span>
683
+ </summary>
684
+ {children ? <div className="om-pi-tool-body">{children}</div> : null}
685
+ </details>
686
+ );
687
+ };
688
+
689
+ const BashToolRenderer: ToolRenderer = (input) => {
690
+ const command = stringArgument(input.arguments, "command");
691
+ const truncation = truncationSummary(input.result?.details);
692
+ return (
693
+ <div className="om-pi-tool-details" data-om-tool-status={input.status}>
694
+ <div className="om-pi-tool-kind">
695
+ <span
696
+ className="om-pi-tool-status om-pi-tool-status-label"
697
+ aria-label={`Tool ${input.status}`}
698
+ >
699
+ bash
700
+ </span>
701
+ </div>
702
+ <pre className="om-pi-tool-command">
703
+ <code>$ {command ?? "bash"}</code>
704
+ </pre>
705
+ <ToolSection label="Logs" status={input.status}>
706
+ {truncation ? <p className="om-pi-tool-field">{truncation}</p> : null}
707
+ <Output result={input.result} />
708
+ </ToolSection>
709
+ </div>
710
+ );
711
+ };
712
+
713
+ const BashProcessToolRenderer: ToolRenderer = (input) => {
714
+ const action = stringArgument(input.arguments, "action") ?? "bash_process";
715
+ const pgid =
716
+ numberArgument(input.arguments, "pgid") ??
717
+ stringArgument(input.arguments, "pgid");
718
+ return toolFrame(input, `${action}${pgid === undefined ? "" : ` · ${pgid}`}`);
719
+ };
720
+
721
+ const languageForPath = (path: string | undefined) => {
722
+ const extension = path?.split(".").at(-1)?.toLowerCase();
723
+ return extension ? (languageAliases[extension] ?? extension) : "";
724
+ };
725
+
726
+ const ReadToolRenderer: ToolRenderer = (input) => {
727
+ const path = stringArgument(input.arguments, "path");
728
+ const offset = numberArgument(input.arguments, "offset") ?? 1;
729
+ const limit = numberArgument(input.arguments, "limit");
730
+ const range =
731
+ limit === undefined ? `:${offset}` : `:${offset}-${offset + limit - 1}`;
732
+ const content = resultText(input.result);
733
+ const language = languageForPath(path);
734
+ return (
735
+ <ToolDisclosure
736
+ indicator="read"
737
+ label={`${path ?? "read"}${range}`}
738
+ status={input.status}
739
+ >
740
+ {input.result?.isError ? <Output result={input.result} /> : null}
741
+ {content && !input.result?.isError ? (
742
+ <MarkdownPart
743
+ block={{
744
+ text: `\`\`\`${language}\n${bounded(content)}\n\`\`\``,
745
+ type: "text",
746
+ }}
747
+ subdued
748
+ />
749
+ ) : null}
750
+ </ToolDisclosure>
751
+ );
752
+ };
753
+
754
+ type EditResultDetails = { diff: string; patch?: string };
755
+
756
+ const editResultDetails = (details: unknown): EditResultDetails | undefined => {
757
+ const record = detailsRecord(details);
758
+ return typeof record?.diff === "string"
759
+ ? {
760
+ diff: record.diff,
761
+ patch: typeof record.patch === "string" ? record.patch : undefined,
762
+ }
763
+ : undefined;
764
+ };
765
+
766
+ const EditToolRenderer: ToolRenderer = (input) => {
767
+ const details = editResultDetails(input.result?.details);
768
+ const diff = details?.diff;
769
+ const path = stringArgument(input.arguments, "path") ?? "edit";
770
+ const label = <code className="om-pi-tool-path">{path}</code>;
771
+ return (
772
+ <ToolDisclosure
773
+ collapsible={false}
774
+ indicator="edit"
775
+ label={label}
776
+ status={input.status}
777
+ >
778
+ {diff ? (
779
+ <div className="om-pi-tool-diff">
780
+ {details?.patch ? (
781
+ <PierrePatchDiff
782
+ options={{ hunkSeparators: "simple" }}
783
+ patch={details.patch}
784
+ />
785
+ ) : (
786
+ <pre>{bounded(diff)}</pre>
787
+ )}
788
+ </div>
789
+ ) : null}
790
+ {input.result?.isError ? (
791
+ <ToolSection defaultOpen={false} label="Error" status={input.status}>
792
+ <Output result={input.result} />
793
+ </ToolSection>
794
+ ) : null}
795
+ </ToolDisclosure>
796
+ );
797
+ };
798
+
799
+ const newFilePatch = (path: string, content: string) => {
800
+ const safePath = path.replace(/[\r\n]/g, "_");
801
+ const displayed = bounded(content);
802
+ const hasTrailingNewline = displayed.endsWith("\n");
803
+ const lines = displayed ? displayed.split("\n") : [];
804
+ const additions = hasTrailingNewline ? lines.slice(0, -1) : lines;
805
+ const hunk = additions.length
806
+ ? `@@ -0,0 +1,${additions.length} @@\n${additions.map((line) => `+${line}`).join("\n")}${hasTrailingNewline ? "\n" : ""}`
807
+ : "";
808
+ return `diff --git a/${safePath} b/${safePath}\nnew file mode 100644\n--- /dev/null\n+++ b/${safePath}\n${hunk}`;
809
+ };
810
+
811
+ const WriteToolRenderer: ToolRenderer = (input) => {
812
+ const path = stringArgument(input.arguments, "path") ?? "write";
813
+ const content = stringArgument(input.arguments, "content");
814
+ const label = <code className="om-pi-tool-path">{path}</code>;
815
+ return (
816
+ <ToolDisclosure
817
+ collapsible={false}
818
+ indicator="write"
819
+ label={label}
820
+ status={input.status}
821
+ >
822
+ {content === undefined ? null : (
823
+ <div
824
+ className="om-pi-tool-diff"
825
+ data-patch={newFilePatch(path, content)}
826
+ >
827
+ <PierrePatchDiff
828
+ options={{ hunkSeparators: "simple" }}
829
+ patch={newFilePatch(path, content)}
830
+ />
831
+ </div>
832
+ )}
833
+ {input.result?.isError ? (
834
+ <ToolSection defaultOpen={false} label="Error" status={input.status}>
835
+ <Output result={input.result} />
836
+ </ToolSection>
837
+ ) : null}
838
+ </ToolDisclosure>
839
+ );
840
+ };
841
+
842
+ const GenericToolRenderer: ToolRenderer = (input) =>
843
+ toolFrame(
844
+ input,
845
+ input.toolName,
846
+ input.arguments === undefined ? null : (
847
+ <pre className="om-pi-tool-input">{formatted(input.arguments)}</pre>
848
+ ),
849
+ );
850
+
851
+ export const defaultToolRenderers: ToolRendererRegistry = {
852
+ bash: BashToolRenderer,
853
+ bash_process: BashProcessToolRenderer,
854
+ edit: EditToolRenderer,
855
+ read: ReadToolRenderer,
856
+ write: WriteToolRenderer,
857
+ };
858
+
859
+ const ToolPart = ({
860
+ block,
861
+ entry,
862
+ renderers,
863
+ }: {
864
+ block: PiContentBlock;
865
+ entry: PiConversationEntry;
866
+ renderers: ToolRendererRegistry;
867
+ }) => {
868
+ const knownToolName = block.name ?? entry.toolName;
869
+ const tool = block.tool ?? { status: "pending" as const };
870
+ if (!knownToolName && tool.status === "pending") {
871
+ return (
872
+ <p className="om-pi-muted" role="status">
873
+ Running tool…
874
+ </p>
875
+ );
876
+ }
877
+ const toolName = knownToolName ?? "Tool call";
878
+ const Renderer = renderers[toolName] ?? GenericToolRenderer;
879
+ return (
880
+ <Renderer
881
+ arguments={block.arguments}
882
+ result={tool.result}
883
+ status={toolStatus(tool)}
884
+ toolName={toolName}
885
+ />
886
+ );
887
+ };
888
+
889
+ const MessagePart = ({
890
+ block,
891
+ entry,
892
+ renderers,
893
+ }: {
894
+ block: PiContentBlock;
895
+ entry: PiConversationEntry;
896
+ renderers: ToolRendererRegistry;
897
+ }) => {
898
+ if (block.type === "thinking") {
899
+ return <ThinkingPart block={block} status={entry.status} />;
900
+ }
901
+ if (block.type === "toolCall") {
902
+ return <ToolPart block={block} entry={entry} renderers={renderers} />;
903
+ }
904
+ if (block.type === "image") {
905
+ return <ImagePart block={block} />;
906
+ }
907
+ return <MarkdownPart block={block} />;
908
+ };
909
+
910
+ const roleName = (entry: PiConversationEntry) => {
911
+ if (entry.role === "toolResult") {
912
+ return entry.toolName ? `${entry.toolName} result` : "Tool result";
913
+ }
914
+ return entry.role === "user" ? "You" : "Pi";
915
+ };
916
+
917
+ const PiConversationMessage = ({
918
+ className,
919
+ entry,
920
+ renderBlock,
921
+ renderers,
922
+ }: {
923
+ className?: string;
924
+ entry: PiConversationEntry;
925
+ renderBlock?: PiConversationBlockRender;
926
+ renderers: ToolRendererRegistry;
927
+ }) => (
928
+ <article
929
+ aria-label={`${roleName(entry)} message`}
930
+ className={["om-pi-conversation-entry", className]
931
+ .filter(Boolean)
932
+ .join(" ")}
933
+ data-om-role={entry.role}
934
+ data-om-source={entry.source}
935
+ data-om-status={entry.status}
936
+ >
937
+ {entry.role === "user" || entry.status !== "complete" ? (
938
+ <header className="om-pi-muted">
939
+ {entry.role === "user" ? <span>You</span> : null}
940
+ {entry.status === "pending" ? (
941
+ <span role="status">Streaming…</span>
942
+ ) : null}
943
+ {entry.status === "error" ? (
944
+ <span className="om-pi-tool-error">Error</span>
945
+ ) : null}
946
+ </header>
947
+ ) : null}
948
+ <div className="om-pi-content-block">
949
+ {entry.content.map((block, index) => (
950
+ <div key={`${block.type}:${block.id ?? index}`}>
951
+ {renderBlock?.({ block, entry, index }) ?? (
952
+ <MessagePart block={block} entry={entry} renderers={renderers} />
953
+ )}
954
+ </div>
955
+ ))}
956
+ {!entry.content.length && entry.status === "pending" ? (
957
+ <p className="om-pi-muted" role="status">
958
+ Waiting for output…
959
+ </p>
960
+ ) : null}
961
+ </div>
962
+ {entry.errorMessage ? (
963
+ <p className="om-pi-conversation-error" role="alert">
964
+ {entry.errorMessage}
965
+ </p>
966
+ ) : null}
967
+ </article>
968
+ );
969
+
970
+ const statusLabel = (status: "busy" | "degraded" | "idle" | "offline") => {
971
+ if (status === "busy") {
972
+ return "Pi is working";
973
+ }
974
+ if (status === "degraded") {
975
+ return "Pi updates are degraded";
976
+ }
977
+ if (status === "idle") {
978
+ return "Pi is ready";
979
+ }
980
+ return "Pi is offline";
981
+ };
982
+
983
+ export type PiMessageComposerProps = {
984
+ autoFocus?: boolean;
985
+ available: boolean;
986
+ busy?: boolean;
987
+ className?: string;
988
+ sendMessage: PiConversationSend;
989
+ stop?: PiConversationStop;
990
+ style?: OvermuxStyle;
991
+ };
992
+
993
+ export const PiMessageComposer = ({
994
+ autoFocus = false,
995
+ available,
996
+ busy = false,
997
+ className,
998
+ sendMessage,
999
+ stop,
1000
+ style,
1001
+ }: PiMessageComposerProps) => {
1002
+ const messageId = useId();
1003
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
1004
+ const [draft, setDraft] = useState("");
1005
+ const [error, setError] = useState("");
1006
+ const [focused, setFocused] = useState(false);
1007
+ const [sending, setSending] = useState<"followUp" | "steer">();
1008
+ const [stopping, setStopping] = useState(false);
1009
+ const actionsVisible = focused || Boolean(draft);
1010
+ useLayoutEffect(() => {
1011
+ const textarea = textareaRef.current;
1012
+ if (!textarea) {
1013
+ return;
1014
+ }
1015
+ textarea.style.height = "auto";
1016
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 192)}px`;
1017
+ }, [draft]);
1018
+ useLayoutEffect(() => {
1019
+ if (autoFocus && available) {
1020
+ textareaRef.current?.focus();
1021
+ }
1022
+ }, [autoFocus, available]);
1023
+ const submit = async (deliverAs: "followUp" | "steer") => {
1024
+ const message = draft.trim();
1025
+ if (!message || !available || sending || stopping) {
1026
+ return;
1027
+ }
1028
+ setSending(deliverAs);
1029
+ try {
1030
+ await sendMessage({ deliverAs, message });
1031
+ setDraft("");
1032
+ setError("");
1033
+ } catch (cause) {
1034
+ setError(cause instanceof Error ? cause.message : String(cause));
1035
+ } finally {
1036
+ setSending(undefined);
1037
+ }
1038
+ };
1039
+ const abort = async () => {
1040
+ if (!available || !busy || !stop || sending || stopping) {
1041
+ return;
1042
+ }
1043
+ setStopping(true);
1044
+ try {
1045
+ await stop();
1046
+ setError("");
1047
+ } catch (cause) {
1048
+ setError(cause instanceof Error ? cause.message : String(cause));
1049
+ } finally {
1050
+ setStopping(false);
1051
+ }
1052
+ };
1053
+ const onSubmit = (event: FormEvent<HTMLFormElement>) => {
1054
+ event.preventDefault();
1055
+ void submit("steer");
1056
+ };
1057
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
1058
+ if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) {
1059
+ return;
1060
+ }
1061
+ event.preventDefault();
1062
+ void submit(busy && event.shiftKey ? "followUp" : "steer");
1063
+ };
1064
+ return (
1065
+ <form
1066
+ className={["om-pi-composer", className].filter(Boolean).join(" ")}
1067
+ data-om-pi-composer
1068
+ onSubmit={onSubmit}
1069
+ style={style}
1070
+ >
1071
+ <div className="om-pi-composer-box">
1072
+ <label className="om-pi-visually-hidden" htmlFor={messageId}>
1073
+ Message Pi
1074
+ </label>
1075
+ <textarea
1076
+ className={[
1077
+ "om-pi-composer-textarea",
1078
+ busy && !actionsVisible
1079
+ ? "om-pi-composer-textarea-with-stop"
1080
+ : undefined,
1081
+ ]
1082
+ .filter(Boolean)
1083
+ .join(" ")}
1084
+ disabled={!available || Boolean(sending) || stopping}
1085
+ id={messageId}
1086
+ onBlur={() => setFocused(false)}
1087
+ onChange={(event) => setDraft(event.target.value)}
1088
+ onFocus={() => setFocused(true)}
1089
+ onKeyDown={onKeyDown}
1090
+ placeholder={available ? "Message Pi…" : "Pi is offline"}
1091
+ ref={textareaRef}
1092
+ rows={1}
1093
+ value={draft}
1094
+ />
1095
+ {busy && !actionsVisible ? (
1096
+ <button
1097
+ aria-label={stopping ? "Stopping Pi" : "Stop Pi"}
1098
+ className="om-pi-composer-stop om-pi-composer-stop-inline"
1099
+ disabled={!available || !stop || Boolean(sending) || stopping}
1100
+ onClick={() => void abort()}
1101
+ title="Stop Pi"
1102
+ type="button"
1103
+ >
1104
+ <Square aria-hidden="true" />
1105
+ </button>
1106
+ ) : null}
1107
+ <div className="om-pi-composer-actions">
1108
+ <span className="om-pi-muted">Ctrl/⌘ Enter · Shift queues</span>
1109
+ {busy ? (
1110
+ <>
1111
+ {actionsVisible ? (
1112
+ <button
1113
+ aria-label={stopping ? "Stopping Pi" : "Stop Pi"}
1114
+ className="om-pi-composer-stop"
1115
+ disabled={!available || !stop || Boolean(sending) || stopping}
1116
+ onClick={() => void abort()}
1117
+ title="Stop Pi"
1118
+ type="button"
1119
+ >
1120
+ <Square aria-hidden="true" />
1121
+ </button>
1122
+ ) : null}
1123
+ <button
1124
+ aria-label="Follow up after the current turn"
1125
+ className="om-pi-composer-follow-up"
1126
+ disabled={
1127
+ !available || !draft.trim() || Boolean(sending) || stopping
1128
+ }
1129
+ onClick={() => void submit("followUp")}
1130
+ type="button"
1131
+ >
1132
+ {sending === "followUp" ? "Queueing…" : "Follow up"}
1133
+ </button>
1134
+ </>
1135
+ ) : null}
1136
+ <button
1137
+ className="om-pi-composer-send"
1138
+ disabled={
1139
+ !available || !draft.trim() || Boolean(sending) || stopping
1140
+ }
1141
+ type="submit"
1142
+ >
1143
+ <Send aria-hidden="true" />
1144
+ {sending === "steer" ? "Sending…" : "Send"}
1145
+ </button>
1146
+ </div>
1147
+ </div>
1148
+ {error ? (
1149
+ <p className="om-pi-conversation-error" role="alert">
1150
+ {error}
1151
+ </p>
1152
+ ) : null}
1153
+ </form>
1154
+ );
1155
+ };
1156
+
1157
+ export const PiConversation = ({
1158
+ autoFocus = false,
1159
+ conversation,
1160
+ className,
1161
+ classNames = {},
1162
+ emptyState,
1163
+ renderBlock,
1164
+ renderers,
1165
+ sendMessage,
1166
+ setModel,
1167
+ setThinkingLevel,
1168
+ stop,
1169
+ style,
1170
+ }: PiConversationProps) => {
1171
+ const [snapshot, setSnapshot] = useState<PiConversationSnapshot>();
1172
+ useLayoutEffect(() => conversation.subscribe(setSnapshot), [conversation]);
1173
+ const error = conversation.error?.message;
1174
+ const status = snapshot?.status ?? "offline";
1175
+ const scrollerRef = useRef<HTMLDivElement>(null);
1176
+ const followingRef = useRef(true);
1177
+ const [following, setFollowing] = useState(true);
1178
+ const toolRenderers = { ...defaultToolRenderers, ...renderers };
1179
+ const scrollToLatest = useCallback(() => {
1180
+ const scroller = scrollerRef.current;
1181
+ if (!scroller) {
1182
+ return;
1183
+ }
1184
+ followingRef.current = true;
1185
+ setFollowing(true);
1186
+ scroller.scrollTop = scroller.scrollHeight;
1187
+ }, []);
1188
+ const updateFollowing = useCallback(() => {
1189
+ const scroller = scrollerRef.current;
1190
+ if (!scroller) {
1191
+ return;
1192
+ }
1193
+ const next =
1194
+ scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 48;
1195
+ followingRef.current = next;
1196
+ setFollowing(next);
1197
+ }, []);
1198
+ useLayoutEffect(() => {
1199
+ if (followingRef.current) {
1200
+ scrollToLatest();
1201
+ }
1202
+ }, [scrollToLatest, snapshot]);
1203
+ return (
1204
+ <section
1205
+ aria-label="Pi conversation"
1206
+ className={["om-pi-conversation", className, classNames.root]
1207
+ .filter(Boolean)
1208
+ .join(" ")}
1209
+ data-om-agent-status={status}
1210
+ data-om-pi-conversation
1211
+ style={style}
1212
+ >
1213
+ <header
1214
+ className={["om-pi-conversation-status", classNames.status]
1215
+ .filter(Boolean)
1216
+ .join(" ")}
1217
+ >
1218
+ <div className="om-pi-conversation-status-row">
1219
+ <strong>Pi conversation</strong>
1220
+ <span aria-live="polite" className="om-pi-muted" role="status">
1221
+ {!snapshot && !error ? "Connecting to Pi…" : statusLabel(status)}
1222
+ </span>
1223
+ </div>
1224
+ </header>
1225
+ {error ? (
1226
+ <p className="om-pi-conversation-error" role="alert">
1227
+ {error}
1228
+ </p>
1229
+ ) : null}
1230
+ <div
1231
+ className={["om-pi-conversation-scroller", classNames.scroller]
1232
+ .filter(Boolean)
1233
+ .join(" ")}
1234
+ onScroll={updateFollowing}
1235
+ ref={scrollerRef}
1236
+ tabIndex={0}
1237
+ >
1238
+ <div
1239
+ className={["om-pi-conversation-content", classNames.content]
1240
+ .filter(Boolean)
1241
+ .join(" ")}
1242
+ >
1243
+ {snapshot?.entries.map((entry) => (
1244
+ <PiConversationMessage
1245
+ className={["om-pi-conversation-entry", classNames.entry]
1246
+ .filter(Boolean)
1247
+ .join(" ")}
1248
+ entry={entry}
1249
+ key={entry.id}
1250
+ renderBlock={renderBlock}
1251
+ renderers={toolRenderers}
1252
+ />
1253
+ ))}
1254
+ {snapshot && !snapshot.entries.length
1255
+ ? (emptyState ?? (
1256
+ <p className="om-pi-muted">No conversation yet.</p>
1257
+ ))
1258
+ : null}
1259
+ {!snapshot && error ? (
1260
+ <p className="om-pi-muted">
1261
+ Conversation unavailable while Pi is offline.
1262
+ </p>
1263
+ ) : null}
1264
+ </div>
1265
+ </div>
1266
+ {!following ? (
1267
+ <button
1268
+ className="om-pi-conversation-jump"
1269
+ onClick={scrollToLatest}
1270
+ type="button"
1271
+ >
1272
+ Jump to latest
1273
+ </button>
1274
+ ) : null}
1275
+ <PiMessageComposer
1276
+ autoFocus={autoFocus}
1277
+ available={snapshot?.agentAvailable === true}
1278
+ busy={status === "busy"}
1279
+ className={classNames.composer}
1280
+ sendMessage={sendMessage}
1281
+ stop={stop}
1282
+ />
1283
+ {snapshot?.sessionMetadata ? (
1284
+ <PiConversationMetadata
1285
+ busy={status === "busy"}
1286
+ metadata={snapshot.sessionMetadata}
1287
+ setModel={setModel}
1288
+ setThinkingLevel={setThinkingLevel}
1289
+ />
1290
+ ) : null}
1291
+ </section>
1292
+ );
1293
+ };