@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.
- package/README.md +118 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +40 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-2jNv4tol.d.ts +34 -0
- package/dist/config-2jNv4tol.d.ts.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +47 -0
- package/dist/config.js.map +1 -0
- package/dist/extension.d.ts +19 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +265 -0
- package/dist/extension.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonl-tail.d.ts +13 -0
- package/dist/jsonl-tail.d.ts.map +1 -0
- package/dist/jsonl-tail.js +83 -0
- package/dist/jsonl-tail.js.map +1 -0
- package/dist/live-events-ClhFOMGW.js +497 -0
- package/dist/live-events-ClhFOMGW.js.map +1 -0
- package/dist/live-events-DAmf6RRx.d.ts +107 -0
- package/dist/live-events-DAmf6RRx.d.ts.map +1 -0
- package/dist/live-events.d.ts +2 -0
- package/dist/live-events.js +2 -0
- package/dist/notification-DzOd9cRc.d.ts +20 -0
- package/dist/notification-DzOd9cRc.d.ts.map +1 -0
- package/dist/notification.d.ts +2 -0
- package/dist/notification.js +54 -0
- package/dist/notification.js.map +1 -0
- package/dist/plugin.d.ts +90 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +213 -0
- package/dist/plugin.js.map +1 -0
- package/dist/projection.d.ts +101 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +550 -0
- package/dist/projection.js.map +1 -0
- package/dist/protocol-CsrnSPOv.d.ts +115 -0
- package/dist/protocol-CsrnSPOv.d.ts.map +1 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +365 -0
- package/dist/protocol.js.map +1 -0
- package/dist/react.d.ts +139 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +796 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +4 -0
- package/dist/session-status-CZLTo8Km.d.ts +71 -0
- package/dist/session-status-CZLTo8Km.d.ts.map +1 -0
- package/dist/styles.css +619 -0
- package/docs/index.md +10 -0
- package/package.json +115 -0
- package/src/cli.ts +59 -0
- package/src/config.ts +87 -0
- package/src/extension.ts +486 -0
- package/src/index.ts +14 -0
- package/src/jsonl-tail.ts +110 -0
- package/src/live-events.ts +578 -0
- package/src/notification.ts +109 -0
- package/src/plugin.ts +369 -0
- package/src/projection.ts +995 -0
- package/src/protocol.ts +805 -0
- package/src/react.tsx +1293 -0
- package/src/server.ts +15 -0
- package/src/session-status.ts +379 -0
- 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
|
+
};
|