@raingor/pi-web-switch 0.4.1 → 0.4.2

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.
@@ -1,595 +0,0 @@
1
- // MessageView — renders a single chat message (user, assistant, tool result, bash).
2
- // Ported and simplified from pi-web's components/MessageView.tsx.
3
-
4
- import { memo, useState, type ReactNode } from "react";
5
- import type {
6
- AgentMessage,
7
- AssistantMessage,
8
- ToolResultMessage,
9
- ToolCallContent,
10
- TextContent,
11
- ThinkingContent,
12
- ImageContent,
13
- BashExecutionMessage,
14
- CustomMessage,
15
- } from "@/types/chat";
16
-
17
- interface Props {
18
- message: AgentMessage;
19
- isStreaming?: boolean;
20
- modelNames?: Record<string, string>;
21
- cwd?: string;
22
- onOpenFile?: (filePath: string) => void;
23
- entryId?: string;
24
- onFork?: (entryId: string) => void;
25
- forking?: boolean;
26
- onEditContent?: (content: string) => void;
27
- showTimestamp?: boolean;
28
- sessionId?: string;
29
- }
30
-
31
- function formatTimestamp(ts?: number): string {
32
- if (!ts) return "";
33
- const d = new Date(ts);
34
- return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
35
- }
36
-
37
- function extractText(content: string | (TextContent | ImageContent)[]): string {
38
- if (typeof content === "string") return content;
39
- return content
40
- .filter((b): b is TextContent => b.type === "text")
41
- .map((b) => b.text)
42
- .join("\n");
43
- }
44
-
45
- // ─── Tool Call Block ─────────────────────────────────────
46
-
47
- function ToolCallBlock({ block }: { block: ToolCallContent }) {
48
- const [expanded, setExpanded] = useState(false);
49
- const inputStr = JSON.stringify(block.input, null, 2);
50
-
51
- return (
52
- <div style={{
53
- border: "1px solid var(--border, #e5e7eb)",
54
- borderRadius: 8,
55
- background: "var(--bg-panel, #f9fafb)",
56
- overflow: "hidden",
57
- margin: "8px 0",
58
- }}>
59
- <button
60
- onClick={() => setExpanded(!expanded)}
61
- style={{
62
- display: "flex",
63
- alignItems: "center",
64
- gap: 8,
65
- width: "100%",
66
- padding: "8px 12px",
67
- background: "none",
68
- border: "none",
69
- cursor: "pointer",
70
- textAlign: "left",
71
- color: "var(--text-muted, #6b7280)",
72
- fontSize: 13,
73
- }}
74
- >
75
- <svg
76
- width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6"
77
- strokeLinecap="round" strokeLinejoin="round"
78
- style={{ transform: expanded ? "rotate(90deg)" : "none", transition: "transform 0.15s", flexShrink: 0 }}
79
- >
80
- <polyline points="4 2.5 7.5 6 4 9.5" />
81
- </svg>
82
- <span style={{ fontWeight: 600, color: "var(--accent, #2563eb)" }}>{block.toolName}</span>
83
- <span style={{ fontSize: 11, opacity: 0.6 }}>tool call</span>
84
- </button>
85
- {expanded && (
86
- <pre style={{
87
- margin: 0,
88
- padding: "8px 12px",
89
- borderTop: "1px solid var(--border, #e5e7eb)",
90
- fontSize: 12,
91
- fontFamily: "var(--font-mono, monospace)",
92
- color: "var(--text-muted, #6b7280)",
93
- whiteSpace: "pre-wrap",
94
- wordBreak: "break-word",
95
- overflow: "auto",
96
- maxHeight: 300,
97
- }}>
98
- {inputStr}
99
- </pre>
100
- )}
101
- </div>
102
- );
103
- }
104
-
105
- // ─── Thinking Block ──────────────────────────────────────
106
-
107
- function ThinkingBlock({ block }: { block: ThinkingContent }) {
108
- const [expanded, setExpanded] = useState(false);
109
- if (!block.thinking && block.deferred) {
110
- return (
111
- <div style={{ margin: "6px 0", fontSize: 12, color: "var(--text-dim)", fontStyle: "italic" }}>
112
- Thinking (collapsed)
113
- </div>
114
- );
115
- }
116
-
117
- return (
118
- <div style={{ margin: "8px 0" }}>
119
- <button
120
- onClick={() => setExpanded(!expanded)}
121
- style={{
122
- display: "flex",
123
- alignItems: "center",
124
- gap: 6,
125
- background: "none",
126
- border: "none",
127
- cursor: "pointer",
128
- fontSize: 12,
129
- color: "var(--text-muted, #6b7280)",
130
- }}
131
- >
132
- <svg
133
- width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6"
134
- strokeLinecap="round" strokeLinejoin="round"
135
- style={{ transform: expanded ? "rotate(90deg)" : "none", transition: "transform 0.15s" }}
136
- >
137
- <polyline points="4 2.5 7.5 6 4 9.5" />
138
- </svg>
139
- Thinking
140
- </button>
141
- {expanded && (
142
- <div style={{
143
- marginTop: 6,
144
- padding: "8px 12px",
145
- borderLeft: "2px solid var(--border, #e5e7eb)",
146
- fontSize: 13,
147
- color: "var(--text-muted, #6b7280)",
148
- whiteSpace: "pre-wrap",
149
- fontFamily: "var(--font-mono, monospace)",
150
- lineHeight: 1.6,
151
- }}>
152
- {block.thinking}
153
- </div>
154
- )}
155
- </div>
156
- );
157
- }
158
-
159
- // ─── Image Block ─────────────────────────────────────────
160
-
161
- function ImageBlock({ block }: { block: ImageContent }) {
162
- const src = block.source.type === "url"
163
- ? block.source.url
164
- : `data:${block.source.media_type ?? "image/png"};base64,${block.source.data}`;
165
- return (
166
- <img
167
- src={src}
168
- alt="attachment"
169
- style={{
170
- maxWidth: "100%",
171
- maxHeight: 400,
172
- borderRadius: 8,
173
- margin: "8px 0",
174
- }}
175
- />
176
- );
177
- }
178
-
179
- // ─── Tool Result Block ───────────────────────────────────
180
-
181
- function ToolResultView({ message }: { message: ToolResultMessage }) {
182
- const [expanded, setExpanded] = useState(false);
183
- const textContent = message.content
184
- .filter((b): b is TextContent => b.type === "text")
185
- .map((b) => b.text)
186
- .join("\n");
187
-
188
- const truncated = textContent.length > 500;
189
- const displayText = expanded || !truncated ? textContent : textContent.slice(0, 500) + "...";
190
-
191
- return (
192
- <div style={{
193
- margin: "4px 0 8px 0",
194
- padding: "8px 12px",
195
- borderRadius: 8,
196
- background: message.isError ? "rgba(239,68,68,0.06)" : "var(--bg-panel, #f9fafb)",
197
- border: `1px solid ${message.isError ? "rgba(239,68,68,0.2)" : "var(--border, #e5e7eb)"}`,
198
- fontSize: 13,
199
- }}>
200
- {message.toolName && (
201
- <div style={{
202
- fontSize: 11,
203
- fontWeight: 600,
204
- color: message.isError ? "#dc2626" : "var(--text-muted, #6b7280)",
205
- marginBottom: 4,
206
- }}>
207
- {message.toolName} result {message.isError ? "(error)" : ""}
208
- </div>
209
- )}
210
- <pre style={{
211
- margin: 0,
212
- fontFamily: "var(--font-mono, monospace)",
213
- color: message.isError ? "#dc2626" : "var(--text-muted, #6b7280)",
214
- whiteSpace: "pre-wrap",
215
- wordBreak: "break-word",
216
- lineHeight: 1.5,
217
- }}>
218
- {displayText}
219
- </pre>
220
- {truncated && (
221
- <button
222
- onClick={() => setExpanded(!expanded)}
223
- style={{
224
- marginTop: 4,
225
- background: "none",
226
- border: "none",
227
- color: "var(--accent, #2563eb)",
228
- cursor: "pointer",
229
- fontSize: 12,
230
- }}
231
- >
232
- {expanded ? "Show less" : `Show ${textContent.length - 500} more characters`}
233
- </button>
234
- )}
235
- </div>
236
- );
237
- }
238
-
239
- // ─── Bash Execution View ─────────────────────────────────
240
-
241
- function BashExecutionView({ message }: { message: BashExecutionMessage }) {
242
- return (
243
- <div style={{
244
- margin: "8px 0",
245
- borderRadius: 8,
246
- overflow: "hidden",
247
- border: "1px solid var(--border, #e5e7eb)",
248
- }}>
249
- <div style={{
250
- padding: "6px 12px",
251
- background: "var(--bg-panel, #f9fafb)",
252
- fontSize: 12,
253
- fontFamily: "var(--font-mono, monospace)",
254
- color: "var(--text-muted, #6b7280)",
255
- display: "flex",
256
- alignItems: "center",
257
- gap: 6,
258
- }}>
259
- <span style={{ color: "var(--accent, #2563eb)" }}>$</span>
260
- <span>{message.command}</span>
261
- {message.exitCode !== undefined && (
262
- <span style={{ marginLeft: "auto", color: message.exitCode === 0 ? "#10b981" : "#dc2626" }}>
263
- exit {message.exitCode}
264
- </span>
265
- )}
266
- </div>
267
- {message.output && (
268
- <pre style={{
269
- margin: 0,
270
- padding: "8px 12px",
271
- fontSize: 12,
272
- fontFamily: "var(--font-mono, monospace)",
273
- color: "var(--text-muted, #6b7280)",
274
- whiteSpace: "pre-wrap",
275
- wordBreak: "break-word",
276
- maxHeight: 300,
277
- overflow: "auto",
278
- }}>
279
- {message.output}
280
- </pre>
281
- )}
282
- </div>
283
- );
284
- }
285
-
286
- // ─── Markdown Renderer (simplified) ──────────────────────
287
-
288
- function renderMarkdown(text: string): ReactNode {
289
- // Simple markdown rendering: code blocks, inline code, bold, links, line breaks
290
- const parts: ReactNode[] = [];
291
- const codeBlockRegex = /```(\w+)?\n?([\s\S]*?)```/g;
292
- let lastIndex = 0;
293
- let match;
294
- let key = 0;
295
-
296
- while ((match = codeBlockRegex.exec(text)) !== null) {
297
- if (match.index > lastIndex) {
298
- parts.push(renderInlineMarkdown(text.slice(lastIndex, match.index), `md-${key++}`));
299
- }
300
- const lang = match[1] || "text";
301
- const code = match[2];
302
- parts.push(
303
- <div key={`code-${key++}`} style={{
304
- margin: "8px 0",
305
- borderRadius: 8,
306
- overflow: "hidden",
307
- border: "1px solid var(--border, #e5e7eb)",
308
- }}>
309
- <div style={{
310
- padding: "4px 12px",
311
- background: "var(--bg-panel, #f9fafb)",
312
- fontSize: 11,
313
- color: "var(--text-dim)",
314
- fontFamily: "var(--font-mono, monospace)",
315
- borderBottom: "1px solid var(--border, #e5e7eb)",
316
- }}>
317
- {lang}
318
- </div>
319
- <pre style={{
320
- margin: 0,
321
- padding: "10px 12px",
322
- fontSize: 13,
323
- fontFamily: "var(--font-mono, monospace)",
324
- color: "var(--text, #1f2937)",
325
- whiteSpace: "pre-wrap",
326
- wordBreak: "break-word",
327
- overflow: "auto",
328
- maxHeight: 500,
329
- }}>
330
- <code>{code}</code>
331
- </pre>
332
- </div>
333
- );
334
- lastIndex = match.index + match[0].length;
335
- }
336
-
337
- if (lastIndex < text.length) {
338
- parts.push(renderInlineMarkdown(text.slice(lastIndex), `md-${key++}`));
339
- }
340
-
341
- return <>{parts}</>;
342
- }
343
-
344
- function renderInlineMarkdown(text: string, keyPrefix: string): ReactNode {
345
- // Split by lines for paragraph rendering
346
- const lines = text.split("\n");
347
- return (
348
- <div key={keyPrefix}>
349
- {lines.map((line, i) => (
350
- <div key={`${keyPrefix}-${i}`} style={{ minHeight: "1.5em" }}>
351
- {renderLine(line, `${keyPrefix}-${i}`)}
352
- </div>
353
- ))}
354
- </div>
355
- );
356
- }
357
-
358
- function renderLine(line: string, keyPrefix: string): ReactNode {
359
- // Handle inline code, bold, links
360
- const parts: ReactNode[] = [];
361
- const regex = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g;
362
- let lastIndex = 0;
363
- let match;
364
- let key = 0;
365
-
366
- while ((match = regex.exec(line)) !== null) {
367
- if (match.index > lastIndex) {
368
- parts.push(line.slice(lastIndex, match.index));
369
- }
370
- const token = match[0];
371
- if (token.startsWith("`")) {
372
- parts.push(
373
- <code key={`${keyPrefix}-${key++}`} style={{
374
- padding: "1px 5px",
375
- borderRadius: 4,
376
- background: "var(--bg-panel, #f3f4f6)",
377
- fontSize: "0.9em",
378
- fontFamily: "var(--font-mono, monospace)",
379
- }}>
380
- {token.slice(1, -1)}
381
- </code>
382
- );
383
- } else if (token.startsWith("**")) {
384
- parts.push(<strong key={`${keyPrefix}-${key++}`}>{token.slice(2, -2)}</strong>);
385
- } else if (token.startsWith("[")) {
386
- const linkMatch = token.match(/\[([^\]]+)\]\(([^)]+)\)/);
387
- if (linkMatch) {
388
- parts.push(
389
- <a key={`${keyPrefix}-${key++}`} href={linkMatch[2]} target="_blank" rel="noopener noreferrer"
390
- style={{ color: "var(--accent, #2563eb)", textDecoration: "underline" }}>
391
- {linkMatch[1]}
392
- </a>
393
- );
394
- }
395
- }
396
- lastIndex = match.index + token.length;
397
- }
398
-
399
- if (lastIndex < line.length) {
400
- parts.push(line.slice(lastIndex));
401
- }
402
-
403
- return parts.length > 0 ? parts : (line === "" ? "\u200B" : line);
404
- }
405
-
406
- // ─── Main MessageView ────────────────────────────────────
407
-
408
- function MessageViewImpl({
409
- message,
410
- isStreaming,
411
- modelNames,
412
- cwd,
413
- onOpenFile,
414
- entryId,
415
- onFork,
416
- forking,
417
- onEditContent,
418
- showTimestamp,
419
- sessionId,
420
- }: Props) {
421
- if (message.role === "user") {
422
- const text = typeof message.content === "string"
423
- ? message.content
424
- : extractText(message.content);
425
- const images = Array.isArray(message.content)
426
- ? message.content.filter((b): b is ImageContent => b.type === "image")
427
- : [];
428
-
429
- return (
430
- <div style={{
431
- display: "flex",
432
- justifyContent: "flex-end",
433
- marginBottom: 16,
434
- }}>
435
- <div style={{
436
- maxWidth: "80%",
437
- padding: "10px 14px",
438
- borderRadius: "16px 16px 4px 16px",
439
- background: "var(--accent, #2563eb)",
440
- color: "#fff",
441
- fontSize: 14,
442
- lineHeight: 1.6,
443
- wordBreak: "break-word",
444
- }}>
445
- {text && <div>{renderMarkdown(text)}</div>}
446
- {images.map((img, i) => (
447
- <ImageBlock key={i} block={img} />
448
- ))}
449
- </div>
450
- </div>
451
- );
452
- }
453
-
454
- if (message.role === "assistant") {
455
- const am = message as AssistantMessage;
456
- const blocks = am.content;
457
- const modelLabel = am.provider && am.model
458
- ? modelNames?.[`${am.provider}:${am.model}`] ?? am.model
459
- : am.model;
460
-
461
- return (
462
- <div style={{ marginBottom: 16 }}>
463
- <div style={{
464
- display: "flex",
465
- alignItems: "center",
466
- gap: 8,
467
- marginBottom: 4,
468
- }}>
469
- <span style={{
470
- fontSize: 13,
471
- fontWeight: 600,
472
- color: "var(--text, #1f2937)",
473
- }}>
474
- π {modelLabel && <span style={{ fontWeight: 400, color: "var(--text-muted, #6b7280)", fontSize: 12 }}>· {modelLabel}</span>}
475
- </span>
476
- {showTimestamp && message.timestamp && (
477
- <span style={{ fontSize: 11, color: "var(--text-dim)" }}>
478
- {formatTimestamp(message.timestamp)}
479
- </span>
480
- )}
481
- {onFork && entryId && !isStreaming && (
482
- <button
483
- onClick={() => onFork(entryId)}
484
- disabled={forking}
485
- title="Fork from here"
486
- style={{
487
- background: "none",
488
- border: "none",
489
- cursor: forking ? "wait" : "pointer",
490
- color: "var(--text-dim)",
491
- padding: 0,
492
- fontSize: 11,
493
- }}
494
- >
495
- {forking ? "⏳" : "⎇"}
496
- </button>
497
- )}
498
- </div>
499
- <div style={{
500
- padding: "0 2px",
501
- fontSize: 14,
502
- lineHeight: 1.7,
503
- color: "var(--text, #1f2937)",
504
- }}>
505
- {blocks.map((block, i) => {
506
- switch (block.type) {
507
- case "text":
508
- return <div key={i}>{renderMarkdown(block.text)}</div>;
509
- case "thinking":
510
- return <ThinkingBlock key={i} block={block} />;
511
- case "toolCall":
512
- return <ToolCallBlock key={i} block={block} />;
513
- case "image":
514
- return <ImageBlock key={i} block={block} />;
515
- default:
516
- return null;
517
- }
518
- })}
519
- {am.errorMessage && (
520
- <div style={{
521
- marginTop: 8,
522
- padding: "8px 12px",
523
- borderRadius: 8,
524
- background: "rgba(239,68,68,0.06)",
525
- color: "#dc2626",
526
- fontSize: 13,
527
- }}>
528
- {am.errorMessage}
529
- </div>
530
- )}
531
- {am.usage && (
532
- <div style={{
533
- marginTop: 6,
534
- fontSize: 11,
535
- color: "var(--text-dim)",
536
- display: "flex",
537
- gap: 12,
538
- }}>
539
- <span>in: {am.usage.input.toLocaleString()}</span>
540
- <span>out: {am.usage.output.toLocaleString()}</span>
541
- {am.usage.cacheRead > 0 && <span>cache: {am.usage.cacheRead.toLocaleString()}</span>}
542
- {am.usage.cost?.total ? <span>${am.usage.cost.total.toFixed(4)}</span> : null}
543
- </div>
544
- )}
545
- </div>
546
- </div>
547
- );
548
- }
549
-
550
- if (message.role === "toolResult") {
551
- return <ToolResultView message={message as ToolResultMessage} />;
552
- }
553
-
554
- if (message.role === "bashExecution") {
555
- return <BashExecutionView message={message as BashExecutionMessage} />;
556
- }
557
-
558
- if (message.role === "custom") {
559
- const cm = message as CustomMessage;
560
- if (!cm.display) return null;
561
- const text = typeof cm.content === "string" ? cm.content : extractText(cm.content);
562
- if (cm.customType === "compaction") {
563
- return (
564
- <div style={{
565
- margin: "12px 0",
566
- padding: "8px 14px",
567
- borderRadius: 8,
568
- background: "var(--bg-panel, #f9fafb)",
569
- border: "1px solid var(--border, #e5e7eb)",
570
- fontSize: 13,
571
- color: "var(--text-muted, #6b7280)",
572
- fontStyle: "italic",
573
- }}>
574
- {text}
575
- </div>
576
- );
577
- }
578
- return (
579
- <div style={{
580
- margin: "8px 0",
581
- padding: "8px 14px",
582
- borderRadius: 8,
583
- background: "var(--bg-panel, #f9fafb)",
584
- fontSize: 13,
585
- color: "var(--text-muted, #6b7280)",
586
- }}>
587
- {text}
588
- </div>
589
- );
590
- }
591
-
592
- return null;
593
- }
594
-
595
- export const MessageView = memo(MessageViewImpl);