mini-coder 0.4.1 → 0.5.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 +89 -48
- package/assets/icon-1-minimal.svg +31 -0
- package/assets/icon-2-dark-terminal.svg +48 -0
- package/assets/icon-3-gradient-modern.svg +45 -0
- package/assets/icon-4-filled-bold.svg +54 -0
- package/assets/icon-5-community-badge.svg +63 -0
- package/assets/preview-0-5-0.png +0 -0
- package/assets/preview.gif +0 -0
- package/bin/mc.ts +14 -0
- package/bun.lock +438 -0
- package/package.json +12 -29
- package/src/agent.ts +640 -0
- package/src/cli.ts +124 -0
- package/src/git.ts +171 -0
- package/src/headless.ts +140 -0
- package/src/index.ts +666 -0
- package/src/input.ts +155 -0
- package/src/paths.ts +37 -0
- package/src/plugins.ts +183 -0
- package/src/prompt.ts +301 -0
- package/src/session.ts +1043 -0
- package/src/settings.ts +191 -0
- package/src/skills.ts +262 -0
- package/src/submit.ts +323 -0
- package/src/theme.ts +147 -0
- package/src/tools.ts +636 -0
- package/src/ui/agent.test.ts +49 -0
- package/src/ui/agent.ts +210 -0
- package/src/ui/commands.test.ts +610 -0
- package/src/ui/commands.ts +638 -0
- package/src/ui/conversation.test.ts +892 -0
- package/src/ui/conversation.ts +926 -0
- package/src/ui/help.test.ts +44 -0
- package/src/ui/help.ts +125 -0
- package/src/ui/input.test.ts +74 -0
- package/src/ui/input.ts +138 -0
- package/src/ui/overlay.test.ts +42 -0
- package/src/ui/overlay.ts +59 -0
- package/src/ui/status.test.ts +451 -0
- package/src/ui/status.ts +357 -0
- package/src/ui.ts +694 -0
- package/.claude/settings.local.json +0 -54
- package/.prettierignore +0 -7
- package/dist/mc-edit.js +0 -275
- package/dist/mc.js +0 -7355
- package/docs/KNOWN_ISSUES.md +0 -13
- package/docs/design-decisions.md +0 -31
- package/docs/mini-coder.1.md +0 -227
- package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
- package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
- package/lefthook.yml +0 -4
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation-log rendering for the terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* Renders user, assistant, tool, and UI-only messages into cel-tui nodes.
|
|
5
|
+
* Streaming state is passed in explicitly so the top-level UI module can keep
|
|
6
|
+
* ownership of module-scoped runtime state while this module stays pure.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Markdown } from "@cel-tui/components";
|
|
12
|
+
import { HStack, measureContentHeight, Text, VStack } from "@cel-tui/core";
|
|
13
|
+
import type { Node } from "@cel-tui/types";
|
|
14
|
+
import type {
|
|
15
|
+
AssistantMessage,
|
|
16
|
+
TextContent,
|
|
17
|
+
ToolResultMessage,
|
|
18
|
+
UserMessage,
|
|
19
|
+
} from "@mariozechner/pi-ai";
|
|
20
|
+
import type { AppState } from "../index.ts";
|
|
21
|
+
import type { UiMessage } from "../session.ts";
|
|
22
|
+
import type { Theme } from "../theme.ts";
|
|
23
|
+
|
|
24
|
+
/** Single blank-line gap used between conversation-level blocks. */
|
|
25
|
+
export const CONVERSATION_GAP = 1;
|
|
26
|
+
|
|
27
|
+
/** Fixed rendered height for non-verbose tool previews. */
|
|
28
|
+
const UI_TOOL_PREVIEW_ROWS = 8;
|
|
29
|
+
|
|
30
|
+
/** Default width used when preview measurements do not receive one explicitly. */
|
|
31
|
+
const DEFAULT_TOOL_PREVIEW_WIDTH = 80;
|
|
32
|
+
|
|
33
|
+
/** Horizontal columns consumed by tool-block padding and the left border. */
|
|
34
|
+
const TOOL_BLOCK_CHROME_WIDTH = 4;
|
|
35
|
+
|
|
36
|
+
/** A pending tool result shown in the streaming tail. */
|
|
37
|
+
export interface PendingToolResult {
|
|
38
|
+
/** Tool call id from the assistant message. */
|
|
39
|
+
toolCallId: string;
|
|
40
|
+
/** Tool name. */
|
|
41
|
+
toolName: string;
|
|
42
|
+
/** Progressive or final tool-result content captured so far. */
|
|
43
|
+
content: ToolResultMessage["content"];
|
|
44
|
+
/** Whether the tool result was an error. */
|
|
45
|
+
isError: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Render options shared by completed and in-progress assistant content. */
|
|
49
|
+
interface ConversationRenderOpts {
|
|
50
|
+
/** Whether reasoning blocks are visible. */
|
|
51
|
+
showReasoning: boolean;
|
|
52
|
+
/** Whether tool output should be truncated in the UI. */
|
|
53
|
+
verbose: boolean;
|
|
54
|
+
/** Active UI theme. */
|
|
55
|
+
theme: Theme;
|
|
56
|
+
/** Available terminal width for width-aware tool previews. */
|
|
57
|
+
previewWidth?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A single streaming assistant tail appended after persisted messages. */
|
|
61
|
+
export interface StreamingConversationState {
|
|
62
|
+
/** Whether a response is currently streaming. */
|
|
63
|
+
isStreaming: boolean;
|
|
64
|
+
/** Assistant content accumulated so far. */
|
|
65
|
+
content: AssistantMessage["content"];
|
|
66
|
+
/** Tool results observed during the current streaming turn. */
|
|
67
|
+
pendingToolResults: readonly PendingToolResult[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Display-only assistant state used while a response is still streaming. */
|
|
71
|
+
interface AssistantRenderState {
|
|
72
|
+
/** Assistant content blocks accumulated so far. */
|
|
73
|
+
content: AssistantMessage["content"];
|
|
74
|
+
/** Optional error text appended below the assistant content. */
|
|
75
|
+
errorMessage?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type ConversationMessage = AppState["messages"][number];
|
|
79
|
+
|
|
80
|
+
type ToolCallRenderInfo = {
|
|
81
|
+
name: string;
|
|
82
|
+
args: Record<string, unknown>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
interface ToolCallArgsCache {
|
|
86
|
+
messages: readonly ConversationMessage[] | null;
|
|
87
|
+
count: number;
|
|
88
|
+
entries: Map<string, ToolCallRenderInfo>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ConversationRenderCache {
|
|
92
|
+
messages: readonly ConversationMessage[] | null;
|
|
93
|
+
startIndex: number;
|
|
94
|
+
count: number;
|
|
95
|
+
showReasoning: boolean;
|
|
96
|
+
verbose: boolean;
|
|
97
|
+
previewWidth: number;
|
|
98
|
+
theme: Theme | null;
|
|
99
|
+
nodes: Node[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type ToolRenderDirection = "->" | "<-";
|
|
103
|
+
|
|
104
|
+
type ToolRenderLineKind =
|
|
105
|
+
| "command"
|
|
106
|
+
| "path"
|
|
107
|
+
| "text"
|
|
108
|
+
| "diffAdded"
|
|
109
|
+
| "diffRemoved"
|
|
110
|
+
| "summary"
|
|
111
|
+
| "error";
|
|
112
|
+
|
|
113
|
+
interface ToolBlockSpec {
|
|
114
|
+
/** Tool name shown in the header pill. */
|
|
115
|
+
toolName: string;
|
|
116
|
+
/** Direction shown in the header pill. */
|
|
117
|
+
direction: ToolRenderDirection;
|
|
118
|
+
/** Logical body lines for the tool block. */
|
|
119
|
+
bodyLines: readonly ToolRenderLine[];
|
|
120
|
+
/** Whether `/verbose` preview rules apply to the body. */
|
|
121
|
+
previewBody: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A single logical line in a rendered tool block. */
|
|
125
|
+
export interface ToolRenderLine {
|
|
126
|
+
/** Semantic line kind for styling. */
|
|
127
|
+
kind: ToolRenderLineKind;
|
|
128
|
+
/** Text content for this line. */
|
|
129
|
+
text: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const EMPTY_STREAMING_CONTENT: AssistantMessage["content"] = [];
|
|
133
|
+
const EMPTY_PENDING_TOOL_RESULTS: readonly PendingToolResult[] = [];
|
|
134
|
+
const EMPTY_RENDER_NODES: Node[] = [];
|
|
135
|
+
const EMPTY_TOOL_RESULT_ARGS: Record<string, unknown> = Object.freeze({});
|
|
136
|
+
|
|
137
|
+
const toolCallArgsCache: ToolCallArgsCache = {
|
|
138
|
+
messages: null,
|
|
139
|
+
count: 0,
|
|
140
|
+
entries: new Map(),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const conversationRenderCache: ConversationRenderCache = {
|
|
144
|
+
messages: null,
|
|
145
|
+
startIndex: 0,
|
|
146
|
+
count: 0,
|
|
147
|
+
showReasoning: false,
|
|
148
|
+
verbose: false,
|
|
149
|
+
previewWidth: DEFAULT_TOOL_PREVIEW_WIDTH,
|
|
150
|
+
theme: null,
|
|
151
|
+
nodes: EMPTY_RENDER_NODES,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Reset cached committed conversation renders. */
|
|
155
|
+
export function resetConversationRenderCache(): void {
|
|
156
|
+
toolCallArgsCache.messages = null;
|
|
157
|
+
toolCallArgsCache.count = 0;
|
|
158
|
+
toolCallArgsCache.entries = new Map();
|
|
159
|
+
conversationRenderCache.messages = null;
|
|
160
|
+
conversationRenderCache.startIndex = 0;
|
|
161
|
+
conversationRenderCache.count = 0;
|
|
162
|
+
conversationRenderCache.showReasoning = false;
|
|
163
|
+
conversationRenderCache.verbose = false;
|
|
164
|
+
conversationRenderCache.previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH;
|
|
165
|
+
conversationRenderCache.theme = null;
|
|
166
|
+
conversationRenderCache.nodes = EMPTY_RENDER_NODES;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Render a user message with a subtle background. */
|
|
170
|
+
function renderUserMessage(msg: UserMessage, theme: Theme): Node {
|
|
171
|
+
const text =
|
|
172
|
+
typeof msg.content === "string"
|
|
173
|
+
? msg.content
|
|
174
|
+
: msg.content
|
|
175
|
+
.filter((content): content is TextContent => content.type === "text")
|
|
176
|
+
.map((content) => content.text)
|
|
177
|
+
.join("");
|
|
178
|
+
|
|
179
|
+
return VStack({ bgColor: theme.userMsgBg, padding: { x: 1 } }, [
|
|
180
|
+
Text(text, { wrap: "word" }),
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Render a markdown block with stable internal paragraph spacing. */
|
|
185
|
+
function renderMarkdownBlock(content: string): Node | null {
|
|
186
|
+
const children = Markdown(content).map((node) => {
|
|
187
|
+
if (node.type === "text" && node.content === "") {
|
|
188
|
+
return node;
|
|
189
|
+
}
|
|
190
|
+
return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, [node])]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return children.length > 0 ? VStack({ gap: 0 }, children) : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getAssistantErrorMessage(
|
|
197
|
+
assistant: AssistantMessage | AssistantRenderState,
|
|
198
|
+
): string | undefined {
|
|
199
|
+
if ("role" in assistant && assistant.stopReason === "error") {
|
|
200
|
+
return assistant.errorMessage;
|
|
201
|
+
}
|
|
202
|
+
if ("role" in assistant) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
return assistant.errorMessage;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderThinkingBlock(
|
|
209
|
+
thinking: string,
|
|
210
|
+
opts: ConversationRenderOpts,
|
|
211
|
+
): Node {
|
|
212
|
+
if (opts.showReasoning) {
|
|
213
|
+
return VStack({ padding: { x: 1 } }, [
|
|
214
|
+
Text(thinking, {
|
|
215
|
+
wrap: "word",
|
|
216
|
+
fgColor: opts.theme.mutedText,
|
|
217
|
+
italic: true,
|
|
218
|
+
}),
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const lineCount = thinking.split("\n").length;
|
|
223
|
+
const unit = lineCount === 1 ? "line" : "lines";
|
|
224
|
+
return VStack({ padding: { x: 1 } }, [
|
|
225
|
+
Text(`Thinking... ${lineCount} ${unit}.`, {
|
|
226
|
+
fgColor: opts.theme.mutedText,
|
|
227
|
+
italic: true,
|
|
228
|
+
}),
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderAssistantContentBlock(
|
|
233
|
+
block: AssistantMessage["content"][number],
|
|
234
|
+
opts: ConversationRenderOpts,
|
|
235
|
+
): Node | null {
|
|
236
|
+
if (block.type === "text" && block.text) {
|
|
237
|
+
return renderMarkdownBlock(block.text);
|
|
238
|
+
}
|
|
239
|
+
if (block.type === "thinking" && block.thinking) {
|
|
240
|
+
return renderThinkingBlock(block.thinking, opts);
|
|
241
|
+
}
|
|
242
|
+
if (block.type === "toolCall") {
|
|
243
|
+
return renderToolCall(block, opts);
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Render a completed or in-progress assistant response.
|
|
250
|
+
*
|
|
251
|
+
* @param assistant - Completed assistant message or in-progress render state.
|
|
252
|
+
* @param opts - Shared conversation render options.
|
|
253
|
+
* @returns The rendered assistant node, or `null` when there is nothing to show.
|
|
254
|
+
*/
|
|
255
|
+
export function renderAssistantMessage(
|
|
256
|
+
assistant: AssistantMessage | AssistantRenderState,
|
|
257
|
+
opts: ConversationRenderOpts,
|
|
258
|
+
): Node | null {
|
|
259
|
+
const children = assistant.content
|
|
260
|
+
.map((block) => {
|
|
261
|
+
return renderAssistantContentBlock(block, opts);
|
|
262
|
+
})
|
|
263
|
+
.filter((child): child is Node => child !== null);
|
|
264
|
+
|
|
265
|
+
const errorMessage = getAssistantErrorMessage(assistant);
|
|
266
|
+
if (errorMessage) {
|
|
267
|
+
children.push(
|
|
268
|
+
VStack({ padding: { x: 1 } }, [
|
|
269
|
+
Text(`Error: ${errorMessage}`, {
|
|
270
|
+
fgColor: opts.theme.error,
|
|
271
|
+
}),
|
|
272
|
+
]),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (children.length === 0) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return VStack({ gap: CONVERSATION_GAP }, children);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getPreviewWidth(previewWidth?: number): number {
|
|
284
|
+
if (!Number.isFinite(previewWidth)) {
|
|
285
|
+
return DEFAULT_TOOL_PREVIEW_WIDTH;
|
|
286
|
+
}
|
|
287
|
+
return Math.max(1, Math.floor(previewWidth!));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getToolBodyWidth(previewWidth?: number): number {
|
|
291
|
+
return Math.max(1, getPreviewWidth(previewWidth) - TOOL_BLOCK_CHROME_WIDTH);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Split multi-line tool text into logical render lines. */
|
|
295
|
+
function splitToolTextLines(
|
|
296
|
+
text: string,
|
|
297
|
+
kind: Exclude<ToolRenderLineKind, "summary">,
|
|
298
|
+
): ToolRenderLine[] {
|
|
299
|
+
if (text === "") {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const lines = text.split("\n");
|
|
304
|
+
if (lines.at(-1) === "") {
|
|
305
|
+
lines.pop();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return lines.map((line) => ({ kind, text: line }));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Read a string argument from a tool-call argument map. */
|
|
312
|
+
function getToolArgString(args: Record<string, unknown>, key: string): string {
|
|
313
|
+
const value = args[key];
|
|
314
|
+
return typeof value === "string" ? value : "";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Strip shell execution labels and normalize the exit line for the UI. */
|
|
318
|
+
function normalizeShellOutput(output: string): string {
|
|
319
|
+
return output
|
|
320
|
+
.replace(/^Exit code: (\d+)(?:\n|$)/, (_, code: string) => `exit ${code}\n`)
|
|
321
|
+
.replace(/(^|\n)\[stderr\]\n/g, "$1")
|
|
322
|
+
.replace(/\n$/, "");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getToolHeaderName(toolName: string): string {
|
|
326
|
+
return toolName === "readImage" ? "read image" : toolName;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getToolHeaderColor(toolName: string, theme: Theme) {
|
|
330
|
+
switch (toolName) {
|
|
331
|
+
case "shell":
|
|
332
|
+
return theme.accentText;
|
|
333
|
+
case "edit":
|
|
334
|
+
return theme.secondaryAccentText;
|
|
335
|
+
case "readImage":
|
|
336
|
+
return theme.accentText;
|
|
337
|
+
default:
|
|
338
|
+
return theme.secondaryAccentText ?? theme.toolText;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Render a single styled text node for a tool line. */
|
|
343
|
+
function renderToolLine(line: ToolRenderLine, theme: Theme): Node {
|
|
344
|
+
switch (line.kind) {
|
|
345
|
+
case "command":
|
|
346
|
+
case "path":
|
|
347
|
+
return Text(line.text, {
|
|
348
|
+
fgColor: theme.accentText,
|
|
349
|
+
bold: true,
|
|
350
|
+
wrap: "word",
|
|
351
|
+
});
|
|
352
|
+
case "diffAdded":
|
|
353
|
+
return Text(line.text, { fgColor: theme.diffAdded, wrap: "word" });
|
|
354
|
+
case "diffRemoved":
|
|
355
|
+
return Text(line.text, { fgColor: theme.diffRemoved, wrap: "word" });
|
|
356
|
+
case "summary":
|
|
357
|
+
return Text(line.text, {
|
|
358
|
+
fgColor: theme.toolText,
|
|
359
|
+
italic: true,
|
|
360
|
+
wrap: "word",
|
|
361
|
+
});
|
|
362
|
+
case "error":
|
|
363
|
+
return Text(line.text, { fgColor: theme.error, wrap: "word" });
|
|
364
|
+
case "text":
|
|
365
|
+
return Text(line.text, { wrap: "word" });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Render styled text nodes for tool lines. */
|
|
370
|
+
function renderToolLines(
|
|
371
|
+
lines: readonly ToolRenderLine[],
|
|
372
|
+
theme: Theme,
|
|
373
|
+
): Node[] {
|
|
374
|
+
return lines.map((line) => renderToolLine(line, theme));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function measureToolLinesHeight(
|
|
378
|
+
lines: readonly ToolRenderLine[],
|
|
379
|
+
width: number,
|
|
380
|
+
theme: Theme,
|
|
381
|
+
): number {
|
|
382
|
+
if (lines.length === 0) {
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return measureContentHeight(VStack({}, renderToolLines(lines, theme)), {
|
|
387
|
+
width,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function measureToolLineHeight(
|
|
392
|
+
line: ToolRenderLine,
|
|
393
|
+
width: number,
|
|
394
|
+
theme: Theme,
|
|
395
|
+
): number {
|
|
396
|
+
return measureContentHeight(VStack({}, [renderToolLine(line, theme)]), {
|
|
397
|
+
width,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function countVisibleTailLines(
|
|
402
|
+
lines: readonly ToolRenderLine[],
|
|
403
|
+
width: number,
|
|
404
|
+
theme: Theme,
|
|
405
|
+
maxRows: number,
|
|
406
|
+
): number {
|
|
407
|
+
let remainingRows = maxRows;
|
|
408
|
+
let visibleLineCount = 0;
|
|
409
|
+
|
|
410
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
411
|
+
const lineHeight = measureToolLineHeight(lines[index]!, width, theme);
|
|
412
|
+
if (lineHeight > remainingRows) {
|
|
413
|
+
return visibleLineCount > 0 ? visibleLineCount : 1;
|
|
414
|
+
}
|
|
415
|
+
remainingRows -= lineHeight;
|
|
416
|
+
visibleLineCount += 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return visibleLineCount;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function renderToolHeaderPill(
|
|
423
|
+
toolName: string,
|
|
424
|
+
direction: ToolRenderDirection,
|
|
425
|
+
theme: Theme,
|
|
426
|
+
): Node {
|
|
427
|
+
return HStack({ bgColor: theme.toolBorder, padding: { x: 1 } }, [
|
|
428
|
+
Text(`[${getToolHeaderName(toolName)} ${direction}]`, {
|
|
429
|
+
fgColor: getToolHeaderColor(toolName, theme),
|
|
430
|
+
bold: true,
|
|
431
|
+
}),
|
|
432
|
+
]);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function renderToolBody(
|
|
436
|
+
lines: readonly ToolRenderLine[],
|
|
437
|
+
previewBody: boolean,
|
|
438
|
+
opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
|
|
439
|
+
): { body: Node | null; summary?: ToolRenderLine } {
|
|
440
|
+
if (lines.length === 0) {
|
|
441
|
+
return { body: null };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const renderedLines = renderToolLines(lines, opts.theme);
|
|
445
|
+
if (opts.verbose || !previewBody) {
|
|
446
|
+
return { body: VStack({}, renderedLines) };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const bodyWidth = getToolBodyWidth(opts.previewWidth);
|
|
450
|
+
const totalHeight = measureToolLinesHeight(lines, bodyWidth, opts.theme);
|
|
451
|
+
if (totalHeight <= UI_TOOL_PREVIEW_ROWS) {
|
|
452
|
+
return { body: VStack({}, renderedLines) };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const visibleLineCount = countVisibleTailLines(
|
|
456
|
+
lines,
|
|
457
|
+
bodyWidth,
|
|
458
|
+
opts.theme,
|
|
459
|
+
UI_TOOL_PREVIEW_ROWS,
|
|
460
|
+
);
|
|
461
|
+
const previewLines = lines.slice(-visibleLineCount);
|
|
462
|
+
const hiddenLineCount = Math.max(0, lines.length - visibleLineCount);
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
body: VStack(
|
|
466
|
+
{
|
|
467
|
+
height: UI_TOOL_PREVIEW_ROWS,
|
|
468
|
+
},
|
|
469
|
+
renderToolLines(previewLines, opts.theme),
|
|
470
|
+
),
|
|
471
|
+
summary:
|
|
472
|
+
hiddenLineCount > 0
|
|
473
|
+
? {
|
|
474
|
+
kind: "summary",
|
|
475
|
+
text: `And ${hiddenLineCount} lines more`,
|
|
476
|
+
}
|
|
477
|
+
: undefined,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Render a tool block with a left border and compact header pill. */
|
|
482
|
+
function renderToolBlock(
|
|
483
|
+
spec: ToolBlockSpec,
|
|
484
|
+
opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
|
|
485
|
+
): Node {
|
|
486
|
+
const body = renderToolBody(spec.bodyLines, spec.previewBody, opts);
|
|
487
|
+
const children: Node[] = [
|
|
488
|
+
HStack({}, [
|
|
489
|
+
renderToolHeaderPill(spec.toolName, spec.direction, opts.theme),
|
|
490
|
+
]),
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
if (body.body) {
|
|
494
|
+
children.push(body.body);
|
|
495
|
+
}
|
|
496
|
+
if (body.summary) {
|
|
497
|
+
children.push(renderToolLine(body.summary, opts.theme));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return HStack({ padding: { x: 1 } }, [
|
|
501
|
+
Text("│ ", { fgColor: opts.theme.toolBorder }),
|
|
502
|
+
VStack({ flex: 1, fgColor: opts.theme.toolText }, children),
|
|
503
|
+
]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function getToolContentText(content: ToolResultMessage["content"]): string {
|
|
507
|
+
return content
|
|
508
|
+
.filter((entry): entry is TextContent => entry.type === "text")
|
|
509
|
+
.map((entry) => entry.text)
|
|
510
|
+
.join("\n");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
|
|
514
|
+
return {
|
|
515
|
+
toolName: "shell",
|
|
516
|
+
direction: "->",
|
|
517
|
+
bodyLines: splitToolTextLines(getToolArgString(args, "command"), "command"),
|
|
518
|
+
previewBody: true,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function buildEditToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
|
|
523
|
+
const lines: ToolRenderLine[] = [];
|
|
524
|
+
const filePath = getToolArgString(args, "path");
|
|
525
|
+
if (filePath) {
|
|
526
|
+
lines.push({ kind: "path", text: filePath });
|
|
527
|
+
}
|
|
528
|
+
lines.push(
|
|
529
|
+
...splitToolTextLines(getToolArgString(args, "oldText"), "diffRemoved"),
|
|
530
|
+
);
|
|
531
|
+
lines.push(
|
|
532
|
+
...splitToolTextLines(getToolArgString(args, "newText"), "diffAdded"),
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
toolName: "edit",
|
|
537
|
+
direction: "->",
|
|
538
|
+
bodyLines: lines,
|
|
539
|
+
previewBody: true,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function buildReadImageToolCallSpec(
|
|
544
|
+
args: Record<string, unknown>,
|
|
545
|
+
): ToolBlockSpec {
|
|
546
|
+
const filePath = getToolArgString(args, "path");
|
|
547
|
+
return {
|
|
548
|
+
toolName: "readImage",
|
|
549
|
+
direction: "->",
|
|
550
|
+
bodyLines: filePath ? [{ kind: "path", text: filePath }] : [],
|
|
551
|
+
previewBody: false,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function buildGenericToolCallSpec(
|
|
556
|
+
toolName: string,
|
|
557
|
+
args: Record<string, unknown>,
|
|
558
|
+
): ToolBlockSpec {
|
|
559
|
+
const text = JSON.stringify(args, null, 2);
|
|
560
|
+
return {
|
|
561
|
+
toolName,
|
|
562
|
+
direction: "->",
|
|
563
|
+
bodyLines: text && text !== "{}" ? splitToolTextLines(text, "text") : [],
|
|
564
|
+
previewBody: false,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function buildToolCallSpec(
|
|
569
|
+
toolName: string,
|
|
570
|
+
args: Record<string, unknown>,
|
|
571
|
+
): ToolBlockSpec {
|
|
572
|
+
if (toolName === "shell") {
|
|
573
|
+
return buildShellToolCallSpec(args);
|
|
574
|
+
}
|
|
575
|
+
if (toolName === "edit") {
|
|
576
|
+
return buildEditToolCallSpec(args);
|
|
577
|
+
}
|
|
578
|
+
if (toolName === "readImage") {
|
|
579
|
+
return buildReadImageToolCallSpec(args);
|
|
580
|
+
}
|
|
581
|
+
return buildGenericToolCallSpec(toolName, args);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function renderToolCall(
|
|
585
|
+
toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
|
|
586
|
+
opts: Pick<ConversationRenderOpts, "previewWidth" | "verbose" | "theme">,
|
|
587
|
+
): Node {
|
|
588
|
+
return renderToolBlock(
|
|
589
|
+
buildToolCallSpec(toolCall.name, toolCall.arguments),
|
|
590
|
+
opts,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function buildShellToolResultSpec(
|
|
595
|
+
content: ToolResultMessage["content"],
|
|
596
|
+
): ToolBlockSpec {
|
|
597
|
+
return {
|
|
598
|
+
toolName: "shell",
|
|
599
|
+
direction: "<-",
|
|
600
|
+
bodyLines: splitToolTextLines(
|
|
601
|
+
normalizeShellOutput(getToolContentText(content)),
|
|
602
|
+
"text",
|
|
603
|
+
),
|
|
604
|
+
previewBody: true,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function buildEditToolResultSpec(
|
|
609
|
+
args: Record<string, unknown>,
|
|
610
|
+
isError: boolean,
|
|
611
|
+
resultText: string,
|
|
612
|
+
): ToolBlockSpec {
|
|
613
|
+
if (isError) {
|
|
614
|
+
return {
|
|
615
|
+
toolName: "edit",
|
|
616
|
+
direction: "<-",
|
|
617
|
+
bodyLines: splitToolTextLines(resultText, "error"),
|
|
618
|
+
previewBody: true,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const filePath = getToolArgString(args, "path");
|
|
623
|
+
return {
|
|
624
|
+
toolName: "edit",
|
|
625
|
+
direction: "<-",
|
|
626
|
+
bodyLines: filePath ? [{ kind: "path", text: `~ ${filePath}` }] : [],
|
|
627
|
+
previewBody: false,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function buildReadImageToolResultSpec(
|
|
632
|
+
args: Record<string, unknown>,
|
|
633
|
+
content: ToolResultMessage["content"],
|
|
634
|
+
isError: boolean,
|
|
635
|
+
): ToolBlockSpec {
|
|
636
|
+
if (isError) {
|
|
637
|
+
return {
|
|
638
|
+
toolName: "readImage",
|
|
639
|
+
direction: "<-",
|
|
640
|
+
bodyLines: splitToolTextLines(getToolContentText(content), "error"),
|
|
641
|
+
previewBody: false,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const filePath = getToolArgString(args, "path");
|
|
646
|
+
return {
|
|
647
|
+
toolName: "readImage",
|
|
648
|
+
direction: "<-",
|
|
649
|
+
bodyLines: filePath ? [{ kind: "path", text: filePath }] : [],
|
|
650
|
+
previewBody: false,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function buildGenericToolResultSpec(
|
|
655
|
+
toolName: string,
|
|
656
|
+
output: string,
|
|
657
|
+
isError: boolean,
|
|
658
|
+
): ToolBlockSpec {
|
|
659
|
+
return {
|
|
660
|
+
toolName,
|
|
661
|
+
direction: "<-",
|
|
662
|
+
bodyLines: splitToolTextLines(output, isError ? "error" : "text"),
|
|
663
|
+
previewBody: false,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function renderToolResultContent(
|
|
668
|
+
toolName: string,
|
|
669
|
+
args: Record<string, unknown>,
|
|
670
|
+
content: ToolResultMessage["content"],
|
|
671
|
+
isError: boolean,
|
|
672
|
+
opts: Pick<ConversationRenderOpts, "previewWidth" | "verbose" | "theme">,
|
|
673
|
+
): Node {
|
|
674
|
+
if (toolName === "shell") {
|
|
675
|
+
return renderToolBlock(buildShellToolResultSpec(content), opts);
|
|
676
|
+
}
|
|
677
|
+
if (toolName === "edit") {
|
|
678
|
+
return renderToolBlock(
|
|
679
|
+
buildEditToolResultSpec(args, isError, getToolContentText(content)),
|
|
680
|
+
opts,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (toolName === "readImage") {
|
|
684
|
+
return renderToolBlock(
|
|
685
|
+
buildReadImageToolResultSpec(args, content, isError),
|
|
686
|
+
opts,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return renderToolBlock(
|
|
691
|
+
buildGenericToolResultSpec(toolName, getToolContentText(content), isError),
|
|
692
|
+
opts,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Render a tool result, dispatching by tool name.
|
|
698
|
+
*
|
|
699
|
+
* @param toolName - Tool name to render.
|
|
700
|
+
* @param args - Tool call arguments used for compact result rendering.
|
|
701
|
+
* @param resultText - Text content from the tool result message.
|
|
702
|
+
* @param isError - Whether the tool execution failed.
|
|
703
|
+
* @param opts - Shared conversation render options.
|
|
704
|
+
* @returns The rendered tool block node.
|
|
705
|
+
*/
|
|
706
|
+
export function renderToolResult(
|
|
707
|
+
toolName: string,
|
|
708
|
+
args: Record<string, unknown>,
|
|
709
|
+
resultText: string,
|
|
710
|
+
isError: boolean,
|
|
711
|
+
opts: ConversationRenderOpts,
|
|
712
|
+
): Node {
|
|
713
|
+
const content: ToolResultMessage["content"] = resultText
|
|
714
|
+
? [{ type: "text", text: resultText }]
|
|
715
|
+
: [];
|
|
716
|
+
|
|
717
|
+
return renderToolResultContent(toolName, args, content, isError, opts);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Render an internal UI message in the conversation log. */
|
|
721
|
+
function renderUiMessage(msg: UiMessage, theme: Theme): Node {
|
|
722
|
+
return VStack({ padding: { x: 1 } }, [
|
|
723
|
+
Text(msg.content, {
|
|
724
|
+
fgColor: theme.mutedText,
|
|
725
|
+
italic: true,
|
|
726
|
+
wrap: "word",
|
|
727
|
+
}),
|
|
728
|
+
]);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function pushConversationNode(nodes: Node[], node: Node | null): void {
|
|
732
|
+
if (!node) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
nodes.push(node);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function rememberToolCallArgs(
|
|
739
|
+
message: AssistantMessage,
|
|
740
|
+
toolCallArgs: Map<string, ToolCallRenderInfo>,
|
|
741
|
+
): void {
|
|
742
|
+
for (const block of message.content) {
|
|
743
|
+
if (block.type !== "toolCall") {
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
toolCallArgs.set(block.id, {
|
|
747
|
+
name: block.name,
|
|
748
|
+
args: block.arguments,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function renderToolResultMessage(
|
|
754
|
+
toolName: string,
|
|
755
|
+
args: Record<string, unknown>,
|
|
756
|
+
content: ToolResultMessage["content"],
|
|
757
|
+
isError: boolean,
|
|
758
|
+
renderOpts: ConversationRenderOpts,
|
|
759
|
+
): Node {
|
|
760
|
+
return renderToolResultContent(toolName, args, content, isError, renderOpts);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function renderConversationMessage(
|
|
764
|
+
message: ConversationMessage,
|
|
765
|
+
renderOpts: ConversationRenderOpts,
|
|
766
|
+
toolCallArgs: Map<string, ToolCallRenderInfo>,
|
|
767
|
+
theme: Theme,
|
|
768
|
+
): Node | null {
|
|
769
|
+
if (message.role === "ui") {
|
|
770
|
+
return renderUiMessage(message, theme);
|
|
771
|
+
}
|
|
772
|
+
if (message.role === "user") {
|
|
773
|
+
return renderUserMessage(message, theme);
|
|
774
|
+
}
|
|
775
|
+
if (message.role === "assistant") {
|
|
776
|
+
rememberToolCallArgs(message, toolCallArgs);
|
|
777
|
+
return renderAssistantMessage(message, renderOpts);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const info = toolCallArgs.get(message.toolCallId);
|
|
781
|
+
return renderToolResultMessage(
|
|
782
|
+
info?.name ?? message.toolName,
|
|
783
|
+
info?.args ?? EMPTY_TOOL_RESULT_ARGS,
|
|
784
|
+
message.content,
|
|
785
|
+
message.isError,
|
|
786
|
+
renderOpts,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function cacheToolCallArgs(messages: readonly ConversationMessage[]): void {
|
|
791
|
+
if (
|
|
792
|
+
toolCallArgsCache.messages !== messages ||
|
|
793
|
+
toolCallArgsCache.count > messages.length
|
|
794
|
+
) {
|
|
795
|
+
toolCallArgsCache.messages = messages;
|
|
796
|
+
toolCallArgsCache.count = 0;
|
|
797
|
+
toolCallArgsCache.entries = new Map();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
for (let index = toolCallArgsCache.count; index < messages.length; index++) {
|
|
801
|
+
const message = messages[index];
|
|
802
|
+
if (message?.role === "assistant") {
|
|
803
|
+
rememberToolCallArgs(message, toolCallArgsCache.entries);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
toolCallArgsCache.count = messages.length;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function canReuseCommittedConversationCache(
|
|
811
|
+
state: Pick<AppState, "messages" | "showReasoning" | "verbose" | "theme">,
|
|
812
|
+
startIndex: number,
|
|
813
|
+
previewWidth: number,
|
|
814
|
+
): boolean {
|
|
815
|
+
return (
|
|
816
|
+
conversationRenderCache.messages === state.messages &&
|
|
817
|
+
conversationRenderCache.startIndex === startIndex &&
|
|
818
|
+
conversationRenderCache.showReasoning === state.showReasoning &&
|
|
819
|
+
conversationRenderCache.verbose === state.verbose &&
|
|
820
|
+
conversationRenderCache.previewWidth === previewWidth &&
|
|
821
|
+
conversationRenderCache.theme === state.theme &&
|
|
822
|
+
conversationRenderCache.count <= state.messages.length
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function cacheCommittedConversation(
|
|
827
|
+
state: Pick<AppState, "messages" | "showReasoning" | "verbose" | "theme">,
|
|
828
|
+
renderOpts: ConversationRenderOpts,
|
|
829
|
+
startIndex: number,
|
|
830
|
+
): void {
|
|
831
|
+
const previewWidth = getPreviewWidth(renderOpts.previewWidth);
|
|
832
|
+
cacheToolCallArgs(state.messages);
|
|
833
|
+
|
|
834
|
+
if (!canReuseCommittedConversationCache(state, startIndex, previewWidth)) {
|
|
835
|
+
conversationRenderCache.messages = state.messages;
|
|
836
|
+
conversationRenderCache.startIndex = startIndex;
|
|
837
|
+
conversationRenderCache.count = startIndex;
|
|
838
|
+
conversationRenderCache.showReasoning = state.showReasoning;
|
|
839
|
+
conversationRenderCache.verbose = state.verbose;
|
|
840
|
+
conversationRenderCache.previewWidth = previewWidth;
|
|
841
|
+
conversationRenderCache.theme = state.theme;
|
|
842
|
+
conversationRenderCache.nodes = [];
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
for (
|
|
846
|
+
let index = conversationRenderCache.count;
|
|
847
|
+
index < state.messages.length;
|
|
848
|
+
index++
|
|
849
|
+
) {
|
|
850
|
+
pushConversationNode(
|
|
851
|
+
conversationRenderCache.nodes,
|
|
852
|
+
renderConversationMessage(
|
|
853
|
+
state.messages[index]!,
|
|
854
|
+
renderOpts,
|
|
855
|
+
toolCallArgsCache.entries,
|
|
856
|
+
state.theme,
|
|
857
|
+
),
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
conversationRenderCache.count = state.messages.length;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function hasStreamingTail(streaming: StreamingConversationState): boolean {
|
|
865
|
+
return (
|
|
866
|
+
(streaming.content !== EMPTY_STREAMING_CONTENT ||
|
|
867
|
+
streaming.pendingToolResults !== EMPTY_PENDING_TOOL_RESULTS) &&
|
|
868
|
+
(streaming.content.length > 0 || streaming.pendingToolResults.length > 0)
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Build the full conversation log as an array of nodes.
|
|
874
|
+
*
|
|
875
|
+
* @param state - Conversation rendering state.
|
|
876
|
+
* @param streaming - Current in-progress assistant tail, if any.
|
|
877
|
+
* @param startIndex - Index of the first committed message to render.
|
|
878
|
+
* @param previewWidth - Available terminal width for width-aware tool previews.
|
|
879
|
+
* @returns The rendered conversation log nodes.
|
|
880
|
+
*/
|
|
881
|
+
export function buildConversationLogNodes(
|
|
882
|
+
state: Pick<AppState, "messages" | "showReasoning" | "verbose" | "theme">,
|
|
883
|
+
streaming: StreamingConversationState,
|
|
884
|
+
startIndex = 0,
|
|
885
|
+
previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH,
|
|
886
|
+
): Node[] {
|
|
887
|
+
const renderOpts: ConversationRenderOpts = {
|
|
888
|
+
showReasoning: state.showReasoning,
|
|
889
|
+
verbose: state.verbose,
|
|
890
|
+
theme: state.theme,
|
|
891
|
+
previewWidth,
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
cacheCommittedConversation(state, renderOpts, startIndex);
|
|
895
|
+
|
|
896
|
+
if (!hasStreamingTail(streaming)) {
|
|
897
|
+
return conversationRenderCache.nodes;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const nodes = [...conversationRenderCache.nodes];
|
|
901
|
+
pushConversationNode(
|
|
902
|
+
nodes,
|
|
903
|
+
renderAssistantMessage(
|
|
904
|
+
{
|
|
905
|
+
content: streaming.content,
|
|
906
|
+
},
|
|
907
|
+
renderOpts,
|
|
908
|
+
),
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
for (const pendingToolResult of streaming.pendingToolResults) {
|
|
912
|
+
const info = toolCallArgsCache.entries.get(pendingToolResult.toolCallId);
|
|
913
|
+
pushConversationNode(
|
|
914
|
+
nodes,
|
|
915
|
+
renderToolResultMessage(
|
|
916
|
+
info?.name ?? pendingToolResult.toolName,
|
|
917
|
+
info?.args ?? EMPTY_TOOL_RESULT_ARGS,
|
|
918
|
+
pendingToolResult.content,
|
|
919
|
+
pendingToolResult.isError,
|
|
920
|
+
renderOpts,
|
|
921
|
+
),
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return nodes;
|
|
926
|
+
}
|