pum-agent 0.2.23-beta.1 → 0.2.25-beta.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/package.json +1 -1
- package/src/app.tsx +12 -11
- package/src/cli.ts +2 -2
- package/src/platform.ts +2 -4
- package/src/subagents/manager.ts +4 -16
- package/src/text-wrap.ts +84 -0
- package/src/transcript.tsx +111 -8
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -52,10 +52,12 @@ import {
|
|
|
52
52
|
needsTranscriptGap,
|
|
53
53
|
topAnchorScrollTop,
|
|
54
54
|
PendingMessageLine,
|
|
55
|
+
flushStream,
|
|
55
56
|
rawToolText,
|
|
56
57
|
resolveGoalReview,
|
|
57
58
|
resolvePendingDelivery,
|
|
58
59
|
settleTranscriptMessage,
|
|
60
|
+
streamedDelta,
|
|
59
61
|
StreamLine,
|
|
60
62
|
TextLine,
|
|
61
63
|
ToolLine,
|
|
@@ -310,8 +312,14 @@ const QUIT_WINDOW_MS = 2000;
|
|
|
310
312
|
const MAX_INPUT_ROWS = 8;
|
|
311
313
|
/** How long a scroll to a row waits between tries for React to draw it. */
|
|
312
314
|
const ROW_DRAW_RETRY_MS = 30;
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
+
/**
|
|
316
|
+
* How many of those tries it makes before it gives up.
|
|
317
|
+
*
|
|
318
|
+
* A busy machine with a long session can take most of a second to draw a row
|
|
319
|
+
* that had to be mounted first, and a jump that gives up before then looks to
|
|
320
|
+
* the reader exactly like a dead key.
|
|
321
|
+
*/
|
|
322
|
+
const ROW_DRAW_TRIES = 30;
|
|
315
323
|
/** Frames a scroll correction waits for its rows before it gives up. */
|
|
316
324
|
const ANCHOR_FRAME_BUDGET = 30;
|
|
317
325
|
/** Keys that move around without changing the text. */
|
|
@@ -731,11 +739,7 @@ const DEFAULT_PROMPT_STASH_STORE: PromptStashStore = {
|
|
|
731
739
|
|
|
732
740
|
/** Move any buffered stream into the transcript so later lines land in order. */
|
|
733
741
|
function flushed(t: Transcript): Transcript {
|
|
734
|
-
|
|
735
|
-
const line: Line = { kind: "text", role: t.stream.kind, text: t.stream.text.trim() };
|
|
736
|
-
return { lines: [...t.lines, line], stream: null, pending: t.pending };
|
|
737
|
-
}
|
|
738
|
-
return { ...t, stream: null };
|
|
742
|
+
return flushStream(t);
|
|
739
743
|
}
|
|
740
744
|
|
|
741
745
|
export function App({
|
|
@@ -1882,10 +1886,7 @@ export function App({
|
|
|
1882
1886
|
}, [activeAgentId, agents.map((agent) => agent.id).join(":")]);
|
|
1883
1887
|
|
|
1884
1888
|
const delta = (kind: "assistant" | "thinking", text: string) =>
|
|
1885
|
-
setTx((t) =>
|
|
1886
|
-
if (t.stream?.kind === kind) return { ...t, stream: { kind, text: t.stream.text + text } };
|
|
1887
|
-
return { ...flushed(t), stream: { kind, text } };
|
|
1888
|
-
});
|
|
1889
|
+
setTx((t) => streamedDelta(t, kind, text));
|
|
1889
1890
|
|
|
1890
1891
|
const patchTool = (id: string, patch: Partial<ToolCall>) =>
|
|
1891
1892
|
setTx((t) => ({
|
package/src/cli.ts
CHANGED
|
@@ -229,8 +229,8 @@ session history, Ctrl+T for triggers, and Ctrl+C twice on an empty prompt to qui
|
|
|
229
229
|
Configuration:
|
|
230
230
|
PUM stores credentials, settings, and sessions in its own configuration
|
|
231
231
|
directory. Set PUM_DIR to override the complete directory. Defaults are
|
|
232
|
-
$XDG_CONFIG_HOME/pum or ~/.config/pum on Linux,
|
|
233
|
-
|
|
232
|
+
$XDG_CONFIG_HOME/pum or ~/.config/pum on Linux and Windows, and
|
|
233
|
+
~/Library/Application Support/pum on macOS.
|
|
234
234
|
|
|
235
235
|
Package: ${metadata.name}
|
|
236
236
|
Executable: pum
|
package/src/platform.ts
CHANGED
|
@@ -19,11 +19,9 @@ export function defaultAgentDir(
|
|
|
19
19
|
): string {
|
|
20
20
|
const paths = pathApi(platform);
|
|
21
21
|
if (env.PUM_DIR) return paths.resolve(env.PUM_DIR);
|
|
22
|
-
if (platform === "win32") {
|
|
23
|
-
const base = env.LOCALAPPDATA ?? env.APPDATA ?? paths.join(home, "AppData", "Local");
|
|
24
|
-
return paths.join(base, "pum");
|
|
25
|
-
}
|
|
26
22
|
if (platform === "darwin") return paths.join(home, "Library", "Application Support", "pum");
|
|
23
|
+
// Windows shares the Linux layout on purpose: one path to document, and a
|
|
24
|
+
// config directory the user can find and back up next to their other tools.
|
|
27
25
|
return paths.join(env.XDG_CONFIG_HOME ?? paths.join(home, ".config"), "pum");
|
|
28
26
|
}
|
|
29
27
|
|
package/src/subagents/manager.ts
CHANGED
|
@@ -68,8 +68,10 @@ import type { SandboxMode } from "../sandbox/types";
|
|
|
68
68
|
import { readBranch } from "../git-branch";
|
|
69
69
|
import { GOAL_VERDICT_TOOL_NAME, goalVerdictParameters } from "../goal-judge";
|
|
70
70
|
import {
|
|
71
|
+
flushStream,
|
|
71
72
|
resolvePendingDelivery,
|
|
72
73
|
settleTranscriptMessage,
|
|
74
|
+
streamedDelta,
|
|
73
75
|
type Line,
|
|
74
76
|
type PendingLine,
|
|
75
77
|
} from "../transcript";
|
|
@@ -314,15 +316,7 @@ type ManagerOptions = {
|
|
|
314
316
|
const emptyTranscript = (): AgentTranscript => ({ lines: [], stream: null, pending: [] });
|
|
315
317
|
|
|
316
318
|
function flushTranscript(transcript: AgentTranscript): AgentTranscript {
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
lines: [
|
|
320
|
-
...transcript.lines,
|
|
321
|
-
{ kind: "text", role: transcript.stream.kind, text: transcript.stream.text.trim() },
|
|
322
|
-
],
|
|
323
|
-
stream: null,
|
|
324
|
-
pending: transcript.pending,
|
|
325
|
-
};
|
|
319
|
+
return flushStream(transcript);
|
|
326
320
|
}
|
|
327
321
|
|
|
328
322
|
function snapshotMetadata(snapshot: SubagentSnapshot): Omit<SubagentSnapshot, "transcript"> {
|
|
@@ -981,13 +975,7 @@ export class SubagentManager {
|
|
|
981
975
|
if (kind === "assistant" && record.completionMessageIds?.size) {
|
|
982
976
|
record.completionResponse = (record.completionResponse ?? "") + update.delta;
|
|
983
977
|
}
|
|
984
|
-
this.updateTranscript(record, (value) =>
|
|
985
|
-
if (value.stream?.kind === kind) {
|
|
986
|
-
return { ...value, stream: { kind, text: value.stream.text + update.delta } };
|
|
987
|
-
}
|
|
988
|
-
const flushed = flushTranscript(value);
|
|
989
|
-
return { ...flushed, stream: { kind, text: update.delta } };
|
|
990
|
-
});
|
|
978
|
+
this.updateTranscript(record, (value) => streamedDelta(value, kind, update.delta));
|
|
991
979
|
break;
|
|
992
980
|
}
|
|
993
981
|
case "tool_execution_start":
|
package/src/text-wrap.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapping that breaks at spaces only.
|
|
3
|
+
*
|
|
4
|
+
* OpenTUI's `wrapMode="word"` also breaks after `.`, `,`, `:`, `-`, `/` and
|
|
5
|
+
* `(`. In reasoning text, which is dense with file names, flags and paths,
|
|
6
|
+
* that puts `auth.` and `json` on two rows, and reads as a linebreak in the
|
|
7
|
+
* middle of a word. Pre-wrapped text leaves the renderer nothing to break.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** OpenTUI paints one tab as two columns, not as a jump to a tab stop. */
|
|
11
|
+
const TAB = " ";
|
|
12
|
+
|
|
13
|
+
/** Columns a run of text occupies on screen. */
|
|
14
|
+
export function textColumns(text: string): number {
|
|
15
|
+
return Bun.stringWidth(text.replaceAll("\t", TAB));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Break `text` into rows of at most `width` columns, at spaces only.
|
|
20
|
+
*
|
|
21
|
+
* A word wider than the whole row is split, because no break point exists.
|
|
22
|
+
* Existing line breaks and leading indentation are kept.
|
|
23
|
+
*/
|
|
24
|
+
export function wrapAtSpaces(text: string, width: number): string {
|
|
25
|
+
if (!Number.isFinite(width) || width < 1) return text;
|
|
26
|
+
const rows: string[] = [];
|
|
27
|
+
for (const line of text.split("\n")) rows.push(...wrapLine(line, width));
|
|
28
|
+
return rows.join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function wrapLine(line: string, width: number): string[] {
|
|
32
|
+
if (textColumns(line) <= width) return [line];
|
|
33
|
+
const rows: string[] = [];
|
|
34
|
+
// Whitespace runs are kept as their own segments, so the indentation of a
|
|
35
|
+
// code line survives and two words never lose the space between them.
|
|
36
|
+
const segments = line.split(/(\s+)/);
|
|
37
|
+
let row = "";
|
|
38
|
+
let gap = "";
|
|
39
|
+
const flush = () => {
|
|
40
|
+
if (row.length > 0) rows.push(row);
|
|
41
|
+
row = "";
|
|
42
|
+
gap = "";
|
|
43
|
+
};
|
|
44
|
+
for (const segment of segments) {
|
|
45
|
+
if (segment.length === 0) continue;
|
|
46
|
+
if (/^\s+$/.test(segment)) {
|
|
47
|
+
// Indentation belongs to the row it opens; a gap between two words is
|
|
48
|
+
// only paid for when the following word fits on the same row.
|
|
49
|
+
if (row.length === 0) row = segment;
|
|
50
|
+
else gap = segment;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (textColumns(row + gap + segment) <= width) {
|
|
54
|
+
row += gap + segment;
|
|
55
|
+
gap = "";
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
flush();
|
|
59
|
+
if (textColumns(segment) <= width) {
|
|
60
|
+
row = segment;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const pieces = splitWord(segment, width);
|
|
64
|
+
rows.push(...pieces.slice(0, -1));
|
|
65
|
+
row = pieces[pieces.length - 1]!;
|
|
66
|
+
}
|
|
67
|
+
flush();
|
|
68
|
+
return rows.length > 0 ? rows : [line];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Cut a word that cannot fit any row into row-sized pieces. */
|
|
72
|
+
function splitWord(word: string, width: number): string[] {
|
|
73
|
+
const pieces: string[] = [];
|
|
74
|
+
let piece = "";
|
|
75
|
+
for (const char of word) {
|
|
76
|
+
if (textColumns(piece + char) > width && piece.length > 0) {
|
|
77
|
+
pieces.push(piece);
|
|
78
|
+
piece = "";
|
|
79
|
+
}
|
|
80
|
+
piece += char;
|
|
81
|
+
}
|
|
82
|
+
if (piece.length > 0) pieces.push(piece);
|
|
83
|
+
return pieces;
|
|
84
|
+
}
|
package/src/transcript.tsx
CHANGED
|
@@ -2,13 +2,14 @@ import {
|
|
|
2
2
|
StyledText,
|
|
3
3
|
TextAttributes,
|
|
4
4
|
fg,
|
|
5
|
+
type BoxRenderable,
|
|
5
6
|
type MarkdownRenderable,
|
|
6
7
|
type MouseEvent as OpenTuiMouseEvent,
|
|
7
8
|
type SyntaxStyle,
|
|
8
9
|
type TextChunk,
|
|
9
10
|
type TextRenderable,
|
|
10
11
|
} from "@opentui/core";
|
|
11
|
-
import type
|
|
12
|
+
import { useRenderer, type MarkdownProps } from "@opentui/react";
|
|
12
13
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
13
14
|
import {
|
|
14
15
|
useBlinkingText,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
goalReviewHeadline,
|
|
23
24
|
type GoalReviewStatus,
|
|
24
25
|
} from "./goal-review";
|
|
26
|
+
import { wrapAtSpaces } from "./text-wrap";
|
|
25
27
|
import type { Theme } from "./theme";
|
|
26
28
|
import { bashOutputWindow, type BashOutputWindow, type ToolCall } from "./tool-line";
|
|
27
29
|
import type { TranscriptOutputMode } from "./transcript-output";
|
|
@@ -158,10 +160,65 @@ export function resolveGoalReview<T extends PendingTranscriptState>(
|
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
/** Finish the stream, then insert messages that arrived while it was active. */
|
|
163
|
+
/**
|
|
164
|
+
* The line a buffered stream becomes, or nothing when it holds no text.
|
|
165
|
+
*
|
|
166
|
+
* Reasoning keeps its text as it arrived. The rest of it can still turn up
|
|
167
|
+
* after the answer has started, and it is appended to this line, so a space at
|
|
168
|
+
* the join has to survive; the row normalizes what it shows anyway. An answer
|
|
169
|
+
* is markdown, where leading whitespace would open a code block, so it is
|
|
170
|
+
* trimmed.
|
|
171
|
+
*/
|
|
172
|
+
export function streamedLine(stream: PendingTranscriptState["stream"]): Line | null {
|
|
173
|
+
if (!stream?.text.trim()) return null;
|
|
174
|
+
const text = stream.kind === "thinking" ? stream.text : stream.text.trim();
|
|
175
|
+
return { kind: "text", role: stream.kind, text };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Move any buffered stream into the transcript, and close the stream. */
|
|
179
|
+
export function flushStream<T extends PendingTranscriptState>(value: T): T {
|
|
180
|
+
const line = streamedLine(value.stream);
|
|
181
|
+
if (!line) return { ...value, stream: null };
|
|
182
|
+
return { ...value, lines: [...value.lines, line], stream: null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Take one streamed delta of `kind` into the transcript.
|
|
187
|
+
*
|
|
188
|
+
* A reasoning provider does not always finish one stream before it starts the
|
|
189
|
+
* other: the last of the reasoning can arrive after the first words of the
|
|
190
|
+
* answer. Committing the answer to make room for it would cut the answer in
|
|
191
|
+
* two, which reads as a line break in the middle of a sentence. So the answer
|
|
192
|
+
* keeps the stream, and the late reasoning rejoins the row it belongs to.
|
|
193
|
+
*/
|
|
194
|
+
export function streamedDelta<T extends PendingTranscriptState>(
|
|
195
|
+
value: T,
|
|
196
|
+
kind: "assistant" | "thinking",
|
|
197
|
+
text: string,
|
|
198
|
+
): T {
|
|
199
|
+
if (value.stream?.kind === kind) {
|
|
200
|
+
return { ...value, stream: { kind, text: value.stream.text + text } };
|
|
201
|
+
}
|
|
202
|
+
if (kind === "thinking" && value.stream?.kind === "assistant") {
|
|
203
|
+
return { ...value, lines: withThinkingAppended(value.lines, text) };
|
|
204
|
+
}
|
|
205
|
+
return { ...flushStream(value), stream: { kind, text } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Add reasoning to the row it came from, or start one if there is none. */
|
|
209
|
+
function withThinkingAppended(lines: readonly Line[], text: string): Line[] {
|
|
210
|
+
const last = lines[lines.length - 1];
|
|
211
|
+
if (last?.kind === "text" && last.role === "thinking") {
|
|
212
|
+
return [...lines.slice(0, -1), { ...last, text: last.text + text }];
|
|
213
|
+
}
|
|
214
|
+
return [...lines, { kind: "text", role: "thinking", text }];
|
|
215
|
+
}
|
|
216
|
+
|
|
161
217
|
export function settleTranscriptMessage<T extends PendingTranscriptState>(value: T): T {
|
|
162
218
|
const lines = [...value.lines];
|
|
163
|
-
|
|
164
|
-
|
|
219
|
+
const streamed = streamedLine(value.stream);
|
|
220
|
+
if (streamed) {
|
|
221
|
+
lines.push(streamed);
|
|
165
222
|
}
|
|
166
223
|
const delivered = value.pending.filter((item) => item.delivered);
|
|
167
224
|
return {
|
|
@@ -237,6 +294,40 @@ export function normalizeThinkingText(text: string): string {
|
|
|
237
294
|
.replace(/\n[ \t]*\n+/g, "\n");
|
|
238
295
|
}
|
|
239
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Columns the message body of a row occupies, or 0 before it is measured.
|
|
299
|
+
*
|
|
300
|
+
* The row is pre-wrapped at this width, so the renderer's own word wrap finds
|
|
301
|
+
* nothing to break. Layout runs before effects, so the first measurement is
|
|
302
|
+
* already the real one; the terminal's resize event covers every later one,
|
|
303
|
+
* because a resized row re-lays out without React rendering it again.
|
|
304
|
+
*/
|
|
305
|
+
function useBodyColumns(ref: RefObject<BoxRenderable | null>, active: boolean): number {
|
|
306
|
+
const renderer = useRenderer();
|
|
307
|
+
const [columns, setColumns] = useState(0);
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (!active) return;
|
|
310
|
+
const measure = () => {
|
|
311
|
+
const width = ref.current?.width ?? 0;
|
|
312
|
+
if (width > 0) setColumns((current) => (current === width ? current : width));
|
|
313
|
+
};
|
|
314
|
+
measure();
|
|
315
|
+
renderer.on("resize", measure);
|
|
316
|
+
return () => { renderer.off("resize", measure); };
|
|
317
|
+
}, [active, ref, renderer]);
|
|
318
|
+
return active ? columns : 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Reasoning text as the row shows it: wrapped at spaces only.
|
|
323
|
+
*
|
|
324
|
+
* A caret rides the last character, so it needs a column of its own. Without
|
|
325
|
+
* one, the last word jumps to the next row the moment the caret appears.
|
|
326
|
+
*/
|
|
327
|
+
function thinkingDisplayText(text: string, columns: number, caret: boolean): string {
|
|
328
|
+
return wrapAtSpaces(normalizeThinkingText(text), caret ? columns - 1 : columns);
|
|
329
|
+
}
|
|
330
|
+
|
|
240
331
|
export function roleColor(theme: Theme, role: Role): string {
|
|
241
332
|
switch (role) {
|
|
242
333
|
case "user":
|
|
@@ -262,6 +353,7 @@ function Row({
|
|
|
262
353
|
glyph,
|
|
263
354
|
glyphColor,
|
|
264
355
|
glyphRef,
|
|
356
|
+
bodyRef,
|
|
265
357
|
background,
|
|
266
358
|
onGlyphClick,
|
|
267
359
|
children,
|
|
@@ -270,6 +362,8 @@ function Row({
|
|
|
270
362
|
glyphColor: string;
|
|
271
363
|
/** An animated gutter glyph writes its own content through this ref. */
|
|
272
364
|
glyphRef?: RefObject<TextRenderable | null>;
|
|
365
|
+
/** A row that wraps its own text measures the message column through this. */
|
|
366
|
+
bodyRef?: RefObject<BoxRenderable | null>;
|
|
273
367
|
background?: string;
|
|
274
368
|
onGlyphClick?: (event: OpenTuiMouseEvent) => void;
|
|
275
369
|
children: React.ReactNode;
|
|
@@ -299,7 +393,7 @@ function Row({
|
|
|
299
393
|
</box>
|
|
300
394
|
{/* The nested flex item gives every transcript type the same measured
|
|
301
395
|
remaining-width column as the tool-row body. */}
|
|
302
|
-
<box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
396
|
+
<box ref={bodyRef} style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
303
397
|
{children}
|
|
304
398
|
</box>
|
|
305
399
|
</box>
|
|
@@ -353,7 +447,12 @@ export function TextLine({
|
|
|
353
447
|
const color = roleColor(theme, role);
|
|
354
448
|
const isUser = role === "user";
|
|
355
449
|
const isAssistant = role === "assistant";
|
|
356
|
-
const
|
|
450
|
+
const isThinking = role === "thinking";
|
|
451
|
+
const bodyRef = useRef<BoxRenderable>(null);
|
|
452
|
+
const columns = useBodyColumns(bodyRef, isThinking);
|
|
453
|
+
const displayText = isThinking
|
|
454
|
+
? thinkingDisplayText(text, columns, workingCaret)
|
|
455
|
+
: text;
|
|
357
456
|
const textCaret = useBlinkingText({
|
|
358
457
|
chunks: [fg(color)(displayText)],
|
|
359
458
|
contentKey: `${role}:${displayText}`,
|
|
@@ -395,7 +494,7 @@ export function TextLine({
|
|
|
395
494
|
}
|
|
396
495
|
|
|
397
496
|
return (
|
|
398
|
-
<Row glyph={GUTTER} glyphColor={color}>
|
|
497
|
+
<Row glyph={GUTTER} glyphColor={color} bodyRef={isThinking ? bodyRef : undefined}>
|
|
399
498
|
<text
|
|
400
499
|
ref={workingCaret ? textCaret : undefined}
|
|
401
500
|
// The caret hook repaints this imperatively on the frame clock. Passing
|
|
@@ -424,7 +523,11 @@ export function StreamLine({
|
|
|
424
523
|
text: string;
|
|
425
524
|
}) {
|
|
426
525
|
const color = roleColor(theme, role);
|
|
427
|
-
const
|
|
526
|
+
const isThinking = role === "thinking";
|
|
527
|
+
const bodyRef = useRef<BoxRenderable>(null);
|
|
528
|
+
const columns = useBodyColumns(bodyRef, isThinking);
|
|
529
|
+
// The shimmer always carries a caret, so the row always reserves its column.
|
|
530
|
+
const displayText = isThinking ? thinkingDisplayText(text, columns, true) : text;
|
|
428
531
|
const shimmer = useShimmerText({
|
|
429
532
|
text: displayText,
|
|
430
533
|
color,
|
|
@@ -436,7 +539,7 @@ export function StreamLine({
|
|
|
436
539
|
const markdown = useMarkdownCaret(text, role === "assistant");
|
|
437
540
|
|
|
438
541
|
return (
|
|
439
|
-
<Row glyph={GUTTER} glyphColor={color}>
|
|
542
|
+
<Row glyph={GUTTER} glyphColor={color} bodyRef={isThinking ? bodyRef : undefined}>
|
|
440
543
|
{role === "assistant" ? (
|
|
441
544
|
<SelectableMarkdown
|
|
442
545
|
ref={markdown.ref}
|