mini-coder 0.5.11 → 0.5.13

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.
@@ -8,12 +8,18 @@
8
8
  * @module
9
9
  */
10
10
 
11
+ import { homedir } from "node:os";
11
12
  import {
12
- SyntaxHighlight,
13
- type SyntaxHighlightTheme,
14
- } from "@cel-tui/components";
13
+ basename,
14
+ extname,
15
+ isAbsolute,
16
+ join,
17
+ normalize,
18
+ relative,
19
+ } from "node:path";
20
+ import { SyntaxHighlight } from "@cel-tui/components";
15
21
  import { HStack, measureContentHeight, Text, VStack } from "@cel-tui/core";
16
- import type { Color, Node } from "@cel-tui/types";
22
+ import type { Node } from "@cel-tui/types";
17
23
  import type {
18
24
  AssistantMessage,
19
25
  TextContent,
@@ -22,8 +28,17 @@ import type {
22
28
  } from "@mariozechner/pi-ai";
23
29
  import type { AppState } from "../index.ts";
24
30
  import type { UiMessage } from "../session.ts";
25
- import type { Theme } from "../theme.ts";
26
- import { parseTodoSnapshot, type TodoItem } from "../tools.ts";
31
+ import { readBoolean, readFiniteNumber, readString } from "../shared.ts";
32
+ import { getSyntaxHighlightTheme, type Theme } from "../theme.ts";
33
+ import {
34
+ parseGrepResult,
35
+ parseLegacyShellResult,
36
+ parseReadContinuationHint,
37
+ parseReadResult,
38
+ parseShellResultDetails,
39
+ parseTodoSnapshot,
40
+ type TodoItem,
41
+ } from "../tools.ts";
27
42
  import { APP_NAME, DEV_VERSION_LABEL } from "../version.ts";
28
43
 
29
44
  /** Single blank-line gap used between conversation-level blocks. */
@@ -35,32 +50,9 @@ const UI_TOOL_PREVIEW_ROWS = 8;
35
50
  /** Default width used when preview measurements do not receive one explicitly. */
36
51
  const DEFAULT_TOOL_PREVIEW_WIDTH = 80;
37
52
 
38
- /** Horizontal columns consumed by assistant-markdown padding. */
39
- const MARKDOWN_BLOCK_CHROME_WIDTH = 2;
40
-
41
53
  /** Horizontal columns consumed by tool-block padding and the left border. */
42
54
  const TOOL_BLOCK_CHROME_WIDTH = 4;
43
55
 
44
- /** ANSI16 fallback hex values for syntax-highlighter theme overrides. */
45
- const ANSI_COLOR_HEX: Readonly<Record<Color, string>> = {
46
- color00: "#000000",
47
- color01: "#cd3131",
48
- color02: "#0dbc79",
49
- color03: "#e5e510",
50
- color04: "#2472c8",
51
- color05: "#bc3fbc",
52
- color06: "#11a8cd",
53
- color07: "#e5e5e5",
54
- color08: "#666666",
55
- color09: "#f14c4c",
56
- color10: "#23d18b",
57
- color11: "#f5f543",
58
- color12: "#3b8eea",
59
- color13: "#d670d6",
60
- color14: "#29b8db",
61
- color15: "#ffffff",
62
- };
63
-
64
56
  /** A pending tool result shown in the streaming tail. */
65
57
  export interface PendingToolResult {
66
58
  /** Tool call id from the assistant message. */
@@ -69,6 +61,8 @@ export interface PendingToolResult {
69
61
  toolName: string;
70
62
  /** Progressive or final tool-result content captured so far. */
71
63
  content: ToolResultMessage["content"];
64
+ /** Optional structured details preserved on the tool result. */
65
+ details?: ToolResultMessage["details"];
72
66
  /** Whether the tool result was an error. */
73
67
  isError: boolean;
74
68
  }
@@ -81,6 +75,8 @@ interface ConversationRenderOpts {
81
75
  verbose: boolean;
82
76
  /** Active UI theme. */
83
77
  theme: Theme;
78
+ /** Session working directory used for path display. */
79
+ cwd?: string;
84
80
  /** Available terminal width for width-aware tool previews. */
85
81
  previewWidth?: number;
86
82
  }
@@ -107,7 +103,7 @@ type ConversationMessage = AppState["messages"][number];
107
103
 
108
104
  type ConversationLogState = Pick<
109
105
  AppState,
110
- "messages" | "showReasoning" | "verbose" | "theme"
106
+ "messages" | "showReasoning" | "verbose" | "theme" | "cwd"
111
107
  > & {
112
108
  versionLabel?: AppState["versionLabel"];
113
109
  };
@@ -123,15 +119,14 @@ interface ToolCallArgsCache {
123
119
  entries: Map<string, ToolCallRenderInfo>;
124
120
  }
125
121
 
126
- interface ConversationRenderCache {
127
- messages: readonly ConversationMessage[] | null;
128
- startIndex: number;
129
- count: number;
122
+ interface ConversationMessageRenderCacheEntry {
123
+ node: Node | null;
124
+ height: number;
130
125
  showReasoning: boolean;
131
126
  verbose: boolean;
132
127
  previewWidth: number;
128
+ cwd: string | null;
133
129
  theme: Theme | null;
134
- nodes: Node[];
135
130
  }
136
131
 
137
132
  type ToolRenderDirection = "->" | "<-";
@@ -140,12 +135,13 @@ type ToolRenderLineKind =
140
135
  | "command"
141
136
  | "path"
142
137
  | "text"
138
+ | "context"
143
139
  | "diffAdded"
144
140
  | "diffRemoved"
145
141
  | "summary"
146
142
  | "error";
147
143
 
148
- type SyntaxThemeVariant = "markdown" | "shell";
144
+ type SyntaxThemeVariant = "markdown" | "code" | "shell";
149
145
 
150
146
  interface HighlightedBodySpec {
151
147
  /** Full raw source content to syntax-highlight. */
@@ -161,12 +157,16 @@ interface ToolBlockSpec {
161
157
  toolName: string;
162
158
  /** Direction shown in the header pill. */
163
159
  direction: ToolRenderDirection;
160
+ /** Optional compact text appended after the header pill. */
161
+ headerSuffix?: string;
164
162
  /** Logical body lines for the tool block. */
165
163
  bodyLines: readonly ToolRenderLine[];
166
164
  /** Optional syntax-highlighted body rendered from the full unsplit source. */
167
165
  highlightedBody?: HighlightedBodySpec;
168
166
  /** Optional custom body node rendered as-is. */
169
167
  bodyNode?: Node;
168
+ /** Optional footer lines rendered after the body. */
169
+ footerLines?: readonly ToolRenderLine[];
170
170
  /** Whether `/verbose` preview rules apply to the body. */
171
171
  previewBody: boolean;
172
172
  }
@@ -179,9 +179,36 @@ export interface ToolRenderLine {
179
179
  text: string;
180
180
  }
181
181
 
182
+ /** A rendered conversation item plus its measured height. */
183
+ export interface ConversationLogItem {
184
+ /** Rendered conversation node. */
185
+ node: Node;
186
+ /** Intrinsic item height in rows, excluding conversation gaps. */
187
+ height: number;
188
+ }
189
+
190
+ /** Rendered items plus cached cumulative intrinsic heights. */
191
+ export interface ConversationLayoutSnapshot {
192
+ /** Rendered items in on-screen order. */
193
+ items: readonly ConversationLogItem[];
194
+ /** Cumulative intrinsic heights, excluding inter-item gaps. */
195
+ prefixHeights: readonly number[];
196
+ }
197
+
198
+ interface ConversationLayoutCache {
199
+ messages: readonly ConversationMessage[] | null;
200
+ count: number;
201
+ showReasoning: boolean;
202
+ verbose: boolean;
203
+ previewWidth: number;
204
+ cwd: string | null;
205
+ theme: Theme | null;
206
+ items: ConversationLogItem[];
207
+ prefixHeights: number[];
208
+ }
209
+
182
210
  const EMPTY_STREAMING_CONTENT: AssistantMessage["content"] = [];
183
211
  const EMPTY_PENDING_TOOL_RESULTS: readonly PendingToolResult[] = [];
184
- const EMPTY_RENDER_NODES: Node[] = [];
185
212
  const EMPTY_TOOL_RESULT_ARGS: Record<string, unknown> = Object.freeze({});
186
213
 
187
214
  const toolCallArgsCache: ToolCallArgsCache = {
@@ -190,15 +217,21 @@ const toolCallArgsCache: ToolCallArgsCache = {
190
217
  entries: new Map(),
191
218
  };
192
219
 
193
- const conversationRenderCache: ConversationRenderCache = {
220
+ let conversationMessageRenderCache = new WeakMap<
221
+ ConversationMessage,
222
+ ConversationMessageRenderCacheEntry
223
+ >();
224
+
225
+ const conversationLayoutCache: ConversationLayoutCache = {
194
226
  messages: null,
195
- startIndex: 0,
196
227
  count: 0,
197
228
  showReasoning: false,
198
229
  verbose: false,
199
230
  previewWidth: DEFAULT_TOOL_PREVIEW_WIDTH,
231
+ cwd: null,
200
232
  theme: null,
201
- nodes: EMPTY_RENDER_NODES,
233
+ items: [],
234
+ prefixHeights: [0],
202
235
  };
203
236
 
204
237
  /** Reset cached committed conversation renders. */
@@ -206,14 +239,16 @@ export function resetConversationRenderCache(): void {
206
239
  toolCallArgsCache.messages = null;
207
240
  toolCallArgsCache.count = 0;
208
241
  toolCallArgsCache.entries = new Map();
209
- conversationRenderCache.messages = null;
210
- conversationRenderCache.startIndex = 0;
211
- conversationRenderCache.count = 0;
212
- conversationRenderCache.showReasoning = false;
213
- conversationRenderCache.verbose = false;
214
- conversationRenderCache.previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH;
215
- conversationRenderCache.theme = null;
216
- conversationRenderCache.nodes = EMPTY_RENDER_NODES;
242
+ conversationMessageRenderCache = new WeakMap();
243
+ conversationLayoutCache.messages = null;
244
+ conversationLayoutCache.count = 0;
245
+ conversationLayoutCache.showReasoning = false;
246
+ conversationLayoutCache.verbose = false;
247
+ conversationLayoutCache.previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH;
248
+ conversationLayoutCache.cwd = null;
249
+ conversationLayoutCache.theme = null;
250
+ conversationLayoutCache.items = [];
251
+ conversationLayoutCache.prefixHeights = [0];
217
252
  }
218
253
 
219
254
  /** Render a user message with a subtle background. */
@@ -232,29 +267,24 @@ function renderUserMessage(msg: UserMessage, theme: Theme): Node {
232
267
  }
233
268
 
234
269
  /** Render a syntax-highlighted raw markdown block. */
235
- function renderMarkdownTextBlock(
236
- content: string,
237
- theme: Theme,
238
- previewWidth?: number,
239
- ): Node | null {
270
+ function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
240
271
  if (content === "") {
241
272
  return null;
242
273
  }
243
274
 
244
- const children = getHighlightedBodyLines(
275
+ const highlighted = getHighlightedBodyNode(
245
276
  {
246
277
  text: content,
247
278
  language: "markdown",
248
279
  themeVariant: "markdown",
249
280
  },
250
281
  theme,
251
- getMarkdownBodyWidth(previewWidth),
252
282
  );
253
- if (children.length === 0) {
283
+ if (!highlighted) {
254
284
  return null;
255
285
  }
256
286
 
257
- return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, children)]);
287
+ return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, [highlighted])]);
258
288
  }
259
289
 
260
290
  function getAssistantErrorMessage(
@@ -324,7 +354,7 @@ function renderAssistantContentBlock(
324
354
  opts: ConversationRenderOpts,
325
355
  ): Node | null {
326
356
  if (block.type === "text" && block.text) {
327
- return renderMarkdownTextBlock(block.text, opts.theme, opts.previewWidth);
357
+ return renderMarkdownTextBlock(block.text, opts.theme);
328
358
  }
329
359
  if (block.type === "thinking" && block.thinking) {
330
360
  return renderThinkingBlock(block.thinking, opts);
@@ -377,21 +407,10 @@ function getPreviewWidth(previewWidth?: number): number {
377
407
  return Math.max(1, Math.floor(previewWidth!));
378
408
  }
379
409
 
380
- function getMarkdownBodyWidth(previewWidth?: number): number {
381
- return Math.max(
382
- 1,
383
- getPreviewWidth(previewWidth) - MARKDOWN_BLOCK_CHROME_WIDTH,
384
- );
385
- }
386
-
387
410
  function getToolBodyWidth(previewWidth?: number): number {
388
411
  return Math.max(1, getPreviewWidth(previewWidth) - TOOL_BLOCK_CHROME_WIDTH);
389
412
  }
390
413
 
391
- function getHighlightWrapChunkSize(bodyWidth: number): number {
392
- return Math.max(1, Math.min(HIGHLIGHT_WRAP_MAX_CHUNK_GRAPHEMES, bodyWidth));
393
- }
394
-
395
414
  /** Split multi-line tool text into logical render lines. */
396
415
  function splitToolTextLines(
397
416
  text: string,
@@ -409,254 +428,228 @@ function splitToolTextLines(
409
428
  return lines.map((line) => ({ kind, text: line }));
410
429
  }
411
430
 
431
+ function normalizeDisplayLineEndings(text: string): string {
432
+ return text.replace(/\r\n/g, "\n");
433
+ }
434
+
412
435
  /** Read a string argument from a tool-call argument map. */
413
436
  function getToolArgString(args: Record<string, unknown>, key: string): string {
414
- const value = args[key];
415
- return typeof value === "string" ? value : "";
437
+ return readString(args, key) ?? "";
416
438
  }
417
439
 
418
- function formatTodoWriteCallSummary(args: Record<string, unknown>): string {
419
- const todoCount = Array.isArray(args.todos) ? args.todos.length : 0;
420
- const todoLabel = todoCount === 1 ? "todo" : "todos";
421
- return `Updating todos... ${todoCount} ${todoLabel} updated`;
422
- }
440
+ const HOME_DIR = homedir();
423
441
 
424
- /** Strip shell execution labels and normalize the exit line for the UI. */
425
- function normalizeShellOutput(output: string): string {
426
- return output
427
- .replace(/^Exit code: (\d+)(?:\n|$)/, (_, code: string) => `exit ${code}\n`)
428
- .replace(/(^|\n)\[stderr\]\n/g, "$1")
429
- .replace(/\n$/, "");
442
+ function resolveToolPath(path: string, cwd?: string): string {
443
+ if (path === "") {
444
+ return "";
445
+ }
446
+ if (!cwd || isAbsolute(path)) {
447
+ return path;
448
+ }
449
+ return join(cwd, path);
430
450
  }
431
451
 
432
- function getToolHeaderName(toolName: string): string {
433
- return toolName.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
452
+ function abbreviateHomePath(path: string): string {
453
+ if (path === HOME_DIR) {
454
+ return "~";
455
+ }
456
+ if (path.startsWith(`${HOME_DIR}/`)) {
457
+ return `~/${path.slice(HOME_DIR.length + 1)}`;
458
+ }
459
+ return path;
434
460
  }
435
461
 
436
- function getToolHeaderColor(toolName: string, theme: Theme) {
437
- switch (toolName) {
438
- case "shell":
439
- case "readImage":
440
- case "todoWrite":
441
- case "todoRead":
442
- return theme.accentText;
443
- case "edit":
444
- return theme.secondaryAccentText;
445
- default:
446
- return theme.secondaryAccentText ?? theme.toolText;
462
+ function normalizeDisplayPath(path: string): string {
463
+ if (path === "") {
464
+ return path;
447
465
  }
466
+ return normalize(path).replace(/\\/g, "/");
448
467
  }
449
468
 
450
- type SyntaxThemeRegistration = Exclude<SyntaxHighlightTheme, string>;
451
- type SyntaxThemeTokenColor = NonNullable<
452
- SyntaxThemeRegistration["tokenColors"]
453
- >[number];
454
-
455
- const graphemeSegmenter = new Intl.Segmenter(undefined, {
456
- granularity: "grapheme",
457
- });
458
- const HIGHLIGHT_WRAP_MAX_CHUNK_GRAPHEMES = 32;
459
- const syntaxThemeCache: Record<
460
- SyntaxThemeVariant,
461
- WeakMap<Theme, SyntaxThemeRegistration>
462
- > = {
463
- markdown: new WeakMap(),
464
- shell: new WeakMap(),
465
- };
466
-
467
- function colorToHex(color: Color | undefined): string | undefined {
468
- return color ? ANSI_COLOR_HEX[color] : undefined;
469
+ function formatResolvedToolPath(path: string, cwd?: string): string {
470
+ const resolved = resolveToolPath(path, cwd);
471
+ if (resolved === "") {
472
+ return path;
473
+ }
474
+ return abbreviateHomePath(normalizeDisplayPath(resolved));
469
475
  }
470
476
 
471
- function pushSyntaxTokenColor(
472
- tokenColors: SyntaxThemeTokenColor[],
473
- scope: string | readonly string[],
474
- foreground: Color | undefined,
475
- fontStyle?: string,
476
- ): void {
477
- const foregroundHex = colorToHex(foreground);
478
- if (!foregroundHex && !fontStyle) {
479
- return;
477
+ function formatRelativeToolPath(path: string, cwd?: string): string {
478
+ const resolved = resolveToolPath(path, cwd);
479
+ if (resolved === "" || !cwd || !isAbsolute(resolved)) {
480
+ return normalizeDisplayPath(path);
480
481
  }
481
482
 
482
- tokenColors.push({
483
- scope,
484
- settings: {
485
- ...(foregroundHex ? { foreground: foregroundHex } : {}),
486
- ...(fontStyle ? { fontStyle } : {}),
487
- },
488
- });
483
+ const relativePath = relative(cwd, resolved);
484
+ if (relativePath === "") {
485
+ return ".";
486
+ }
487
+ if (relativePath.startsWith("..")) {
488
+ return formatResolvedToolPath(resolved);
489
+ }
490
+ return normalizeDisplayPath(relativePath);
491
+ }
492
+
493
+ function getReadLanguageCandidates(path: string): string[] {
494
+ const fileName = basename(path).toLowerCase();
495
+ const extension = extname(fileName).toLowerCase();
496
+
497
+ switch (extension) {
498
+ case ".ts":
499
+ return ["typescript"];
500
+ case ".tsx":
501
+ return ["tsx", "typescript"];
502
+ case ".js":
503
+ case ".mjs":
504
+ case ".cjs":
505
+ return ["javascript"];
506
+ case ".jsx":
507
+ return ["jsx", "javascript"];
508
+ case ".json":
509
+ return ["json"];
510
+ case ".md":
511
+ return ["markdown"];
512
+ case ".yml":
513
+ case ".yaml":
514
+ return ["yaml"];
515
+ case ".toml":
516
+ return ["toml"];
517
+ case ".sh":
518
+ case ".bash":
519
+ return ["bash"];
520
+ case ".css":
521
+ return ["css"];
522
+ case ".html":
523
+ case ".htm":
524
+ return ["html"];
525
+ case ".xml":
526
+ return ["xml"];
527
+ case ".py":
528
+ return ["python"];
529
+ case ".rs":
530
+ return ["rust"];
531
+ case ".go":
532
+ return ["go"];
533
+ case ".java":
534
+ return ["java"];
535
+ case ".c":
536
+ return ["c"];
537
+ case ".h":
538
+ return ["c"];
539
+ case ".cpp":
540
+ case ".cc":
541
+ case ".cxx":
542
+ case ".hpp":
543
+ return ["cpp"];
544
+ default:
545
+ break;
546
+ }
547
+
548
+ if (fileName === "dockerfile") {
549
+ return ["dockerfile"];
550
+ }
551
+ if (fileName === "makefile") {
552
+ return ["makefile"];
553
+ }
554
+ if (fileName === "agents.md" || fileName === "readme.md") {
555
+ return ["markdown"];
556
+ }
557
+ return [];
489
558
  }
490
559
 
491
- function pushShellSyntaxTokenColors(
492
- tokenColors: SyntaxThemeTokenColor[],
493
- theme: Theme,
494
- ): void {
495
- pushSyntaxTokenColor(tokenColors, "comment", theme.mutedText);
496
- pushSyntaxTokenColor(
497
- tokenColors,
498
- ["keyword", "storage"],
499
- theme.secondaryAccentText,
500
- );
501
- pushSyntaxTokenColor(
502
- tokenColors,
503
- [
504
- "entity.name.function",
505
- "function",
506
- "meta.function-call",
507
- "support.function",
508
- "title",
509
- ],
510
- theme.accentText,
511
- );
512
- pushSyntaxTokenColor(
513
- tokenColors,
514
- [
515
- "built_in",
516
- "class",
517
- "entity.name.type",
518
- "entity.other.inherited-class",
519
- "support.class",
520
- "support.type",
521
- "type",
522
- ],
523
- theme.accentText,
524
- );
525
- pushSyntaxTokenColor(
526
- tokenColors,
527
- ["constant.language", "constant.numeric", "literal", "symbol"],
528
- theme.secondaryAccentText ?? theme.accentText,
529
- );
530
- pushSyntaxTokenColor(
531
- tokenColors,
532
- ["markup.inline", "string"],
533
- theme.diffAdded,
534
- );
535
- pushSyntaxTokenColor(tokenColors, "regexp", theme.diffRemoved);
536
- pushSyntaxTokenColor(
537
- tokenColors,
538
- ["attr", "attribute", "entity.other.attribute-name", "property"],
539
- theme.accentText,
540
- );
541
- pushSyntaxTokenColor(
542
- tokenColors,
543
- ["entity.name.tag", "name", "tag"],
544
- theme.accentText,
545
- );
560
+ function formatTodoWriteCallSummary(args: Record<string, unknown>): string {
561
+ const todoCount = Array.isArray(args.todos) ? args.todos.length : 0;
562
+ const todoLabel = todoCount === 1 ? "todo" : "todos";
563
+ return `Updating todos... ${todoCount} ${todoLabel} updated`;
546
564
  }
547
565
 
548
- function pushMarkdownSyntaxTokenColors(
549
- tokenColors: SyntaxThemeTokenColor[],
550
- theme: Theme,
551
- ): void {
552
- pushSyntaxTokenColor(tokenColors, "quote", theme.mutedText, "italic");
553
- pushSyntaxTokenColor(tokenColors, "section", theme.accentText, "bold");
554
- pushSyntaxTokenColor(
555
- tokenColors,
556
- "bullet",
557
- theme.secondaryAccentText,
558
- "bold",
566
+ function parseShellToolContent(
567
+ content: ToolResultMessage["content"],
568
+ details?: ToolResultMessage["details"],
569
+ ) {
570
+ return (
571
+ parseShellResultDetails(details) ??
572
+ parseLegacyShellResult(getToolContentText(content))
559
573
  );
560
- pushSyntaxTokenColor(tokenColors, ["code", "string"], theme.diffAdded);
561
- pushSyntaxTokenColor(tokenColors, "link", theme.accentText, "underline");
562
- pushSyntaxTokenColor(tokenColors, "strong", undefined, "bold");
563
- pushSyntaxTokenColor(tokenColors, "emphasis", undefined, "italic");
564
574
  }
565
575
 
566
- function getSyntaxTheme(
567
- theme: Theme,
568
- variant: SyntaxThemeVariant,
569
- ): SyntaxThemeRegistration {
570
- const cache = syntaxThemeCache[variant];
571
- const cached = cache.get(theme);
572
- if (cached) {
573
- return cached;
574
- }
575
-
576
- const tokenColors: SyntaxThemeTokenColor[] = [];
577
- if (variant === "shell") {
578
- pushShellSyntaxTokenColors(tokenColors, theme);
579
- } else {
580
- pushMarkdownSyntaxTokenColors(tokenColors, theme);
576
+ function buildShellResultLines(
577
+ content: ToolResultMessage["content"],
578
+ details?: ToolResultMessage["details"],
579
+ ): { bodyLines: ToolRenderLine[]; footerLines?: ToolRenderLine[] } {
580
+ const result = parseShellToolContent(content, details);
581
+ if (!result) {
582
+ return {
583
+ bodyLines: splitToolTextLines(
584
+ normalizeDisplayLineEndings(getToolContentText(content)),
585
+ "text",
586
+ ),
587
+ };
581
588
  }
582
589
 
583
- const syntaxTheme: SyntaxThemeRegistration = {
584
- tokenColors,
585
- };
586
- cache.set(theme, syntaxTheme);
587
- return syntaxTheme;
588
- }
590
+ const bodyLines: ToolRenderLine[] = [];
591
+ const stdout = normalizeDisplayLineEndings(result.stdout);
592
+ const stderr = normalizeDisplayLineEndings(result.stderr);
589
593
 
590
- function splitHighlightedTextNode(
591
- node: Extract<Node, { type: "text" }>,
592
- chunkSize: number,
593
- ): Node[] {
594
- if (node.content === "") {
595
- return [Text("", node.props)];
594
+ if (stdout !== "") {
595
+ bodyLines.push(...splitToolTextLines(stdout, "text"));
596
596
  }
597
-
598
- const parts = node.content.match(/\s+|\S+/gu);
599
- if (!parts) {
600
- return [Text(node.content, node.props)];
601
- }
602
-
603
- const children: Node[] = [];
604
- for (const part of parts) {
605
- if (/^\s+$/u.test(part)) {
606
- children.push(Text(part, node.props));
607
- continue;
608
- }
609
-
610
- let chunk = "";
611
- let chunkGraphemes = 0;
612
- for (const { segment } of graphemeSegmenter.segment(part)) {
613
- chunk += segment;
614
- chunkGraphemes += 1;
615
-
616
- if (chunkGraphemes === chunkSize) {
617
- children.push(Text(chunk, node.props));
618
- chunk = "";
619
- chunkGraphemes = 0;
620
- }
621
- }
622
-
623
- if (chunk !== "") {
624
- children.push(Text(chunk, node.props));
597
+ if (stderr !== "") {
598
+ if (bodyLines.length > 0) {
599
+ bodyLines.push({ kind: "text", text: "" });
625
600
  }
601
+ bodyLines.push(...splitToolTextLines(stderr, "text"));
602
+ }
603
+ if (bodyLines.length === 0) {
604
+ bodyLines.push({ kind: "summary", text: "(no output)" });
626
605
  }
627
606
 
628
- return children;
607
+ return {
608
+ bodyLines,
609
+ footerLines: [
610
+ {
611
+ kind: "text",
612
+ text: `exit ${result.exitCode}`,
613
+ },
614
+ ],
615
+ };
629
616
  }
630
617
 
631
- function normalizeHighlightedLine(line: Node, bodyWidth: number): Node {
632
- if (line.type !== "hstack") {
633
- return line;
634
- }
618
+ function getToolHeaderName(toolName: string): string {
619
+ return toolName.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
620
+ }
635
621
 
636
- const chunkSize = getHighlightWrapChunkSize(bodyWidth);
637
- const children = line.children.flatMap((child) => {
638
- return child.type === "text"
639
- ? splitHighlightedTextNode(child, chunkSize)
640
- : [child];
641
- });
642
- return HStack(line.props, children);
622
+ function getToolHeaderColor(toolName: string, theme: Theme) {
623
+ switch (toolName) {
624
+ case "shell":
625
+ case "read":
626
+ case "grep":
627
+ case "readImage":
628
+ case "todoWrite":
629
+ case "todoRead":
630
+ return theme.accentText;
631
+ case "edit":
632
+ return theme.secondaryAccentText;
633
+ default:
634
+ return theme.secondaryAccentText ?? theme.toolText;
635
+ }
643
636
  }
644
637
 
645
- function getHighlightedBodyLines(
638
+ function getHighlightedBodyNode(
646
639
  spec: HighlightedBodySpec,
647
640
  theme: Theme,
648
- bodyWidth: number,
649
- ): Node[] {
641
+ ): Node | null {
650
642
  if (spec.text === "") {
651
- return [];
643
+ return null;
652
644
  }
653
645
 
654
- const highlighted = SyntaxHighlight(spec.text, spec.language, {
655
- theme: getSyntaxTheme(theme, spec.themeVariant),
656
- });
657
- return highlighted.children.map((line) =>
658
- normalizeHighlightedLine(line, bodyWidth),
659
- );
646
+ try {
647
+ return SyntaxHighlight(spec.text, spec.language, {
648
+ theme: getSyntaxHighlightTheme(theme, spec.themeVariant),
649
+ });
650
+ } catch {
651
+ return null;
652
+ }
660
653
  }
661
654
 
662
655
  /** Render a single styled text node for a tool line. */
@@ -673,6 +666,11 @@ function renderToolLine(line: ToolRenderLine, theme: Theme): Node {
673
666
  return Text(line.text, { fgColor: theme.diffAdded, wrap: "word" });
674
667
  case "diffRemoved":
675
668
  return Text(line.text, { fgColor: theme.diffRemoved, wrap: "word" });
669
+ case "context":
670
+ return Text(line.text, {
671
+ fgColor: theme.mutedText,
672
+ wrap: "word",
673
+ });
676
674
  case "summary":
677
675
  return Text(line.text, {
678
676
  fgColor: theme.toolText,
@@ -682,7 +680,10 @@ function renderToolLine(line: ToolRenderLine, theme: Theme): Node {
682
680
  case "error":
683
681
  return Text(line.text, { fgColor: theme.error, wrap: "word" });
684
682
  case "text":
685
- return Text(line.text, { wrap: "word" });
683
+ return Text(line.text, {
684
+ fgColor: theme.toolText,
685
+ wrap: "word",
686
+ });
686
687
  }
687
688
  }
688
689
 
@@ -736,36 +737,58 @@ function renderTodoChecklist(todos: readonly TodoItem[], theme: Theme): Node {
736
737
  );
737
738
  }
738
739
 
739
- function measureToolNodesHeight(lines: readonly Node[], width: number): number {
740
- if (lines.length === 0) {
741
- return 0;
740
+ function measureToolNodeHeight(node: Node, width: number): number {
741
+ return measureContentHeight(VStack({}, [node]), { width });
742
+ }
743
+
744
+ function renderFullToolBody(spec: ToolBlockSpec, theme: Theme): Node | null {
745
+ if (spec.highlightedBody) {
746
+ const highlighted = getHighlightedBodyNode(spec.highlightedBody, theme);
747
+ if (highlighted) {
748
+ return highlighted;
749
+ }
742
750
  }
743
751
 
744
- return measureContentHeight(VStack({}, [...lines]), { width });
745
- }
752
+ if (spec.bodyLines.length === 0) {
753
+ return null;
754
+ }
746
755
 
747
- function measureToolNodeHeight(line: Node, width: number): number {
748
- return measureContentHeight(VStack({}, [line]), { width });
756
+ return VStack({}, renderToolLines(spec.bodyLines, theme));
749
757
  }
750
758
 
751
- function countVisibleTailNodes(
752
- lines: readonly Node[],
753
- width: number,
754
- maxRows: number,
755
- ): number {
756
- let remainingRows = maxRows;
757
- let visibleLineCount = 0;
759
+ function renderToolBodyLines(
760
+ spec: ToolBlockSpec,
761
+ lines: readonly ToolRenderLine[],
762
+ theme: Theme,
763
+ ): Node | null {
764
+ if (lines.length === 0) {
765
+ return null;
766
+ }
758
767
 
759
- for (let index = lines.length - 1; index >= 0; index--) {
760
- const lineHeight = measureToolNodeHeight(lines[index]!, width);
761
- if (lineHeight > remainingRows) {
762
- return visibleLineCount > 0 ? visibleLineCount : 1;
768
+ if (spec.highlightedBody) {
769
+ const highlighted = getHighlightedBodyNode(
770
+ {
771
+ ...spec.highlightedBody,
772
+ text: lines.map((line) => line.text).join("\n"),
773
+ },
774
+ theme,
775
+ );
776
+ if (highlighted) {
777
+ return highlighted;
763
778
  }
764
- remainingRows -= lineHeight;
765
- visibleLineCount += 1;
766
779
  }
767
780
 
768
- return visibleLineCount;
781
+ return VStack({}, renderToolLines(lines, theme));
782
+ }
783
+
784
+ function wrapToolPreviewBody(body: Node): Node {
785
+ return VStack(
786
+ {
787
+ height: UI_TOOL_PREVIEW_ROWS,
788
+ justifyContent: "end",
789
+ },
790
+ body.type === "vstack" ? [...body.children] : [body],
791
+ );
769
792
  }
770
793
 
771
794
  function renderToolHeaderPill(
@@ -781,49 +804,64 @@ function renderToolHeaderPill(
781
804
  ]);
782
805
  }
783
806
 
784
- function renderToolBodyFromNodes(
785
- lines: readonly Node[],
786
- previewBody: boolean,
787
- opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
788
- ): { body: Node | null; summary?: ToolRenderLine } {
789
- if (lines.length === 0) {
790
- return { body: null };
807
+ function renderToolHeaderRow(spec: ToolBlockSpec, theme: Theme): Node {
808
+ const children: Node[] = [
809
+ renderToolHeaderPill(spec.toolName, spec.direction, theme),
810
+ ];
811
+
812
+ if (spec.headerSuffix) {
813
+ children.push(
814
+ Text(` ${spec.headerSuffix}`, {
815
+ fgColor: theme.toolText,
816
+ wrap: "word",
817
+ }),
818
+ );
791
819
  }
792
820
 
793
- if (opts.verbose || !previewBody) {
794
- return { body: VStack({}, [...lines]) };
821
+ return HStack({}, children);
822
+ }
823
+
824
+ function renderCompactToolBody(
825
+ spec: ToolBlockSpec,
826
+ opts: Pick<ConversationRenderOpts, "previewWidth" | "theme">,
827
+ ): { body: Node | null; summary?: ToolRenderLine } {
828
+ if (spec.bodyLines.length === 0) {
829
+ return { body: null };
795
830
  }
796
831
 
797
832
  const bodyWidth = getToolBodyWidth(opts.previewWidth);
798
- const totalHeight = measureToolNodesHeight(lines, bodyWidth);
799
- if (totalHeight <= UI_TOOL_PREVIEW_ROWS) {
800
- return { body: VStack({}, [...lines]) };
801
- }
833
+ let previewStart = Math.max(0, spec.bodyLines.length - UI_TOOL_PREVIEW_ROWS);
802
834
 
803
- const visibleLineCount = countVisibleTailNodes(
804
- lines,
805
- bodyWidth,
806
- UI_TOOL_PREVIEW_ROWS,
807
- );
808
- const previewLines = lines.slice(-visibleLineCount);
809
- const hiddenLineCount = Math.max(0, lines.length - visibleLineCount);
835
+ for (;;) {
836
+ const previewLines = spec.bodyLines.slice(previewStart);
837
+ const previewBody = renderToolBodyLines(spec, previewLines, opts.theme);
838
+ if (!previewBody) {
839
+ return { body: null };
840
+ }
810
841
 
811
- return {
812
- body: VStack(
813
- {
814
- height: UI_TOOL_PREVIEW_ROWS,
815
- justifyContent: "end",
816
- },
817
- [...previewLines],
818
- ),
819
- summary:
820
- hiddenLineCount > 0
821
- ? {
822
- kind: "summary",
823
- text: `And ${hiddenLineCount} lines more`,
824
- }
825
- : undefined,
826
- };
842
+ const previewHeight = measureToolNodeHeight(previewBody, bodyWidth);
843
+ if (
844
+ previewHeight <= UI_TOOL_PREVIEW_ROWS ||
845
+ previewStart >= spec.bodyLines.length - 1
846
+ ) {
847
+ const hiddenLineCount = previewStart;
848
+ return {
849
+ body:
850
+ hiddenLineCount > 0 || previewHeight > UI_TOOL_PREVIEW_ROWS
851
+ ? wrapToolPreviewBody(previewBody)
852
+ : previewBody,
853
+ summary:
854
+ hiddenLineCount > 0
855
+ ? {
856
+ kind: "summary",
857
+ text: `And ${hiddenLineCount} lines more`,
858
+ }
859
+ : undefined,
860
+ };
861
+ }
862
+
863
+ previewStart += 1;
864
+ }
827
865
  }
828
866
 
829
867
  function renderToolBody(
@@ -834,23 +872,13 @@ function renderToolBody(
834
872
  return { body: spec.bodyNode };
835
873
  }
836
874
 
837
- if (spec.highlightedBody) {
838
- return renderToolBodyFromNodes(
839
- getHighlightedBodyLines(
840
- spec.highlightedBody,
841
- opts.theme,
842
- getToolBodyWidth(opts.previewWidth),
843
- ),
844
- spec.previewBody,
845
- opts,
846
- );
875
+ if (opts.verbose || !spec.previewBody) {
876
+ return {
877
+ body: renderFullToolBody(spec, opts.theme),
878
+ };
847
879
  }
848
880
 
849
- return renderToolBodyFromNodes(
850
- renderToolLines(spec.bodyLines, opts.theme),
851
- spec.previewBody,
852
- opts,
853
- );
881
+ return renderCompactToolBody(spec, opts);
854
882
  }
855
883
 
856
884
  /** Render a tool block with a left border and compact header pill. */
@@ -859,11 +887,7 @@ function renderToolBlock(
859
887
  opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
860
888
  ): Node {
861
889
  const body = renderToolBody(spec, opts);
862
- const children: Node[] = [
863
- HStack({}, [
864
- renderToolHeaderPill(spec.toolName, spec.direction, opts.theme),
865
- ]),
866
- ];
890
+ const children: Node[] = [renderToolHeaderRow(spec, opts.theme)];
867
891
 
868
892
  if (body.body) {
869
893
  children.push(body.body);
@@ -871,18 +895,41 @@ function renderToolBlock(
871
895
  if (body.summary) {
872
896
  children.push(renderToolLine(body.summary, opts.theme));
873
897
  }
898
+ if (spec.footerLines) {
899
+ children.push(...renderToolLines(spec.footerLines, opts.theme));
900
+ }
874
901
 
875
902
  return HStack({ padding: { x: 1 } }, [
876
903
  Text("│ ", { fgColor: opts.theme.toolBorder }),
877
- VStack({ flex: 1, fgColor: opts.theme.toolText }, children),
904
+ VStack({ flex: 1 }, children),
878
905
  ]);
879
906
  }
880
907
 
881
- function getToolContentText(content: ToolResultMessage["content"]): string {
908
+ function getToolTextBlocks(content: ToolResultMessage["content"]): string[] {
882
909
  return content
883
910
  .filter((entry): entry is TextContent => entry.type === "text")
884
- .map((entry) => entry.text)
885
- .join("\n");
911
+ .map((entry) => entry.text);
912
+ }
913
+
914
+ function getToolContentText(content: ToolResultMessage["content"]): string {
915
+ return getToolTextBlocks(content).join("\n");
916
+ }
917
+
918
+ function parseReadToolContent(
919
+ content: ToolResultMessage["content"],
920
+ ): ReturnType<typeof parseReadResult> {
921
+ const textBlocks = getToolTextBlocks(content);
922
+ if (textBlocks.length > 1) {
923
+ const continuation = parseReadContinuationHint(textBlocks.at(-1) ?? "");
924
+ if (continuation) {
925
+ return {
926
+ body: textBlocks.slice(0, -1).join("\n"),
927
+ continuation,
928
+ };
929
+ }
930
+ }
931
+
932
+ return parseReadResult(textBlocks.join("\n"));
886
933
  }
887
934
 
888
935
  function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
@@ -903,6 +950,70 @@ function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
903
950
  };
904
951
  }
905
952
 
953
+ function buildReadToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
954
+ const lines: ToolRenderLine[] = [];
955
+ const filePath = getToolArgString(args, "path");
956
+ const offset = readFiniteNumber(args, "offset");
957
+ const limit = readFiniteNumber(args, "limit");
958
+
959
+ if (filePath) {
960
+ lines.push({ kind: "path", text: filePath });
961
+ }
962
+ if (offset !== null) {
963
+ lines.push({ kind: "text", text: `offset: ${offset}` });
964
+ }
965
+ if (limit !== null) {
966
+ lines.push({ kind: "text", text: `limit: ${limit}` });
967
+ }
968
+
969
+ return {
970
+ toolName: "read",
971
+ direction: "->",
972
+ bodyLines: lines,
973
+ previewBody: true,
974
+ };
975
+ }
976
+
977
+ function buildGrepToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
978
+ const lines: ToolRenderLine[] = [];
979
+ const pattern = getToolArgString(args, "pattern");
980
+ const path = getToolArgString(args, "path");
981
+ const glob = getToolArgString(args, "glob");
982
+ const context = readFiniteNumber(args, "context");
983
+ const limit = readFiniteNumber(args, "limit");
984
+ const ignoreCase = readBoolean(args, "ignoreCase");
985
+ const literal = readBoolean(args, "literal");
986
+
987
+ if (pattern) {
988
+ lines.push({ kind: "command", text: pattern });
989
+ }
990
+ if (path) {
991
+ lines.push({ kind: "text", text: `path: ${path}` });
992
+ }
993
+ if (glob) {
994
+ lines.push({ kind: "text", text: `glob: ${glob}` });
995
+ }
996
+ if (ignoreCase) {
997
+ lines.push({ kind: "text", text: "ignoreCase: true" });
998
+ }
999
+ if (literal) {
1000
+ lines.push({ kind: "text", text: "literal: true" });
1001
+ }
1002
+ if (context !== null) {
1003
+ lines.push({ kind: "text", text: `context: ${context}` });
1004
+ }
1005
+ if (limit !== null) {
1006
+ lines.push({ kind: "text", text: `limit: ${limit}` });
1007
+ }
1008
+
1009
+ return {
1010
+ toolName: "grep",
1011
+ direction: "->",
1012
+ bodyLines: lines,
1013
+ previewBody: true,
1014
+ };
1015
+ }
1016
+
906
1017
  function buildEditToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
907
1018
  const lines: ToolRenderLine[] = [];
908
1019
  const filePath = getToolArgString(args, "path");
@@ -947,6 +1058,10 @@ function buildReadImageToolCallSpec(
947
1058
  };
948
1059
  }
949
1060
 
1061
+ function isMcpToolName(toolName: string): boolean {
1062
+ return toolName.includes("__");
1063
+ }
1064
+
950
1065
  function buildGenericToolCallSpec(
951
1066
  toolName: string,
952
1067
  args: Record<string, unknown>,
@@ -956,7 +1071,7 @@ function buildGenericToolCallSpec(
956
1071
  toolName,
957
1072
  direction: "->",
958
1073
  bodyLines: text && text !== "{}" ? splitToolTextLines(text, "text") : [],
959
- previewBody: false,
1074
+ previewBody: isMcpToolName(toolName),
960
1075
  };
961
1076
  }
962
1077
 
@@ -967,6 +1082,12 @@ function buildToolCallSpec(
967
1082
  if (toolName === "shell") {
968
1083
  return buildShellToolCallSpec(args);
969
1084
  }
1085
+ if (toolName === "read") {
1086
+ return buildReadToolCallSpec(args);
1087
+ }
1088
+ if (toolName === "grep") {
1089
+ return buildGrepToolCallSpec(args);
1090
+ }
970
1091
  if (toolName === "edit") {
971
1092
  return buildEditToolCallSpec(args);
972
1093
  }
@@ -991,14 +1112,119 @@ function renderToolCall(
991
1112
 
992
1113
  function buildShellToolResultSpec(
993
1114
  content: ToolResultMessage["content"],
1115
+ details?: ToolResultMessage["details"],
994
1116
  ): ToolBlockSpec {
1117
+ const shellResult = buildShellResultLines(content, details);
995
1118
  return {
996
1119
  toolName: "shell",
997
1120
  direction: "<-",
998
- bodyLines: splitToolTextLines(
999
- normalizeShellOutput(getToolContentText(content)),
1000
- "text",
1001
- ),
1121
+ bodyLines: shellResult.bodyLines,
1122
+ ...(shellResult.footerLines
1123
+ ? { footerLines: shellResult.footerLines }
1124
+ : {}),
1125
+ previewBody: true,
1126
+ };
1127
+ }
1128
+
1129
+ function buildReadToolResultSpec(
1130
+ args: Record<string, unknown>,
1131
+ content: ToolResultMessage["content"],
1132
+ isError: boolean,
1133
+ cwd?: string,
1134
+ ): ToolBlockSpec {
1135
+ const filePath = getToolArgString(args, "path");
1136
+ const resolvedPath = formatResolvedToolPath(filePath, cwd);
1137
+ const resultText = getToolContentText(content);
1138
+
1139
+ if (isError) {
1140
+ return {
1141
+ toolName: "read",
1142
+ direction: "<-",
1143
+ ...(resolvedPath ? { headerSuffix: resolvedPath } : {}),
1144
+ bodyLines: splitToolTextLines(
1145
+ normalizeDisplayLineEndings(resultText),
1146
+ "error",
1147
+ ),
1148
+ previewBody: true,
1149
+ };
1150
+ }
1151
+
1152
+ const parsed = parseReadToolContent(content);
1153
+ const bodyText = parsed.body;
1154
+ const displayBodyText = normalizeDisplayLineEndings(bodyText);
1155
+ const language = getReadLanguageCandidates(resolveToolPath(filePath, cwd))[0];
1156
+ return {
1157
+ toolName: "read",
1158
+ direction: "<-",
1159
+ ...(resolvedPath ? { headerSuffix: resolvedPath } : {}),
1160
+ bodyLines:
1161
+ displayBodyText === ""
1162
+ ? [{ kind: "summary", text: "Empty file." }]
1163
+ : splitToolTextLines(displayBodyText, "text"),
1164
+ ...(bodyText !== "" && language
1165
+ ? {
1166
+ highlightedBody: {
1167
+ text: bodyText,
1168
+ language,
1169
+ themeVariant: "code" as const,
1170
+ },
1171
+ }
1172
+ : {}),
1173
+ previewBody: true,
1174
+ };
1175
+ }
1176
+
1177
+ function buildGrepToolResultSpec(
1178
+ content: ToolResultMessage["content"],
1179
+ isError: boolean,
1180
+ cwd?: string,
1181
+ ): ToolBlockSpec {
1182
+ const resultText = getToolContentText(content);
1183
+ if (isError) {
1184
+ return {
1185
+ toolName: "grep",
1186
+ direction: "<-",
1187
+ bodyLines: splitToolTextLines(resultText, "error"),
1188
+ previewBody: true,
1189
+ };
1190
+ }
1191
+
1192
+ const result = parseGrepResult(resultText);
1193
+ if (!result) {
1194
+ return {
1195
+ toolName: "grep",
1196
+ direction: "<-",
1197
+ bodyLines: splitToolTextLines(resultText, "text"),
1198
+ previewBody: true,
1199
+ };
1200
+ }
1201
+
1202
+ const lines: ToolRenderLine[] = [];
1203
+ for (const file of result.files) {
1204
+ lines.push({
1205
+ kind: "path",
1206
+ text: formatRelativeToolPath(file.path, cwd),
1207
+ });
1208
+ for (const line of file.lines) {
1209
+ const text = line.text.replace(/\r?\n$/, "");
1210
+ lines.push({
1211
+ kind: line.kind === "context" ? "context" : "text",
1212
+ text: ` ${line.lineNumber}: ${text}`,
1213
+ });
1214
+ }
1215
+ }
1216
+
1217
+ if (lines.length === 0) {
1218
+ lines.push({ kind: "summary", text: "No matches found." });
1219
+ }
1220
+
1221
+ return {
1222
+ toolName: "grep",
1223
+ direction: "<-",
1224
+ bodyLines: lines,
1225
+ ...(result.hint
1226
+ ? { footerLines: [{ kind: "summary", text: result.hint }] }
1227
+ : {}),
1002
1228
  previewBody: true,
1003
1229
  };
1004
1230
  }
@@ -1083,7 +1309,7 @@ function buildGenericToolResultSpec(
1083
1309
  toolName,
1084
1310
  direction: "<-",
1085
1311
  bodyLines: splitToolTextLines(output, isError ? "error" : "text"),
1086
- previewBody: false,
1312
+ previewBody: isMcpToolName(toolName),
1087
1313
  };
1088
1314
  }
1089
1315
 
@@ -1092,10 +1318,26 @@ function renderToolResultContent(
1092
1318
  args: Record<string, unknown>,
1093
1319
  content: ToolResultMessage["content"],
1094
1320
  isError: boolean,
1095
- opts: Pick<ConversationRenderOpts, "previewWidth" | "verbose" | "theme">,
1321
+ opts: Pick<
1322
+ ConversationRenderOpts,
1323
+ "previewWidth" | "verbose" | "theme" | "cwd"
1324
+ >,
1325
+ details?: ToolResultMessage["details"],
1096
1326
  ): Node {
1097
1327
  if (toolName === "shell") {
1098
- return renderToolBlock(buildShellToolResultSpec(content), opts);
1328
+ return renderToolBlock(buildShellToolResultSpec(content, details), opts);
1329
+ }
1330
+ if (toolName === "read") {
1331
+ return renderToolBlock(
1332
+ buildReadToolResultSpec(args, content, isError, opts.cwd),
1333
+ opts,
1334
+ );
1335
+ }
1336
+ if (toolName === "grep") {
1337
+ return renderToolBlock(
1338
+ buildGrepToolResultSpec(content, isError, opts.cwd),
1339
+ opts,
1340
+ );
1099
1341
  }
1100
1342
  if (toolName === "edit") {
1101
1343
  return renderToolBlock(
@@ -1130,6 +1372,7 @@ function renderToolResultContent(
1130
1372
  * @param resultText - Text content from the tool result message.
1131
1373
  * @param isError - Whether the tool execution failed.
1132
1374
  * @param opts - Shared conversation render options.
1375
+ * @param details - Optional structured tool-result details.
1133
1376
  * @returns The rendered tool block node.
1134
1377
  */
1135
1378
  export function renderToolResult(
@@ -1138,12 +1381,20 @@ export function renderToolResult(
1138
1381
  resultText: string,
1139
1382
  isError: boolean,
1140
1383
  opts: ConversationRenderOpts,
1384
+ details?: ToolResultMessage["details"],
1141
1385
  ): Node {
1142
1386
  const content: ToolResultMessage["content"] = resultText
1143
1387
  ? [{ type: "text", text: resultText }]
1144
1388
  : [];
1145
1389
 
1146
- return renderToolResultContent(toolName, args, content, isError, opts);
1390
+ return renderToolResultContent(
1391
+ toolName,
1392
+ args,
1393
+ content,
1394
+ isError,
1395
+ opts,
1396
+ details,
1397
+ );
1147
1398
  }
1148
1399
 
1149
1400
  function renderUiTodoMessage(
@@ -1170,11 +1421,7 @@ function renderUiMessage(
1170
1421
  }
1171
1422
 
1172
1423
  if (msg.format === "markdown") {
1173
- const markdown = renderMarkdownTextBlock(
1174
- msg.content,
1175
- opts.theme,
1176
- opts.previewWidth,
1177
- );
1424
+ const markdown = renderMarkdownTextBlock(msg.content, opts.theme);
1178
1425
  if (markdown) {
1179
1426
  return markdown;
1180
1427
  }
@@ -1206,13 +1453,6 @@ function renderEmptyConversationBanner(
1206
1453
  ]);
1207
1454
  }
1208
1455
 
1209
- function pushConversationNode(nodes: Node[], node: Node | null): void {
1210
- if (!node) {
1211
- return;
1212
- }
1213
- nodes.push(node);
1214
- }
1215
-
1216
1456
  function rememberToolCallArgs(
1217
1457
  message: AssistantMessage,
1218
1458
  toolCallArgs: Map<string, ToolCallRenderInfo>,
@@ -1234,8 +1474,16 @@ function renderToolResultMessage(
1234
1474
  content: ToolResultMessage["content"],
1235
1475
  isError: boolean,
1236
1476
  renderOpts: ConversationRenderOpts,
1477
+ details?: ToolResultMessage["details"],
1237
1478
  ): Node {
1238
- return renderToolResultContent(toolName, args, content, isError, renderOpts);
1479
+ return renderToolResultContent(
1480
+ toolName,
1481
+ args,
1482
+ content,
1483
+ isError,
1484
+ renderOpts,
1485
+ details,
1486
+ );
1239
1487
  }
1240
1488
 
1241
1489
  function renderConversationMessage(
@@ -1262,6 +1510,7 @@ function renderConversationMessage(
1262
1510
  message.content,
1263
1511
  message.isError,
1264
1512
  renderOpts,
1513
+ message.details,
1265
1514
  );
1266
1515
  }
1267
1516
 
@@ -1285,58 +1534,150 @@ function cacheToolCallArgs(messages: readonly ConversationMessage[]): void {
1285
1534
  toolCallArgsCache.count = messages.length;
1286
1535
  }
1287
1536
 
1288
- function canReuseCommittedConversationCache(
1289
- state: ConversationLogState,
1290
- startIndex: number,
1537
+ function canReuseConversationMessageRender(
1538
+ cached: ConversationMessageRenderCacheEntry | undefined,
1539
+ renderOpts: ConversationRenderOpts,
1291
1540
  previewWidth: number,
1292
- ): boolean {
1541
+ ): cached is ConversationMessageRenderCacheEntry {
1293
1542
  return (
1294
- conversationRenderCache.messages === state.messages &&
1295
- conversationRenderCache.startIndex === startIndex &&
1296
- conversationRenderCache.showReasoning === state.showReasoning &&
1297
- conversationRenderCache.verbose === state.verbose &&
1298
- conversationRenderCache.previewWidth === previewWidth &&
1299
- conversationRenderCache.theme === state.theme &&
1300
- conversationRenderCache.count <= state.messages.length
1543
+ cached !== undefined &&
1544
+ cached.showReasoning === renderOpts.showReasoning &&
1545
+ cached.verbose === renderOpts.verbose &&
1546
+ cached.previewWidth === previewWidth &&
1547
+ cached.cwd === renderOpts.cwd &&
1548
+ cached.theme === renderOpts.theme
1301
1549
  );
1302
1550
  }
1303
1551
 
1304
- function cacheCommittedConversation(
1552
+ function canReuseCommittedConversationLayout(
1305
1553
  state: ConversationLogState,
1306
1554
  renderOpts: ConversationRenderOpts,
1307
- startIndex: number,
1555
+ previewWidth: number,
1556
+ ): boolean {
1557
+ return (
1558
+ conversationLayoutCache.messages === state.messages &&
1559
+ conversationLayoutCache.count <= state.messages.length &&
1560
+ conversationLayoutCache.showReasoning === renderOpts.showReasoning &&
1561
+ conversationLayoutCache.verbose === renderOpts.verbose &&
1562
+ conversationLayoutCache.previewWidth === previewWidth &&
1563
+ conversationLayoutCache.cwd === renderOpts.cwd &&
1564
+ conversationLayoutCache.theme === renderOpts.theme
1565
+ );
1566
+ }
1567
+
1568
+ function appendConversationLogItem(
1569
+ items: ConversationLogItem[],
1570
+ prefixHeights: number[],
1571
+ item: ConversationLogItem | null,
1308
1572
  ): void {
1573
+ if (!item) {
1574
+ return;
1575
+ }
1576
+
1577
+ items.push(item);
1578
+ prefixHeights.push(prefixHeights[prefixHeights.length - 1]! + item.height);
1579
+ }
1580
+
1581
+ function measureConversationItemHeight(
1582
+ node: Node,
1583
+ previewWidth: number,
1584
+ ): number {
1585
+ return measureContentHeight(node, {
1586
+ width: getPreviewWidth(previewWidth),
1587
+ });
1588
+ }
1589
+
1590
+ function createConversationLogItem(
1591
+ node: Node | null,
1592
+ previewWidth: number,
1593
+ ): ConversationLogItem | null {
1594
+ if (!node) {
1595
+ return null;
1596
+ }
1597
+
1598
+ return {
1599
+ node,
1600
+ height: measureConversationItemHeight(node, previewWidth),
1601
+ };
1602
+ }
1603
+
1604
+ function getCommittedConversationLogItem(
1605
+ message: ConversationMessage,
1606
+ renderOpts: ConversationRenderOpts,
1607
+ ): ConversationLogItem | null {
1309
1608
  const previewWidth = getPreviewWidth(renderOpts.previewWidth);
1310
- cacheToolCallArgs(state.messages);
1609
+ const cached = conversationMessageRenderCache.get(message);
1610
+ if (canReuseConversationMessageRender(cached, renderOpts, previewWidth)) {
1611
+ if (!cached.node) {
1612
+ return null;
1613
+ }
1614
+ return {
1615
+ node: cached.node,
1616
+ height: cached.height,
1617
+ };
1618
+ }
1619
+
1620
+ const node = renderConversationMessage(
1621
+ message,
1622
+ renderOpts,
1623
+ toolCallArgsCache.entries,
1624
+ renderOpts.theme,
1625
+ );
1626
+ const nextEntry: ConversationMessageRenderCacheEntry = {
1627
+ node,
1628
+ height: node ? measureConversationItemHeight(node, previewWidth) : 0,
1629
+ showReasoning: renderOpts.showReasoning,
1630
+ verbose: renderOpts.verbose,
1631
+ previewWidth,
1632
+ cwd: renderOpts.cwd ?? null,
1633
+ theme: renderOpts.theme,
1634
+ };
1635
+ conversationMessageRenderCache.set(message, nextEntry);
1636
+
1637
+ if (!node) {
1638
+ return null;
1639
+ }
1640
+
1641
+ return {
1642
+ node,
1643
+ height: nextEntry.height,
1644
+ };
1645
+ }
1311
1646
 
1312
- if (!canReuseCommittedConversationCache(state, startIndex, previewWidth)) {
1313
- conversationRenderCache.messages = state.messages;
1314
- conversationRenderCache.startIndex = startIndex;
1315
- conversationRenderCache.count = startIndex;
1316
- conversationRenderCache.showReasoning = state.showReasoning;
1317
- conversationRenderCache.verbose = state.verbose;
1318
- conversationRenderCache.previewWidth = previewWidth;
1319
- conversationRenderCache.theme = state.theme;
1320
- conversationRenderCache.nodes = [];
1647
+ function getCommittedConversationLayoutSnapshot(
1648
+ state: ConversationLogState,
1649
+ renderOpts: ConversationRenderOpts,
1650
+ ): ConversationLayoutSnapshot {
1651
+ const previewWidth = getPreviewWidth(renderOpts.previewWidth);
1652
+ if (!canReuseCommittedConversationLayout(state, renderOpts, previewWidth)) {
1653
+ conversationLayoutCache.messages = state.messages;
1654
+ conversationLayoutCache.count = 0;
1655
+ conversationLayoutCache.showReasoning = renderOpts.showReasoning;
1656
+ conversationLayoutCache.verbose = renderOpts.verbose;
1657
+ conversationLayoutCache.previewWidth = previewWidth;
1658
+ conversationLayoutCache.cwd = renderOpts.cwd ?? null;
1659
+ conversationLayoutCache.theme = renderOpts.theme;
1660
+ conversationLayoutCache.items = [];
1661
+ conversationLayoutCache.prefixHeights = [0];
1321
1662
  }
1322
1663
 
1323
1664
  for (
1324
- let index = conversationRenderCache.count;
1665
+ let index = conversationLayoutCache.count;
1325
1666
  index < state.messages.length;
1326
1667
  index++
1327
1668
  ) {
1328
- pushConversationNode(
1329
- conversationRenderCache.nodes,
1330
- renderConversationMessage(
1331
- state.messages[index]!,
1332
- renderOpts,
1333
- toolCallArgsCache.entries,
1334
- state.theme,
1335
- ),
1669
+ appendConversationLogItem(
1670
+ conversationLayoutCache.items,
1671
+ conversationLayoutCache.prefixHeights,
1672
+ getCommittedConversationLogItem(state.messages[index]!, renderOpts),
1336
1673
  );
1337
1674
  }
1338
1675
 
1339
- conversationRenderCache.count = state.messages.length;
1676
+ conversationLayoutCache.count = state.messages.length;
1677
+ return {
1678
+ items: conversationLayoutCache.items,
1679
+ prefixHeights: conversationLayoutCache.prefixHeights,
1680
+ };
1340
1681
  }
1341
1682
 
1342
1683
  function hasStreamingTail(streaming: StreamingConversationState): boolean {
@@ -1347,67 +1688,149 @@ function hasStreamingTail(streaming: StreamingConversationState): boolean {
1347
1688
  );
1348
1689
  }
1349
1690
 
1691
+ function appendStreamingConversationLogItems(
1692
+ items: ConversationLogItem[],
1693
+ prefixHeights: number[],
1694
+ streaming: StreamingConversationState,
1695
+ renderOpts: ConversationRenderOpts,
1696
+ previewWidth: number,
1697
+ ): void {
1698
+ appendConversationLogItem(
1699
+ items,
1700
+ prefixHeights,
1701
+ createConversationLogItem(
1702
+ renderAssistantMessage(
1703
+ {
1704
+ content: streaming.content,
1705
+ },
1706
+ renderOpts,
1707
+ ),
1708
+ previewWidth,
1709
+ ),
1710
+ );
1711
+
1712
+ for (const pendingToolResult of streaming.pendingToolResults) {
1713
+ const info = toolCallArgsCache.entries.get(pendingToolResult.toolCallId);
1714
+ appendConversationLogItem(
1715
+ items,
1716
+ prefixHeights,
1717
+ createConversationLogItem(
1718
+ renderToolResultMessage(
1719
+ info?.name ?? pendingToolResult.toolName,
1720
+ info?.args ?? EMPTY_TOOL_RESULT_ARGS,
1721
+ pendingToolResult.content,
1722
+ pendingToolResult.isError,
1723
+ renderOpts,
1724
+ pendingToolResult.details,
1725
+ ),
1726
+ previewWidth,
1727
+ ),
1728
+ );
1729
+ }
1730
+ }
1731
+
1732
+ function buildEmptyConversationBannerSnapshot(
1733
+ state: ConversationLogState,
1734
+ previewWidth: number,
1735
+ ): ConversationLayoutSnapshot {
1736
+ const banner = renderEmptyConversationBanner(
1737
+ state.theme,
1738
+ state.versionLabel ?? DEV_VERSION_LABEL,
1739
+ );
1740
+ const item = {
1741
+ node: banner,
1742
+ height: measureConversationItemHeight(banner, previewWidth),
1743
+ };
1744
+ return {
1745
+ items: [item],
1746
+ prefixHeights: [0, item.height],
1747
+ };
1748
+ }
1749
+
1350
1750
  /**
1351
- * Build the full conversation log as an array of nodes.
1751
+ * Build rendered conversation items plus cached cumulative intrinsic heights.
1352
1752
  *
1353
1753
  * @param state - Conversation rendering state.
1354
1754
  * @param streaming - Current in-progress assistant tail, if any.
1355
- * @param startIndex - Index of the first committed message to render.
1356
1755
  * @param previewWidth - Available terminal width for width-aware tool previews.
1357
- * @returns The rendered conversation log nodes.
1756
+ * @returns Rendered items plus cumulative heights in on-screen order.
1358
1757
  */
1359
- export function buildConversationLogNodes(
1758
+ export function buildConversationLayoutSnapshot(
1360
1759
  state: ConversationLogState,
1361
1760
  streaming: StreamingConversationState,
1362
- startIndex = 0,
1363
1761
  previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH,
1364
- ): Node[] {
1762
+ ): ConversationLayoutSnapshot {
1365
1763
  const renderOpts: ConversationRenderOpts = {
1366
1764
  showReasoning: state.showReasoning,
1367
1765
  verbose: state.verbose,
1368
1766
  theme: state.theme,
1767
+ cwd: state.cwd,
1369
1768
  previewWidth,
1370
1769
  };
1371
1770
 
1372
- if (state.messages.length === 0 && !hasStreamingTail(streaming)) {
1373
- return [
1374
- renderEmptyConversationBanner(
1375
- state.theme,
1376
- state.versionLabel ?? DEV_VERSION_LABEL,
1377
- ),
1378
- ];
1379
- }
1380
-
1381
- cacheCommittedConversation(state, renderOpts, startIndex);
1771
+ cacheToolCallArgs(state.messages);
1382
1772
 
1383
- if (!hasStreamingTail(streaming)) {
1384
- return conversationRenderCache.nodes;
1773
+ const committedSnapshot = getCommittedConversationLayoutSnapshot(
1774
+ state,
1775
+ renderOpts,
1776
+ );
1777
+ const streamingTailVisible = hasStreamingTail(streaming);
1778
+ if (!streamingTailVisible) {
1779
+ if (committedSnapshot.items.length > 0 || state.messages.length > 0) {
1780
+ return committedSnapshot;
1781
+ }
1782
+ return buildEmptyConversationBannerSnapshot(state, previewWidth);
1385
1783
  }
1386
1784
 
1387
- const nodes = [...conversationRenderCache.nodes];
1388
- pushConversationNode(
1389
- nodes,
1390
- renderAssistantMessage(
1391
- {
1392
- content: streaming.content,
1393
- },
1394
- renderOpts,
1395
- ),
1785
+ const items = [...committedSnapshot.items];
1786
+ const prefixHeights = [...committedSnapshot.prefixHeights];
1787
+ appendStreamingConversationLogItems(
1788
+ items,
1789
+ prefixHeights,
1790
+ streaming,
1791
+ renderOpts,
1792
+ previewWidth,
1396
1793
  );
1794
+ return {
1795
+ items,
1796
+ prefixHeights,
1797
+ };
1798
+ }
1397
1799
 
1398
- for (const pendingToolResult of streaming.pendingToolResults) {
1399
- const info = toolCallArgsCache.entries.get(pendingToolResult.toolCallId);
1400
- pushConversationNode(
1401
- nodes,
1402
- renderToolResultMessage(
1403
- info?.name ?? pendingToolResult.toolName,
1404
- info?.args ?? EMPTY_TOOL_RESULT_ARGS,
1405
- pendingToolResult.content,
1406
- pendingToolResult.isError,
1407
- renderOpts,
1408
- ),
1409
- );
1410
- }
1800
+ /**
1801
+ * Build the rendered conversation items with cached per-message nodes/heights.
1802
+ *
1803
+ * @param state - Conversation rendering state.
1804
+ * @param streaming - Current in-progress assistant tail, if any.
1805
+ * @param previewWidth - Available terminal width for width-aware tool previews.
1806
+ * @returns Rendered items in on-screen order.
1807
+ */
1808
+ export function buildConversationLogItems(
1809
+ state: ConversationLogState,
1810
+ streaming: StreamingConversationState,
1811
+ previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH,
1812
+ ): readonly ConversationLogItem[] {
1813
+ return buildConversationLayoutSnapshot(state, streaming, previewWidth).items;
1814
+ }
1411
1815
 
1412
- return nodes;
1816
+ /**
1817
+ * Build the full conversation log as an array of nodes.
1818
+ *
1819
+ * @param state - Conversation rendering state.
1820
+ * @param streaming - Current in-progress assistant tail, if any.
1821
+ * @param startIndex - Index of the first rendered item to include.
1822
+ * @param previewWidth - Available terminal width for width-aware tool previews.
1823
+ * @param endIndex - Exclusive rendered-item end index.
1824
+ * @returns The rendered conversation log nodes.
1825
+ */
1826
+ export function buildConversationLogNodes(
1827
+ state: ConversationLogState,
1828
+ streaming: StreamingConversationState,
1829
+ startIndex = 0,
1830
+ previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH,
1831
+ endIndex = Number.POSITIVE_INFINITY,
1832
+ ): Node[] {
1833
+ return buildConversationLogItems(state, streaming, previewWidth)
1834
+ .slice(startIndex, endIndex)
1835
+ .map((item) => item.node);
1413
1836
  }