mini-coder 0.5.12 → 0.5.14

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.
@@ -17,12 +17,9 @@ import {
17
17
  normalize,
18
18
  relative,
19
19
  } from "node:path";
20
- import {
21
- SyntaxHighlight,
22
- type SyntaxHighlightTheme,
23
- } from "@cel-tui/components";
20
+ import { SyntaxHighlight } from "@cel-tui/components";
24
21
  import { HStack, measureContentHeight, Text, VStack } from "@cel-tui/core";
25
- import type { Color, Node } from "@cel-tui/types";
22
+ import type { Node } from "@cel-tui/types";
26
23
  import type {
27
24
  AssistantMessage,
28
25
  TextContent,
@@ -32,11 +29,13 @@ import type {
32
29
  import type { AppState } from "../index.ts";
33
30
  import type { UiMessage } from "../session.ts";
34
31
  import { readBoolean, readFiniteNumber, readString } from "../shared.ts";
35
- import type { Theme } from "../theme.ts";
32
+ import { getSyntaxHighlightTheme, type Theme } from "../theme.ts";
36
33
  import {
37
34
  parseGrepResult,
35
+ parseLegacyShellResult,
38
36
  parseReadContinuationHint,
39
37
  parseReadResult,
38
+ parseShellResultDetails,
40
39
  parseTodoSnapshot,
41
40
  type TodoItem,
42
41
  } from "../tools.ts";
@@ -54,26 +53,6 @@ const DEFAULT_TOOL_PREVIEW_WIDTH = 80;
54
53
  /** Horizontal columns consumed by tool-block padding and the left border. */
55
54
  const TOOL_BLOCK_CHROME_WIDTH = 4;
56
55
 
57
- /** ANSI16 fallback hex values for syntax-highlighter theme overrides. */
58
- const ANSI_COLOR_HEX: Readonly<Record<Color, string>> = {
59
- color00: "#000000",
60
- color01: "#cd3131",
61
- color02: "#0dbc79",
62
- color03: "#e5e510",
63
- color04: "#2472c8",
64
- color05: "#bc3fbc",
65
- color06: "#11a8cd",
66
- color07: "#e5e5e5",
67
- color08: "#666666",
68
- color09: "#f14c4c",
69
- color10: "#23d18b",
70
- color11: "#f5f543",
71
- color12: "#3b8eea",
72
- color13: "#d670d6",
73
- color14: "#29b8db",
74
- color15: "#ffffff",
75
- };
76
-
77
56
  /** A pending tool result shown in the streaming tail. */
78
57
  export interface PendingToolResult {
79
58
  /** Tool call id from the assistant message. */
@@ -82,6 +61,8 @@ export interface PendingToolResult {
82
61
  toolName: string;
83
62
  /** Progressive or final tool-result content captured so far. */
84
63
  content: ToolResultMessage["content"];
64
+ /** Optional structured details preserved on the tool result. */
65
+ details?: ToolResultMessage["details"];
85
66
  /** Whether the tool result was an error. */
86
67
  isError: boolean;
87
68
  }
@@ -138,16 +119,14 @@ interface ToolCallArgsCache {
138
119
  entries: Map<string, ToolCallRenderInfo>;
139
120
  }
140
121
 
141
- interface ConversationRenderCache {
142
- messages: readonly ConversationMessage[] | null;
143
- startIndex: number;
144
- count: number;
122
+ interface ConversationMessageRenderCacheEntry {
123
+ node: Node | null;
124
+ height: number;
145
125
  showReasoning: boolean;
146
126
  verbose: boolean;
147
127
  previewWidth: number;
148
128
  cwd: string | null;
149
129
  theme: Theme | null;
150
- nodes: Node[];
151
130
  }
152
131
 
153
132
  type ToolRenderDirection = "->" | "<-";
@@ -200,9 +179,36 @@ export interface ToolRenderLine {
200
179
  text: string;
201
180
  }
202
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
+
203
210
  const EMPTY_STREAMING_CONTENT: AssistantMessage["content"] = [];
204
211
  const EMPTY_PENDING_TOOL_RESULTS: readonly PendingToolResult[] = [];
205
- const EMPTY_RENDER_NODES: Node[] = [];
206
212
  const EMPTY_TOOL_RESULT_ARGS: Record<string, unknown> = Object.freeze({});
207
213
 
208
214
  const toolCallArgsCache: ToolCallArgsCache = {
@@ -211,16 +217,21 @@ const toolCallArgsCache: ToolCallArgsCache = {
211
217
  entries: new Map(),
212
218
  };
213
219
 
214
- const conversationRenderCache: ConversationRenderCache = {
220
+ let conversationMessageRenderCache = new WeakMap<
221
+ ConversationMessage,
222
+ ConversationMessageRenderCacheEntry
223
+ >();
224
+
225
+ const conversationLayoutCache: ConversationLayoutCache = {
215
226
  messages: null,
216
- startIndex: 0,
217
227
  count: 0,
218
228
  showReasoning: false,
219
229
  verbose: false,
220
230
  previewWidth: DEFAULT_TOOL_PREVIEW_WIDTH,
221
231
  cwd: null,
222
232
  theme: null,
223
- nodes: EMPTY_RENDER_NODES,
233
+ items: [],
234
+ prefixHeights: [0],
224
235
  };
225
236
 
226
237
  /** Reset cached committed conversation renders. */
@@ -228,15 +239,16 @@ export function resetConversationRenderCache(): void {
228
239
  toolCallArgsCache.messages = null;
229
240
  toolCallArgsCache.count = 0;
230
241
  toolCallArgsCache.entries = new Map();
231
- conversationRenderCache.messages = null;
232
- conversationRenderCache.startIndex = 0;
233
- conversationRenderCache.count = 0;
234
- conversationRenderCache.showReasoning = false;
235
- conversationRenderCache.verbose = false;
236
- conversationRenderCache.previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH;
237
- conversationRenderCache.cwd = null;
238
- conversationRenderCache.theme = null;
239
- 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];
240
252
  }
241
253
 
242
254
  /** Render a user message with a subtle background. */
@@ -551,12 +563,56 @@ function formatTodoWriteCallSummary(args: Record<string, unknown>): string {
551
563
  return `Updating todos... ${todoCount} ${todoLabel} updated`;
552
564
  }
553
565
 
554
- /** Strip shell execution labels and normalize the exit line for the UI. */
555
- function normalizeShellOutput(output: string): string {
556
- return output
557
- .replace(/^Exit code: (\d+)(?:\n|$)/, (_, code: string) => `exit ${code}\n`)
558
- .replace(/(^|\n)\[stderr\]\n/g, "$1")
559
- .replace(/\n$/, "");
566
+ function parseShellToolContent(
567
+ content: ToolResultMessage["content"],
568
+ details?: ToolResultMessage["details"],
569
+ ) {
570
+ return (
571
+ parseShellResultDetails(details) ??
572
+ parseLegacyShellResult(getToolContentText(content))
573
+ );
574
+ }
575
+
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
+ };
588
+ }
589
+
590
+ const bodyLines: ToolRenderLine[] = [];
591
+ const stdout = normalizeDisplayLineEndings(result.stdout);
592
+ const stderr = normalizeDisplayLineEndings(result.stderr);
593
+
594
+ if (stdout !== "") {
595
+ bodyLines.push(...splitToolTextLines(stdout, "text"));
596
+ }
597
+ if (stderr !== "") {
598
+ if (bodyLines.length > 0) {
599
+ bodyLines.push({ kind: "text", text: "" });
600
+ }
601
+ bodyLines.push(...splitToolTextLines(stderr, "text"));
602
+ }
603
+ if (bodyLines.length === 0) {
604
+ bodyLines.push({ kind: "summary", text: "(no output)" });
605
+ }
606
+
607
+ return {
608
+ bodyLines,
609
+ footerLines: [
610
+ {
611
+ kind: "text",
612
+ text: `exit ${result.exitCode}`,
613
+ },
614
+ ],
615
+ };
560
616
  }
561
617
 
562
618
  function getToolHeaderName(toolName: string): string {
@@ -579,137 +635,6 @@ function getToolHeaderColor(toolName: string, theme: Theme) {
579
635
  }
580
636
  }
581
637
 
582
- type SyntaxThemeRegistration = Exclude<SyntaxHighlightTheme, string>;
583
- type SyntaxThemeTokenColor = NonNullable<
584
- SyntaxThemeRegistration["tokenColors"]
585
- >[number];
586
-
587
- const syntaxThemeCache: Record<
588
- SyntaxThemeVariant,
589
- WeakMap<Theme, SyntaxThemeRegistration>
590
- > = {
591
- markdown: new WeakMap(),
592
- code: new WeakMap(),
593
- shell: new WeakMap(),
594
- };
595
-
596
- function colorToHex(color: Color | undefined): string | undefined {
597
- return color ? ANSI_COLOR_HEX[color] : undefined;
598
- }
599
-
600
- function pushSyntaxTokenColor(
601
- tokenColors: SyntaxThemeTokenColor[],
602
- scope: string | readonly string[],
603
- foreground: Color | undefined,
604
- fontStyle?: string,
605
- ): void {
606
- const foregroundHex = colorToHex(foreground);
607
- if (!foregroundHex && !fontStyle) {
608
- return;
609
- }
610
-
611
- tokenColors.push({
612
- scope,
613
- settings: {
614
- ...(foregroundHex ? { foreground: foregroundHex } : {}),
615
- ...(fontStyle ? { fontStyle } : {}),
616
- },
617
- });
618
- }
619
-
620
- function pushShellSyntaxTokenColors(
621
- tokenColors: SyntaxThemeTokenColor[],
622
- theme: Theme,
623
- ): void {
624
- pushSyntaxTokenColor(
625
- tokenColors,
626
- ["comment", "quote", "doctag"],
627
- theme.mutedText,
628
- "italic",
629
- );
630
- pushSyntaxTokenColor(
631
- tokenColors,
632
- ["keyword", "operator"],
633
- theme.secondaryAccentText,
634
- );
635
- pushSyntaxTokenColor(
636
- tokenColors,
637
- ["function_", "function", "title"],
638
- theme.accentText,
639
- );
640
- pushSyntaxTokenColor(
641
- tokenColors,
642
- ["built_in", "class_", "class", "inherited__", "type"],
643
- theme.accentText,
644
- );
645
- pushSyntaxTokenColor(
646
- tokenColors,
647
- ["escape", "literal", "number", "symbol"],
648
- theme.secondaryAccentText ?? theme.accentText,
649
- );
650
- pushSyntaxTokenColor(tokenColors, ["code", "string"], theme.diffAdded);
651
- pushSyntaxTokenColor(tokenColors, "regexp", theme.diffRemoved);
652
- pushSyntaxTokenColor(
653
- tokenColors,
654
- ["attr", "attribute", "params", "property", "selector-attr"],
655
- theme.accentText,
656
- );
657
- pushSyntaxTokenColor(
658
- tokenColors,
659
- [
660
- "name",
661
- "tag",
662
- "selector-class",
663
- "selector-id",
664
- "selector-pseudo",
665
- "selector-tag",
666
- ],
667
- theme.accentText,
668
- );
669
- }
670
-
671
- function pushMarkdownSyntaxTokenColors(
672
- tokenColors: SyntaxThemeTokenColor[],
673
- theme: Theme,
674
- ): void {
675
- pushSyntaxTokenColor(tokenColors, "quote", theme.mutedText, "italic");
676
- pushSyntaxTokenColor(tokenColors, "section", theme.accentText, "bold");
677
- pushSyntaxTokenColor(
678
- tokenColors,
679
- "bullet",
680
- theme.secondaryAccentText,
681
- "bold",
682
- );
683
- pushSyntaxTokenColor(tokenColors, ["code", "string"], theme.diffAdded);
684
- pushSyntaxTokenColor(tokenColors, "link", theme.accentText, "underline");
685
- pushSyntaxTokenColor(tokenColors, "strong", undefined, "bold");
686
- pushSyntaxTokenColor(tokenColors, "emphasis", undefined, "italic");
687
- }
688
-
689
- function getSyntaxTheme(
690
- theme: Theme,
691
- variant: SyntaxThemeVariant,
692
- ): SyntaxThemeRegistration {
693
- const cache = syntaxThemeCache[variant];
694
- const cached = cache.get(theme);
695
- if (cached) {
696
- return cached;
697
- }
698
-
699
- const tokenColors: SyntaxThemeTokenColor[] = [];
700
- if (variant === "markdown") {
701
- pushMarkdownSyntaxTokenColors(tokenColors, theme);
702
- } else {
703
- pushShellSyntaxTokenColors(tokenColors, theme);
704
- }
705
-
706
- const syntaxTheme: SyntaxThemeRegistration = {
707
- tokenColors,
708
- };
709
- cache.set(theme, syntaxTheme);
710
- return syntaxTheme;
711
- }
712
-
713
638
  function getHighlightedBodyNode(
714
639
  spec: HighlightedBodySpec,
715
640
  theme: Theme,
@@ -720,7 +645,7 @@ function getHighlightedBodyNode(
720
645
 
721
646
  try {
722
647
  return SyntaxHighlight(spec.text, spec.language, {
723
- theme: getSyntaxTheme(theme, spec.themeVariant),
648
+ theme: getSyntaxHighlightTheme(theme, spec.themeVariant),
724
649
  });
725
650
  } catch {
726
651
  return null;
@@ -812,36 +737,58 @@ function renderTodoChecklist(todos: readonly TodoItem[], theme: Theme): Node {
812
737
  );
813
738
  }
814
739
 
815
- function measureToolNodesHeight(lines: readonly Node[], width: number): number {
816
- if (lines.length === 0) {
817
- 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
+ }
818
750
  }
819
751
 
820
- return measureContentHeight(VStack({}, [...lines]), { width });
821
- }
752
+ if (spec.bodyLines.length === 0) {
753
+ return null;
754
+ }
822
755
 
823
- function measureToolNodeHeight(line: Node, width: number): number {
824
- return measureContentHeight(VStack({}, [line]), { width });
756
+ return VStack({}, renderToolLines(spec.bodyLines, theme));
825
757
  }
826
758
 
827
- function countVisibleTailNodes(
828
- lines: readonly Node[],
829
- width: number,
830
- maxRows: number,
831
- ): number {
832
- let remainingRows = maxRows;
833
- 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
+ }
834
767
 
835
- for (let index = lines.length - 1; index >= 0; index--) {
836
- const lineHeight = measureToolNodeHeight(lines[index]!, width);
837
- if (lineHeight > remainingRows) {
838
- 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;
839
778
  }
840
- remainingRows -= lineHeight;
841
- visibleLineCount += 1;
842
779
  }
843
780
 
844
- 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
+ );
845
792
  }
846
793
 
847
794
  function renderToolHeaderPill(
@@ -874,93 +821,47 @@ function renderToolHeaderRow(spec: ToolBlockSpec, theme: Theme): Node {
874
821
  return HStack({}, children);
875
822
  }
876
823
 
877
- function renderToolBodyFromNodes(
878
- lines: readonly Node[],
879
- previewBody: boolean,
880
- opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
824
+ function renderCompactToolBody(
825
+ spec: ToolBlockSpec,
826
+ opts: Pick<ConversationRenderOpts, "previewWidth" | "theme">,
881
827
  ): { body: Node | null; summary?: ToolRenderLine } {
882
- if (lines.length === 0) {
828
+ if (spec.bodyLines.length === 0) {
883
829
  return { body: null };
884
830
  }
885
831
 
886
- if (opts.verbose || !previewBody) {
887
- return { body: VStack({}, [...lines]) };
888
- }
889
-
890
832
  const bodyWidth = getToolBodyWidth(opts.previewWidth);
891
- const totalHeight = measureToolNodesHeight(lines, bodyWidth);
892
- if (totalHeight <= UI_TOOL_PREVIEW_ROWS) {
893
- return { body: VStack({}, [...lines]) };
894
- }
833
+ let previewStart = Math.max(0, spec.bodyLines.length - UI_TOOL_PREVIEW_ROWS);
895
834
 
896
- const visibleLineCount = countVisibleTailNodes(
897
- lines,
898
- bodyWidth,
899
- UI_TOOL_PREVIEW_ROWS,
900
- );
901
- const previewLines = lines.slice(-visibleLineCount);
902
- const hiddenLineCount = Math.max(0, lines.length - visibleLineCount);
903
-
904
- return {
905
- body: VStack(
906
- {
907
- height: UI_TOOL_PREVIEW_ROWS,
908
- justifyContent: "end",
909
- },
910
- [...previewLines],
911
- ),
912
- summary:
913
- hiddenLineCount > 0
914
- ? {
915
- kind: "summary",
916
- text: `And ${hiddenLineCount} lines more`,
917
- }
918
- : undefined,
919
- };
920
- }
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
+ }
921
841
 
922
- function renderToolBodyFromHighlightedNode(
923
- highlighted: Node,
924
- previewBody: boolean,
925
- opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
926
- ): { body: Node | null; summary?: ToolRenderLine } {
927
- if (opts.verbose || !previewBody) {
928
- return { body: highlighted };
929
- }
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
+ }
930
862
 
931
- const bodyWidth = getToolBodyWidth(opts.previewWidth);
932
- const totalHeight = measureToolNodeHeight(highlighted, bodyWidth);
933
- if (totalHeight <= UI_TOOL_PREVIEW_ROWS || highlighted.type !== "vstack") {
934
- return { body: highlighted };
863
+ previewStart += 1;
935
864
  }
936
-
937
- const visibleLineCount = countVisibleTailNodes(
938
- highlighted.children,
939
- bodyWidth,
940
- UI_TOOL_PREVIEW_ROWS,
941
- );
942
- const previewLines = highlighted.children.slice(-visibleLineCount);
943
- const hiddenLineCount = Math.max(
944
- 0,
945
- highlighted.children.length - visibleLineCount,
946
- );
947
-
948
- return {
949
- body: VStack(
950
- {
951
- height: UI_TOOL_PREVIEW_ROWS,
952
- justifyContent: "end",
953
- },
954
- [...previewLines],
955
- ),
956
- summary:
957
- hiddenLineCount > 0
958
- ? {
959
- kind: "summary",
960
- text: `And ${hiddenLineCount} lines more`,
961
- }
962
- : undefined,
963
- };
964
865
  }
965
866
 
966
867
  function renderToolBody(
@@ -971,25 +872,13 @@ function renderToolBody(
971
872
  return { body: spec.bodyNode };
972
873
  }
973
874
 
974
- if (spec.highlightedBody) {
975
- const highlighted = getHighlightedBodyNode(
976
- spec.highlightedBody,
977
- opts.theme,
978
- );
979
- if (highlighted) {
980
- return renderToolBodyFromHighlightedNode(
981
- highlighted,
982
- spec.previewBody,
983
- opts,
984
- );
985
- }
875
+ if (opts.verbose || !spec.previewBody) {
876
+ return {
877
+ body: renderFullToolBody(spec, opts.theme),
878
+ };
986
879
  }
987
880
 
988
- return renderToolBodyFromNodes(
989
- renderToolLines(spec.bodyLines, opts.theme),
990
- spec.previewBody,
991
- opts,
992
- );
881
+ return renderCompactToolBody(spec, opts);
993
882
  }
994
883
 
995
884
  /** Render a tool block with a left border and compact header pill. */
@@ -1169,6 +1058,10 @@ function buildReadImageToolCallSpec(
1169
1058
  };
1170
1059
  }
1171
1060
 
1061
+ function isMcpToolName(toolName: string): boolean {
1062
+ return toolName.includes("__");
1063
+ }
1064
+
1172
1065
  function buildGenericToolCallSpec(
1173
1066
  toolName: string,
1174
1067
  args: Record<string, unknown>,
@@ -1178,7 +1071,7 @@ function buildGenericToolCallSpec(
1178
1071
  toolName,
1179
1072
  direction: "->",
1180
1073
  bodyLines: text && text !== "{}" ? splitToolTextLines(text, "text") : [],
1181
- previewBody: false,
1074
+ previewBody: isMcpToolName(toolName),
1182
1075
  };
1183
1076
  }
1184
1077
 
@@ -1219,14 +1112,16 @@ function renderToolCall(
1219
1112
 
1220
1113
  function buildShellToolResultSpec(
1221
1114
  content: ToolResultMessage["content"],
1115
+ details?: ToolResultMessage["details"],
1222
1116
  ): ToolBlockSpec {
1117
+ const shellResult = buildShellResultLines(content, details);
1223
1118
  return {
1224
1119
  toolName: "shell",
1225
1120
  direction: "<-",
1226
- bodyLines: splitToolTextLines(
1227
- normalizeShellOutput(getToolContentText(content)),
1228
- "text",
1229
- ),
1121
+ bodyLines: shellResult.bodyLines,
1122
+ ...(shellResult.footerLines
1123
+ ? { footerLines: shellResult.footerLines }
1124
+ : {}),
1230
1125
  previewBody: true,
1231
1126
  };
1232
1127
  }
@@ -1414,7 +1309,7 @@ function buildGenericToolResultSpec(
1414
1309
  toolName,
1415
1310
  direction: "<-",
1416
1311
  bodyLines: splitToolTextLines(output, isError ? "error" : "text"),
1417
- previewBody: false,
1312
+ previewBody: isMcpToolName(toolName),
1418
1313
  };
1419
1314
  }
1420
1315
 
@@ -1427,9 +1322,10 @@ function renderToolResultContent(
1427
1322
  ConversationRenderOpts,
1428
1323
  "previewWidth" | "verbose" | "theme" | "cwd"
1429
1324
  >,
1325
+ details?: ToolResultMessage["details"],
1430
1326
  ): Node {
1431
1327
  if (toolName === "shell") {
1432
- return renderToolBlock(buildShellToolResultSpec(content), opts);
1328
+ return renderToolBlock(buildShellToolResultSpec(content, details), opts);
1433
1329
  }
1434
1330
  if (toolName === "read") {
1435
1331
  return renderToolBlock(
@@ -1476,6 +1372,7 @@ function renderToolResultContent(
1476
1372
  * @param resultText - Text content from the tool result message.
1477
1373
  * @param isError - Whether the tool execution failed.
1478
1374
  * @param opts - Shared conversation render options.
1375
+ * @param details - Optional structured tool-result details.
1479
1376
  * @returns The rendered tool block node.
1480
1377
  */
1481
1378
  export function renderToolResult(
@@ -1484,12 +1381,20 @@ export function renderToolResult(
1484
1381
  resultText: string,
1485
1382
  isError: boolean,
1486
1383
  opts: ConversationRenderOpts,
1384
+ details?: ToolResultMessage["details"],
1487
1385
  ): Node {
1488
1386
  const content: ToolResultMessage["content"] = resultText
1489
1387
  ? [{ type: "text", text: resultText }]
1490
1388
  : [];
1491
1389
 
1492
- return renderToolResultContent(toolName, args, content, isError, opts);
1390
+ return renderToolResultContent(
1391
+ toolName,
1392
+ args,
1393
+ content,
1394
+ isError,
1395
+ opts,
1396
+ details,
1397
+ );
1493
1398
  }
1494
1399
 
1495
1400
  function renderUiTodoMessage(
@@ -1548,13 +1453,6 @@ function renderEmptyConversationBanner(
1548
1453
  ]);
1549
1454
  }
1550
1455
 
1551
- function pushConversationNode(nodes: Node[], node: Node | null): void {
1552
- if (!node) {
1553
- return;
1554
- }
1555
- nodes.push(node);
1556
- }
1557
-
1558
1456
  function rememberToolCallArgs(
1559
1457
  message: AssistantMessage,
1560
1458
  toolCallArgs: Map<string, ToolCallRenderInfo>,
@@ -1576,8 +1474,16 @@ function renderToolResultMessage(
1576
1474
  content: ToolResultMessage["content"],
1577
1475
  isError: boolean,
1578
1476
  renderOpts: ConversationRenderOpts,
1477
+ details?: ToolResultMessage["details"],
1579
1478
  ): Node {
1580
- return renderToolResultContent(toolName, args, content, isError, renderOpts);
1479
+ return renderToolResultContent(
1480
+ toolName,
1481
+ args,
1482
+ content,
1483
+ isError,
1484
+ renderOpts,
1485
+ details,
1486
+ );
1581
1487
  }
1582
1488
 
1583
1489
  function renderConversationMessage(
@@ -1604,6 +1510,7 @@ function renderConversationMessage(
1604
1510
  message.content,
1605
1511
  message.isError,
1606
1512
  renderOpts,
1513
+ message.details,
1607
1514
  );
1608
1515
  }
1609
1516
 
@@ -1627,60 +1534,150 @@ function cacheToolCallArgs(messages: readonly ConversationMessage[]): void {
1627
1534
  toolCallArgsCache.count = messages.length;
1628
1535
  }
1629
1536
 
1630
- function canReuseCommittedConversationCache(
1631
- state: ConversationLogState,
1632
- startIndex: number,
1537
+ function canReuseConversationMessageRender(
1538
+ cached: ConversationMessageRenderCacheEntry | undefined,
1539
+ renderOpts: ConversationRenderOpts,
1633
1540
  previewWidth: number,
1634
- ): boolean {
1541
+ ): cached is ConversationMessageRenderCacheEntry {
1635
1542
  return (
1636
- conversationRenderCache.messages === state.messages &&
1637
- conversationRenderCache.startIndex === startIndex &&
1638
- conversationRenderCache.showReasoning === state.showReasoning &&
1639
- conversationRenderCache.verbose === state.verbose &&
1640
- conversationRenderCache.previewWidth === previewWidth &&
1641
- conversationRenderCache.cwd === state.cwd &&
1642
- conversationRenderCache.theme === state.theme &&
1643
- 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
1644
1549
  );
1645
1550
  }
1646
1551
 
1647
- function cacheCommittedConversation(
1552
+ function canReuseCommittedConversationLayout(
1648
1553
  state: ConversationLogState,
1649
1554
  renderOpts: ConversationRenderOpts,
1650
- 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,
1651
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 {
1652
1608
  const previewWidth = getPreviewWidth(renderOpts.previewWidth);
1653
- 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
+ }
1654
1619
 
1655
- if (!canReuseCommittedConversationCache(state, startIndex, previewWidth)) {
1656
- conversationRenderCache.messages = state.messages;
1657
- conversationRenderCache.startIndex = startIndex;
1658
- conversationRenderCache.count = startIndex;
1659
- conversationRenderCache.showReasoning = state.showReasoning;
1660
- conversationRenderCache.verbose = state.verbose;
1661
- conversationRenderCache.previewWidth = previewWidth;
1662
- conversationRenderCache.cwd = state.cwd;
1663
- conversationRenderCache.theme = state.theme;
1664
- conversationRenderCache.nodes = [];
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
+ }
1646
+
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];
1665
1662
  }
1666
1663
 
1667
1664
  for (
1668
- let index = conversationRenderCache.count;
1665
+ let index = conversationLayoutCache.count;
1669
1666
  index < state.messages.length;
1670
1667
  index++
1671
1668
  ) {
1672
- pushConversationNode(
1673
- conversationRenderCache.nodes,
1674
- renderConversationMessage(
1675
- state.messages[index]!,
1676
- renderOpts,
1677
- toolCallArgsCache.entries,
1678
- state.theme,
1679
- ),
1669
+ appendConversationLogItem(
1670
+ conversationLayoutCache.items,
1671
+ conversationLayoutCache.prefixHeights,
1672
+ getCommittedConversationLogItem(state.messages[index]!, renderOpts),
1680
1673
  );
1681
1674
  }
1682
1675
 
1683
- conversationRenderCache.count = state.messages.length;
1676
+ conversationLayoutCache.count = state.messages.length;
1677
+ return {
1678
+ items: conversationLayoutCache.items,
1679
+ prefixHeights: conversationLayoutCache.prefixHeights,
1680
+ };
1684
1681
  }
1685
1682
 
1686
1683
  function hasStreamingTail(streaming: StreamingConversationState): boolean {
@@ -1691,21 +1688,78 @@ function hasStreamingTail(streaming: StreamingConversationState): boolean {
1691
1688
  );
1692
1689
  }
1693
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
+
1694
1750
  /**
1695
- * Build the full conversation log as an array of nodes.
1751
+ * Build rendered conversation items plus cached cumulative intrinsic heights.
1696
1752
  *
1697
1753
  * @param state - Conversation rendering state.
1698
1754
  * @param streaming - Current in-progress assistant tail, if any.
1699
- * @param startIndex - Index of the first committed message to render.
1700
1755
  * @param previewWidth - Available terminal width for width-aware tool previews.
1701
- * @returns The rendered conversation log nodes.
1756
+ * @returns Rendered items plus cumulative heights in on-screen order.
1702
1757
  */
1703
- export function buildConversationLogNodes(
1758
+ export function buildConversationLayoutSnapshot(
1704
1759
  state: ConversationLogState,
1705
1760
  streaming: StreamingConversationState,
1706
- startIndex = 0,
1707
1761
  previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH,
1708
- ): Node[] {
1762
+ ): ConversationLayoutSnapshot {
1709
1763
  const renderOpts: ConversationRenderOpts = {
1710
1764
  showReasoning: state.showReasoning,
1711
1765
  verbose: state.verbose,
@@ -1714,45 +1768,69 @@ export function buildConversationLogNodes(
1714
1768
  previewWidth,
1715
1769
  };
1716
1770
 
1717
- if (state.messages.length === 0 && !hasStreamingTail(streaming)) {
1718
- return [
1719
- renderEmptyConversationBanner(
1720
- state.theme,
1721
- state.versionLabel ?? DEV_VERSION_LABEL,
1722
- ),
1723
- ];
1724
- }
1725
-
1726
- cacheCommittedConversation(state, renderOpts, startIndex);
1771
+ cacheToolCallArgs(state.messages);
1727
1772
 
1728
- if (!hasStreamingTail(streaming)) {
1729
- 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);
1730
1783
  }
1731
1784
 
1732
- const nodes = [...conversationRenderCache.nodes];
1733
- pushConversationNode(
1734
- nodes,
1735
- renderAssistantMessage(
1736
- {
1737
- content: streaming.content,
1738
- },
1739
- renderOpts,
1740
- ),
1785
+ const items = [...committedSnapshot.items];
1786
+ const prefixHeights = [...committedSnapshot.prefixHeights];
1787
+ appendStreamingConversationLogItems(
1788
+ items,
1789
+ prefixHeights,
1790
+ streaming,
1791
+ renderOpts,
1792
+ previewWidth,
1741
1793
  );
1794
+ return {
1795
+ items,
1796
+ prefixHeights,
1797
+ };
1798
+ }
1742
1799
 
1743
- for (const pendingToolResult of streaming.pendingToolResults) {
1744
- const info = toolCallArgsCache.entries.get(pendingToolResult.toolCallId);
1745
- pushConversationNode(
1746
- nodes,
1747
- renderToolResultMessage(
1748
- info?.name ?? pendingToolResult.toolName,
1749
- info?.args ?? EMPTY_TOOL_RESULT_ARGS,
1750
- pendingToolResult.content,
1751
- pendingToolResult.isError,
1752
- renderOpts,
1753
- ),
1754
- );
1755
- }
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
+ }
1756
1815
 
1757
- 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);
1758
1836
  }