mini-coder 0.5.10 → 0.5.12

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,6 +8,15 @@
8
8
  * @module
9
9
  */
10
10
 
11
+ import { homedir } from "node:os";
12
+ import {
13
+ basename,
14
+ extname,
15
+ isAbsolute,
16
+ join,
17
+ normalize,
18
+ relative,
19
+ } from "node:path";
11
20
  import {
12
21
  SyntaxHighlight,
13
22
  type SyntaxHighlightTheme,
@@ -22,8 +31,15 @@ import type {
22
31
  } from "@mariozechner/pi-ai";
23
32
  import type { AppState } from "../index.ts";
24
33
  import type { UiMessage } from "../session.ts";
34
+ import { readBoolean, readFiniteNumber, readString } from "../shared.ts";
25
35
  import type { Theme } from "../theme.ts";
26
- import { parseTodoSnapshot, type TodoItem } from "../tools.ts";
36
+ import {
37
+ parseGrepResult,
38
+ parseReadContinuationHint,
39
+ parseReadResult,
40
+ parseTodoSnapshot,
41
+ type TodoItem,
42
+ } from "../tools.ts";
27
43
  import { APP_NAME, DEV_VERSION_LABEL } from "../version.ts";
28
44
 
29
45
  /** Single blank-line gap used between conversation-level blocks. */
@@ -35,9 +51,6 @@ const UI_TOOL_PREVIEW_ROWS = 8;
35
51
  /** Default width used when preview measurements do not receive one explicitly. */
36
52
  const DEFAULT_TOOL_PREVIEW_WIDTH = 80;
37
53
 
38
- /** Horizontal columns consumed by assistant-markdown padding. */
39
- const MARKDOWN_BLOCK_CHROME_WIDTH = 2;
40
-
41
54
  /** Horizontal columns consumed by tool-block padding and the left border. */
42
55
  const TOOL_BLOCK_CHROME_WIDTH = 4;
43
56
 
@@ -81,6 +94,8 @@ interface ConversationRenderOpts {
81
94
  verbose: boolean;
82
95
  /** Active UI theme. */
83
96
  theme: Theme;
97
+ /** Session working directory used for path display. */
98
+ cwd?: string;
84
99
  /** Available terminal width for width-aware tool previews. */
85
100
  previewWidth?: number;
86
101
  }
@@ -107,7 +122,7 @@ type ConversationMessage = AppState["messages"][number];
107
122
 
108
123
  type ConversationLogState = Pick<
109
124
  AppState,
110
- "messages" | "showReasoning" | "verbose" | "theme"
125
+ "messages" | "showReasoning" | "verbose" | "theme" | "cwd"
111
126
  > & {
112
127
  versionLabel?: AppState["versionLabel"];
113
128
  };
@@ -130,6 +145,7 @@ interface ConversationRenderCache {
130
145
  showReasoning: boolean;
131
146
  verbose: boolean;
132
147
  previewWidth: number;
148
+ cwd: string | null;
133
149
  theme: Theme | null;
134
150
  nodes: Node[];
135
151
  }
@@ -140,12 +156,13 @@ type ToolRenderLineKind =
140
156
  | "command"
141
157
  | "path"
142
158
  | "text"
159
+ | "context"
143
160
  | "diffAdded"
144
161
  | "diffRemoved"
145
162
  | "summary"
146
163
  | "error";
147
164
 
148
- type SyntaxThemeVariant = "markdown" | "shell";
165
+ type SyntaxThemeVariant = "markdown" | "code" | "shell";
149
166
 
150
167
  interface HighlightedBodySpec {
151
168
  /** Full raw source content to syntax-highlight. */
@@ -161,12 +178,16 @@ interface ToolBlockSpec {
161
178
  toolName: string;
162
179
  /** Direction shown in the header pill. */
163
180
  direction: ToolRenderDirection;
181
+ /** Optional compact text appended after the header pill. */
182
+ headerSuffix?: string;
164
183
  /** Logical body lines for the tool block. */
165
184
  bodyLines: readonly ToolRenderLine[];
166
185
  /** Optional syntax-highlighted body rendered from the full unsplit source. */
167
186
  highlightedBody?: HighlightedBodySpec;
168
187
  /** Optional custom body node rendered as-is. */
169
188
  bodyNode?: Node;
189
+ /** Optional footer lines rendered after the body. */
190
+ footerLines?: readonly ToolRenderLine[];
170
191
  /** Whether `/verbose` preview rules apply to the body. */
171
192
  previewBody: boolean;
172
193
  }
@@ -197,6 +218,7 @@ const conversationRenderCache: ConversationRenderCache = {
197
218
  showReasoning: false,
198
219
  verbose: false,
199
220
  previewWidth: DEFAULT_TOOL_PREVIEW_WIDTH,
221
+ cwd: null,
200
222
  theme: null,
201
223
  nodes: EMPTY_RENDER_NODES,
202
224
  };
@@ -212,6 +234,7 @@ export function resetConversationRenderCache(): void {
212
234
  conversationRenderCache.showReasoning = false;
213
235
  conversationRenderCache.verbose = false;
214
236
  conversationRenderCache.previewWidth = DEFAULT_TOOL_PREVIEW_WIDTH;
237
+ conversationRenderCache.cwd = null;
215
238
  conversationRenderCache.theme = null;
216
239
  conversationRenderCache.nodes = EMPTY_RENDER_NODES;
217
240
  }
@@ -232,29 +255,24 @@ function renderUserMessage(msg: UserMessage, theme: Theme): Node {
232
255
  }
233
256
 
234
257
  /** Render a syntax-highlighted raw markdown block. */
235
- function renderMarkdownTextBlock(
236
- content: string,
237
- theme: Theme,
238
- previewWidth?: number,
239
- ): Node | null {
258
+ function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
240
259
  if (content === "") {
241
260
  return null;
242
261
  }
243
262
 
244
- const children = getHighlightedBodyLines(
263
+ const highlighted = getHighlightedBodyNode(
245
264
  {
246
265
  text: content,
247
266
  language: "markdown",
248
267
  themeVariant: "markdown",
249
268
  },
250
269
  theme,
251
- getMarkdownBodyWidth(previewWidth),
252
270
  );
253
- if (children.length === 0) {
271
+ if (!highlighted) {
254
272
  return null;
255
273
  }
256
274
 
257
- return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, children)]);
275
+ return HStack({ padding: { x: 1 } }, [VStack({ flex: 1 }, [highlighted])]);
258
276
  }
259
277
 
260
278
  function getAssistantErrorMessage(
@@ -324,7 +342,7 @@ function renderAssistantContentBlock(
324
342
  opts: ConversationRenderOpts,
325
343
  ): Node | null {
326
344
  if (block.type === "text" && block.text) {
327
- return renderMarkdownTextBlock(block.text, opts.theme, opts.previewWidth);
345
+ return renderMarkdownTextBlock(block.text, opts.theme);
328
346
  }
329
347
  if (block.type === "thinking" && block.thinking) {
330
348
  return renderThinkingBlock(block.thinking, opts);
@@ -377,21 +395,10 @@ function getPreviewWidth(previewWidth?: number): number {
377
395
  return Math.max(1, Math.floor(previewWidth!));
378
396
  }
379
397
 
380
- function getMarkdownBodyWidth(previewWidth?: number): number {
381
- return Math.max(
382
- 1,
383
- getPreviewWidth(previewWidth) - MARKDOWN_BLOCK_CHROME_WIDTH,
384
- );
385
- }
386
-
387
398
  function getToolBodyWidth(previewWidth?: number): number {
388
399
  return Math.max(1, getPreviewWidth(previewWidth) - TOOL_BLOCK_CHROME_WIDTH);
389
400
  }
390
401
 
391
- function getHighlightWrapChunkSize(bodyWidth: number): number {
392
- return Math.max(1, Math.min(HIGHLIGHT_WRAP_MAX_CHUNK_GRAPHEMES, bodyWidth));
393
- }
394
-
395
402
  /** Split multi-line tool text into logical render lines. */
396
403
  function splitToolTextLines(
397
404
  text: string,
@@ -409,10 +416,133 @@ function splitToolTextLines(
409
416
  return lines.map((line) => ({ kind, text: line }));
410
417
  }
411
418
 
419
+ function normalizeDisplayLineEndings(text: string): string {
420
+ return text.replace(/\r\n/g, "\n");
421
+ }
422
+
412
423
  /** Read a string argument from a tool-call argument map. */
413
424
  function getToolArgString(args: Record<string, unknown>, key: string): string {
414
- const value = args[key];
415
- return typeof value === "string" ? value : "";
425
+ return readString(args, key) ?? "";
426
+ }
427
+
428
+ const HOME_DIR = homedir();
429
+
430
+ function resolveToolPath(path: string, cwd?: string): string {
431
+ if (path === "") {
432
+ return "";
433
+ }
434
+ if (!cwd || isAbsolute(path)) {
435
+ return path;
436
+ }
437
+ return join(cwd, path);
438
+ }
439
+
440
+ function abbreviateHomePath(path: string): string {
441
+ if (path === HOME_DIR) {
442
+ return "~";
443
+ }
444
+ if (path.startsWith(`${HOME_DIR}/`)) {
445
+ return `~/${path.slice(HOME_DIR.length + 1)}`;
446
+ }
447
+ return path;
448
+ }
449
+
450
+ function normalizeDisplayPath(path: string): string {
451
+ if (path === "") {
452
+ return path;
453
+ }
454
+ return normalize(path).replace(/\\/g, "/");
455
+ }
456
+
457
+ function formatResolvedToolPath(path: string, cwd?: string): string {
458
+ const resolved = resolveToolPath(path, cwd);
459
+ if (resolved === "") {
460
+ return path;
461
+ }
462
+ return abbreviateHomePath(normalizeDisplayPath(resolved));
463
+ }
464
+
465
+ function formatRelativeToolPath(path: string, cwd?: string): string {
466
+ const resolved = resolveToolPath(path, cwd);
467
+ if (resolved === "" || !cwd || !isAbsolute(resolved)) {
468
+ return normalizeDisplayPath(path);
469
+ }
470
+
471
+ const relativePath = relative(cwd, resolved);
472
+ if (relativePath === "") {
473
+ return ".";
474
+ }
475
+ if (relativePath.startsWith("..")) {
476
+ return formatResolvedToolPath(resolved);
477
+ }
478
+ return normalizeDisplayPath(relativePath);
479
+ }
480
+
481
+ function getReadLanguageCandidates(path: string): string[] {
482
+ const fileName = basename(path).toLowerCase();
483
+ const extension = extname(fileName).toLowerCase();
484
+
485
+ switch (extension) {
486
+ case ".ts":
487
+ return ["typescript"];
488
+ case ".tsx":
489
+ return ["tsx", "typescript"];
490
+ case ".js":
491
+ case ".mjs":
492
+ case ".cjs":
493
+ return ["javascript"];
494
+ case ".jsx":
495
+ return ["jsx", "javascript"];
496
+ case ".json":
497
+ return ["json"];
498
+ case ".md":
499
+ return ["markdown"];
500
+ case ".yml":
501
+ case ".yaml":
502
+ return ["yaml"];
503
+ case ".toml":
504
+ return ["toml"];
505
+ case ".sh":
506
+ case ".bash":
507
+ return ["bash"];
508
+ case ".css":
509
+ return ["css"];
510
+ case ".html":
511
+ case ".htm":
512
+ return ["html"];
513
+ case ".xml":
514
+ return ["xml"];
515
+ case ".py":
516
+ return ["python"];
517
+ case ".rs":
518
+ return ["rust"];
519
+ case ".go":
520
+ return ["go"];
521
+ case ".java":
522
+ return ["java"];
523
+ case ".c":
524
+ return ["c"];
525
+ case ".h":
526
+ return ["c"];
527
+ case ".cpp":
528
+ case ".cc":
529
+ case ".cxx":
530
+ case ".hpp":
531
+ return ["cpp"];
532
+ default:
533
+ break;
534
+ }
535
+
536
+ if (fileName === "dockerfile") {
537
+ return ["dockerfile"];
538
+ }
539
+ if (fileName === "makefile") {
540
+ return ["makefile"];
541
+ }
542
+ if (fileName === "agents.md" || fileName === "readme.md") {
543
+ return ["markdown"];
544
+ }
545
+ return [];
416
546
  }
417
547
 
418
548
  function formatTodoWriteCallSummary(args: Record<string, unknown>): string {
@@ -436,6 +566,8 @@ function getToolHeaderName(toolName: string): string {
436
566
  function getToolHeaderColor(toolName: string, theme: Theme) {
437
567
  switch (toolName) {
438
568
  case "shell":
569
+ case "read":
570
+ case "grep":
439
571
  case "readImage":
440
572
  case "todoWrite":
441
573
  case "todoRead":
@@ -452,15 +584,12 @@ type SyntaxThemeTokenColor = NonNullable<
452
584
  SyntaxThemeRegistration["tokenColors"]
453
585
  >[number];
454
586
 
455
- const graphemeSegmenter = new Intl.Segmenter(undefined, {
456
- granularity: "grapheme",
457
- });
458
- const HIGHLIGHT_WRAP_MAX_CHUNK_GRAPHEMES = 32;
459
587
  const syntaxThemeCache: Record<
460
588
  SyntaxThemeVariant,
461
589
  WeakMap<Theme, SyntaxThemeRegistration>
462
590
  > = {
463
591
  markdown: new WeakMap(),
592
+ code: new WeakMap(),
464
593
  shell: new WeakMap(),
465
594
  };
466
595
 
@@ -492,55 +621,49 @@ function pushShellSyntaxTokenColors(
492
621
  tokenColors: SyntaxThemeTokenColor[],
493
622
  theme: Theme,
494
623
  ): void {
495
- pushSyntaxTokenColor(tokenColors, "comment", theme.mutedText);
496
624
  pushSyntaxTokenColor(
497
625
  tokenColors,
498
- ["keyword", "storage"],
499
- theme.secondaryAccentText,
626
+ ["comment", "quote", "doctag"],
627
+ theme.mutedText,
628
+ "italic",
500
629
  );
501
630
  pushSyntaxTokenColor(
502
631
  tokenColors,
503
- [
504
- "entity.name.function",
505
- "function",
506
- "meta.function-call",
507
- "support.function",
508
- "title",
509
- ],
510
- theme.accentText,
632
+ ["keyword", "operator"],
633
+ theme.secondaryAccentText,
511
634
  );
512
635
  pushSyntaxTokenColor(
513
636
  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
- ],
637
+ ["function_", "function", "title"],
523
638
  theme.accentText,
524
639
  );
525
640
  pushSyntaxTokenColor(
526
641
  tokenColors,
527
- ["constant.language", "constant.numeric", "literal", "symbol"],
528
- theme.secondaryAccentText ?? theme.accentText,
642
+ ["built_in", "class_", "class", "inherited__", "type"],
643
+ theme.accentText,
529
644
  );
530
645
  pushSyntaxTokenColor(
531
646
  tokenColors,
532
- ["markup.inline", "string"],
533
- theme.diffAdded,
647
+ ["escape", "literal", "number", "symbol"],
648
+ theme.secondaryAccentText ?? theme.accentText,
534
649
  );
650
+ pushSyntaxTokenColor(tokenColors, ["code", "string"], theme.diffAdded);
535
651
  pushSyntaxTokenColor(tokenColors, "regexp", theme.diffRemoved);
536
652
  pushSyntaxTokenColor(
537
653
  tokenColors,
538
- ["attr", "attribute", "entity.other.attribute-name", "property"],
654
+ ["attr", "attribute", "params", "property", "selector-attr"],
539
655
  theme.accentText,
540
656
  );
541
657
  pushSyntaxTokenColor(
542
658
  tokenColors,
543
- ["entity.name.tag", "name", "tag"],
659
+ [
660
+ "name",
661
+ "tag",
662
+ "selector-class",
663
+ "selector-id",
664
+ "selector-pseudo",
665
+ "selector-tag",
666
+ ],
544
667
  theme.accentText,
545
668
  );
546
669
  }
@@ -574,10 +697,10 @@ function getSyntaxTheme(
574
697
  }
575
698
 
576
699
  const tokenColors: SyntaxThemeTokenColor[] = [];
577
- if (variant === "shell") {
578
- pushShellSyntaxTokenColors(tokenColors, theme);
579
- } else {
700
+ if (variant === "markdown") {
580
701
  pushMarkdownSyntaxTokenColors(tokenColors, theme);
702
+ } else {
703
+ pushShellSyntaxTokenColors(tokenColors, theme);
581
704
  }
582
705
 
583
706
  const syntaxTheme: SyntaxThemeRegistration = {
@@ -587,76 +710,21 @@ function getSyntaxTheme(
587
710
  return syntaxTheme;
588
711
  }
589
712
 
590
- function splitHighlightedTextNode(
591
- node: Extract<Node, { type: "text" }>,
592
- chunkSize: number,
593
- ): Node[] {
594
- if (node.content === "") {
595
- return [Text("", node.props)];
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));
625
- }
626
- }
627
-
628
- return children;
629
- }
630
-
631
- function normalizeHighlightedLine(line: Node, bodyWidth: number): Node {
632
- if (line.type !== "hstack") {
633
- return line;
634
- }
635
-
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);
643
- }
644
-
645
- function getHighlightedBodyLines(
713
+ function getHighlightedBodyNode(
646
714
  spec: HighlightedBodySpec,
647
715
  theme: Theme,
648
- bodyWidth: number,
649
- ): Node[] {
716
+ ): Node | null {
650
717
  if (spec.text === "") {
651
- return [];
718
+ return null;
652
719
  }
653
720
 
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
- );
721
+ try {
722
+ return SyntaxHighlight(spec.text, spec.language, {
723
+ theme: getSyntaxTheme(theme, spec.themeVariant),
724
+ });
725
+ } catch {
726
+ return null;
727
+ }
660
728
  }
661
729
 
662
730
  /** Render a single styled text node for a tool line. */
@@ -673,6 +741,11 @@ function renderToolLine(line: ToolRenderLine, theme: Theme): Node {
673
741
  return Text(line.text, { fgColor: theme.diffAdded, wrap: "word" });
674
742
  case "diffRemoved":
675
743
  return Text(line.text, { fgColor: theme.diffRemoved, wrap: "word" });
744
+ case "context":
745
+ return Text(line.text, {
746
+ fgColor: theme.mutedText,
747
+ wrap: "word",
748
+ });
676
749
  case "summary":
677
750
  return Text(line.text, {
678
751
  fgColor: theme.toolText,
@@ -682,7 +755,10 @@ function renderToolLine(line: ToolRenderLine, theme: Theme): Node {
682
755
  case "error":
683
756
  return Text(line.text, { fgColor: theme.error, wrap: "word" });
684
757
  case "text":
685
- return Text(line.text, { wrap: "word" });
758
+ return Text(line.text, {
759
+ fgColor: theme.toolText,
760
+ wrap: "word",
761
+ });
686
762
  }
687
763
  }
688
764
 
@@ -781,6 +857,23 @@ function renderToolHeaderPill(
781
857
  ]);
782
858
  }
783
859
 
860
+ function renderToolHeaderRow(spec: ToolBlockSpec, theme: Theme): Node {
861
+ const children: Node[] = [
862
+ renderToolHeaderPill(spec.toolName, spec.direction, theme),
863
+ ];
864
+
865
+ if (spec.headerSuffix) {
866
+ children.push(
867
+ Text(` ${spec.headerSuffix}`, {
868
+ fgColor: theme.toolText,
869
+ wrap: "word",
870
+ }),
871
+ );
872
+ }
873
+
874
+ return HStack({}, children);
875
+ }
876
+
784
877
  function renderToolBodyFromNodes(
785
878
  lines: readonly Node[],
786
879
  previewBody: boolean,
@@ -826,6 +919,50 @@ function renderToolBodyFromNodes(
826
919
  };
827
920
  }
828
921
 
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
+ }
930
+
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 };
935
+ }
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
+ }
965
+
829
966
  function renderToolBody(
830
967
  spec: ToolBlockSpec,
831
968
  opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
@@ -835,15 +972,17 @@ function renderToolBody(
835
972
  }
836
973
 
837
974
  if (spec.highlightedBody) {
838
- return renderToolBodyFromNodes(
839
- getHighlightedBodyLines(
840
- spec.highlightedBody,
841
- opts.theme,
842
- getToolBodyWidth(opts.previewWidth),
843
- ),
844
- spec.previewBody,
845
- opts,
975
+ const highlighted = getHighlightedBodyNode(
976
+ spec.highlightedBody,
977
+ opts.theme,
846
978
  );
979
+ if (highlighted) {
980
+ return renderToolBodyFromHighlightedNode(
981
+ highlighted,
982
+ spec.previewBody,
983
+ opts,
984
+ );
985
+ }
847
986
  }
848
987
 
849
988
  return renderToolBodyFromNodes(
@@ -859,11 +998,7 @@ function renderToolBlock(
859
998
  opts: Pick<ConversationRenderOpts, "previewWidth" | "theme" | "verbose">,
860
999
  ): Node {
861
1000
  const body = renderToolBody(spec, opts);
862
- const children: Node[] = [
863
- HStack({}, [
864
- renderToolHeaderPill(spec.toolName, spec.direction, opts.theme),
865
- ]),
866
- ];
1001
+ const children: Node[] = [renderToolHeaderRow(spec, opts.theme)];
867
1002
 
868
1003
  if (body.body) {
869
1004
  children.push(body.body);
@@ -871,18 +1006,41 @@ function renderToolBlock(
871
1006
  if (body.summary) {
872
1007
  children.push(renderToolLine(body.summary, opts.theme));
873
1008
  }
1009
+ if (spec.footerLines) {
1010
+ children.push(...renderToolLines(spec.footerLines, opts.theme));
1011
+ }
874
1012
 
875
1013
  return HStack({ padding: { x: 1 } }, [
876
1014
  Text("│ ", { fgColor: opts.theme.toolBorder }),
877
- VStack({ flex: 1, fgColor: opts.theme.toolText }, children),
1015
+ VStack({ flex: 1 }, children),
878
1016
  ]);
879
1017
  }
880
1018
 
881
- function getToolContentText(content: ToolResultMessage["content"]): string {
1019
+ function getToolTextBlocks(content: ToolResultMessage["content"]): string[] {
882
1020
  return content
883
1021
  .filter((entry): entry is TextContent => entry.type === "text")
884
- .map((entry) => entry.text)
885
- .join("\n");
1022
+ .map((entry) => entry.text);
1023
+ }
1024
+
1025
+ function getToolContentText(content: ToolResultMessage["content"]): string {
1026
+ return getToolTextBlocks(content).join("\n");
1027
+ }
1028
+
1029
+ function parseReadToolContent(
1030
+ content: ToolResultMessage["content"],
1031
+ ): ReturnType<typeof parseReadResult> {
1032
+ const textBlocks = getToolTextBlocks(content);
1033
+ if (textBlocks.length > 1) {
1034
+ const continuation = parseReadContinuationHint(textBlocks.at(-1) ?? "");
1035
+ if (continuation) {
1036
+ return {
1037
+ body: textBlocks.slice(0, -1).join("\n"),
1038
+ continuation,
1039
+ };
1040
+ }
1041
+ }
1042
+
1043
+ return parseReadResult(textBlocks.join("\n"));
886
1044
  }
887
1045
 
888
1046
  function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
@@ -903,6 +1061,70 @@ function buildShellToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
903
1061
  };
904
1062
  }
905
1063
 
1064
+ function buildReadToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
1065
+ const lines: ToolRenderLine[] = [];
1066
+ const filePath = getToolArgString(args, "path");
1067
+ const offset = readFiniteNumber(args, "offset");
1068
+ const limit = readFiniteNumber(args, "limit");
1069
+
1070
+ if (filePath) {
1071
+ lines.push({ kind: "path", text: filePath });
1072
+ }
1073
+ if (offset !== null) {
1074
+ lines.push({ kind: "text", text: `offset: ${offset}` });
1075
+ }
1076
+ if (limit !== null) {
1077
+ lines.push({ kind: "text", text: `limit: ${limit}` });
1078
+ }
1079
+
1080
+ return {
1081
+ toolName: "read",
1082
+ direction: "->",
1083
+ bodyLines: lines,
1084
+ previewBody: true,
1085
+ };
1086
+ }
1087
+
1088
+ function buildGrepToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
1089
+ const lines: ToolRenderLine[] = [];
1090
+ const pattern = getToolArgString(args, "pattern");
1091
+ const path = getToolArgString(args, "path");
1092
+ const glob = getToolArgString(args, "glob");
1093
+ const context = readFiniteNumber(args, "context");
1094
+ const limit = readFiniteNumber(args, "limit");
1095
+ const ignoreCase = readBoolean(args, "ignoreCase");
1096
+ const literal = readBoolean(args, "literal");
1097
+
1098
+ if (pattern) {
1099
+ lines.push({ kind: "command", text: pattern });
1100
+ }
1101
+ if (path) {
1102
+ lines.push({ kind: "text", text: `path: ${path}` });
1103
+ }
1104
+ if (glob) {
1105
+ lines.push({ kind: "text", text: `glob: ${glob}` });
1106
+ }
1107
+ if (ignoreCase) {
1108
+ lines.push({ kind: "text", text: "ignoreCase: true" });
1109
+ }
1110
+ if (literal) {
1111
+ lines.push({ kind: "text", text: "literal: true" });
1112
+ }
1113
+ if (context !== null) {
1114
+ lines.push({ kind: "text", text: `context: ${context}` });
1115
+ }
1116
+ if (limit !== null) {
1117
+ lines.push({ kind: "text", text: `limit: ${limit}` });
1118
+ }
1119
+
1120
+ return {
1121
+ toolName: "grep",
1122
+ direction: "->",
1123
+ bodyLines: lines,
1124
+ previewBody: true,
1125
+ };
1126
+ }
1127
+
906
1128
  function buildEditToolCallSpec(args: Record<string, unknown>): ToolBlockSpec {
907
1129
  const lines: ToolRenderLine[] = [];
908
1130
  const filePath = getToolArgString(args, "path");
@@ -967,6 +1189,12 @@ function buildToolCallSpec(
967
1189
  if (toolName === "shell") {
968
1190
  return buildShellToolCallSpec(args);
969
1191
  }
1192
+ if (toolName === "read") {
1193
+ return buildReadToolCallSpec(args);
1194
+ }
1195
+ if (toolName === "grep") {
1196
+ return buildGrepToolCallSpec(args);
1197
+ }
970
1198
  if (toolName === "edit") {
971
1199
  return buildEditToolCallSpec(args);
972
1200
  }
@@ -1003,6 +1231,109 @@ function buildShellToolResultSpec(
1003
1231
  };
1004
1232
  }
1005
1233
 
1234
+ function buildReadToolResultSpec(
1235
+ args: Record<string, unknown>,
1236
+ content: ToolResultMessage["content"],
1237
+ isError: boolean,
1238
+ cwd?: string,
1239
+ ): ToolBlockSpec {
1240
+ const filePath = getToolArgString(args, "path");
1241
+ const resolvedPath = formatResolvedToolPath(filePath, cwd);
1242
+ const resultText = getToolContentText(content);
1243
+
1244
+ if (isError) {
1245
+ return {
1246
+ toolName: "read",
1247
+ direction: "<-",
1248
+ ...(resolvedPath ? { headerSuffix: resolvedPath } : {}),
1249
+ bodyLines: splitToolTextLines(
1250
+ normalizeDisplayLineEndings(resultText),
1251
+ "error",
1252
+ ),
1253
+ previewBody: true,
1254
+ };
1255
+ }
1256
+
1257
+ const parsed = parseReadToolContent(content);
1258
+ const bodyText = parsed.body;
1259
+ const displayBodyText = normalizeDisplayLineEndings(bodyText);
1260
+ const language = getReadLanguageCandidates(resolveToolPath(filePath, cwd))[0];
1261
+ return {
1262
+ toolName: "read",
1263
+ direction: "<-",
1264
+ ...(resolvedPath ? { headerSuffix: resolvedPath } : {}),
1265
+ bodyLines:
1266
+ displayBodyText === ""
1267
+ ? [{ kind: "summary", text: "Empty file." }]
1268
+ : splitToolTextLines(displayBodyText, "text"),
1269
+ ...(bodyText !== "" && language
1270
+ ? {
1271
+ highlightedBody: {
1272
+ text: bodyText,
1273
+ language,
1274
+ themeVariant: "code" as const,
1275
+ },
1276
+ }
1277
+ : {}),
1278
+ previewBody: true,
1279
+ };
1280
+ }
1281
+
1282
+ function buildGrepToolResultSpec(
1283
+ content: ToolResultMessage["content"],
1284
+ isError: boolean,
1285
+ cwd?: string,
1286
+ ): ToolBlockSpec {
1287
+ const resultText = getToolContentText(content);
1288
+ if (isError) {
1289
+ return {
1290
+ toolName: "grep",
1291
+ direction: "<-",
1292
+ bodyLines: splitToolTextLines(resultText, "error"),
1293
+ previewBody: true,
1294
+ };
1295
+ }
1296
+
1297
+ const result = parseGrepResult(resultText);
1298
+ if (!result) {
1299
+ return {
1300
+ toolName: "grep",
1301
+ direction: "<-",
1302
+ bodyLines: splitToolTextLines(resultText, "text"),
1303
+ previewBody: true,
1304
+ };
1305
+ }
1306
+
1307
+ const lines: ToolRenderLine[] = [];
1308
+ for (const file of result.files) {
1309
+ lines.push({
1310
+ kind: "path",
1311
+ text: formatRelativeToolPath(file.path, cwd),
1312
+ });
1313
+ for (const line of file.lines) {
1314
+ const text = line.text.replace(/\r?\n$/, "");
1315
+ lines.push({
1316
+ kind: line.kind === "context" ? "context" : "text",
1317
+ text: ` ${line.lineNumber}: ${text}`,
1318
+ });
1319
+ }
1320
+ }
1321
+
1322
+ if (lines.length === 0) {
1323
+ lines.push({ kind: "summary", text: "No matches found." });
1324
+ }
1325
+
1326
+ return {
1327
+ toolName: "grep",
1328
+ direction: "<-",
1329
+ bodyLines: lines,
1330
+ ...(result.hint
1331
+ ? { footerLines: [{ kind: "summary", text: result.hint }] }
1332
+ : {}),
1333
+ previewBody: true,
1334
+ };
1335
+ }
1336
+
1006
1337
  function buildEditToolResultSpec(
1007
1338
  args: Record<string, unknown>,
1008
1339
  isError: boolean,
@@ -1092,11 +1423,26 @@ function renderToolResultContent(
1092
1423
  args: Record<string, unknown>,
1093
1424
  content: ToolResultMessage["content"],
1094
1425
  isError: boolean,
1095
- opts: Pick<ConversationRenderOpts, "previewWidth" | "verbose" | "theme">,
1426
+ opts: Pick<
1427
+ ConversationRenderOpts,
1428
+ "previewWidth" | "verbose" | "theme" | "cwd"
1429
+ >,
1096
1430
  ): Node {
1097
1431
  if (toolName === "shell") {
1098
1432
  return renderToolBlock(buildShellToolResultSpec(content), opts);
1099
1433
  }
1434
+ if (toolName === "read") {
1435
+ return renderToolBlock(
1436
+ buildReadToolResultSpec(args, content, isError, opts.cwd),
1437
+ opts,
1438
+ );
1439
+ }
1440
+ if (toolName === "grep") {
1441
+ return renderToolBlock(
1442
+ buildGrepToolResultSpec(content, isError, opts.cwd),
1443
+ opts,
1444
+ );
1445
+ }
1100
1446
  if (toolName === "edit") {
1101
1447
  return renderToolBlock(
1102
1448
  buildEditToolResultSpec(args, isError, getToolContentText(content)),
@@ -1170,11 +1516,7 @@ function renderUiMessage(
1170
1516
  }
1171
1517
 
1172
1518
  if (msg.format === "markdown") {
1173
- const markdown = renderMarkdownTextBlock(
1174
- msg.content,
1175
- opts.theme,
1176
- opts.previewWidth,
1177
- );
1519
+ const markdown = renderMarkdownTextBlock(msg.content, opts.theme);
1178
1520
  if (markdown) {
1179
1521
  return markdown;
1180
1522
  }
@@ -1296,6 +1638,7 @@ function canReuseCommittedConversationCache(
1296
1638
  conversationRenderCache.showReasoning === state.showReasoning &&
1297
1639
  conversationRenderCache.verbose === state.verbose &&
1298
1640
  conversationRenderCache.previewWidth === previewWidth &&
1641
+ conversationRenderCache.cwd === state.cwd &&
1299
1642
  conversationRenderCache.theme === state.theme &&
1300
1643
  conversationRenderCache.count <= state.messages.length
1301
1644
  );
@@ -1316,6 +1659,7 @@ function cacheCommittedConversation(
1316
1659
  conversationRenderCache.showReasoning = state.showReasoning;
1317
1660
  conversationRenderCache.verbose = state.verbose;
1318
1661
  conversationRenderCache.previewWidth = previewWidth;
1662
+ conversationRenderCache.cwd = state.cwd;
1319
1663
  conversationRenderCache.theme = state.theme;
1320
1664
  conversationRenderCache.nodes = [];
1321
1665
  }
@@ -1366,6 +1710,7 @@ export function buildConversationLogNodes(
1366
1710
  showReasoning: state.showReasoning,
1367
1711
  verbose: state.verbose,
1368
1712
  theme: state.theme,
1713
+ cwd: state.cwd,
1369
1714
  previewWidth,
1370
1715
  };
1371
1716