@agnishc/edb-compact-tools 0.10.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0] - 2026-05-22
4
+
5
+ ### Added
6
+ - Unit tests for new rendering modules
7
+
8
+ ### Changed
9
+ - Modularized frame rendering with paragraph classes and per-tool views
10
+
11
+ ## [0.10.9] - 2026-05-18
12
+
3
13
  ## [0.10.8] - 2026-05-18
4
14
 
5
15
  ## [0.11.0] - 2026-05-18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-compact-tools",
3
- "version": "0.10.8",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension: compact outlined tool-call renderers with ctrl+o expansion",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,29 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { describe, expect, it } from "vitest";
3
+ import { BranchToolBlock } from "./branch-tool-block.js";
4
+ import type { CompactTheme } from "./types.js";
5
+
6
+ const theme: CompactTheme = {
7
+ fg: (_color, text) => text,
8
+ bold: (text) => text,
9
+ };
10
+
11
+ describe("BranchToolBlock", () => {
12
+ it("adds spacer and rule lines around the branch block", () => {
13
+ const block = new BranchToolBlock("full", ["top", "bottom"], theme, (text) => text);
14
+ expect(block.render(20)).toEqual([
15
+ " ".repeat(19),
16
+ "─".repeat(19),
17
+ "● top ",
18
+ "└─ bottom ",
19
+ "─".repeat(19),
20
+ " ".repeat(19),
21
+ ]);
22
+ });
23
+
24
+ it("never renders wider than the available width", () => {
25
+ const block = new BranchToolBlock("full", ["x".repeat(100), "y".repeat(100)], theme, (text) => text);
26
+ const lines = block.render(20);
27
+ expect(lines.every((line) => visibleWidth(line) <= 19)).toBe(true);
28
+ });
29
+ });
@@ -0,0 +1,70 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { CompactTheme, ToolBlockKind } from "./types.js";
3
+
4
+ export class EmptyBlock {
5
+ render(): string[] {
6
+ return [];
7
+ }
8
+ invalidate(): void {}
9
+ }
10
+
11
+ export interface BranchToolBlockOptions {
12
+ pending?: boolean;
13
+ }
14
+
15
+ export class BranchToolBlock {
16
+ constructor(
17
+ private readonly kind: ToolBlockKind,
18
+ private readonly lines: string[],
19
+ private readonly theme: CompactTheme,
20
+ private readonly colorFn: (text: string) => string,
21
+ private readonly options: BranchToolBlockOptions = {},
22
+ ) {}
23
+
24
+ render(width: number): string[] {
25
+ const renderWidth = Math.max(8, width - 1);
26
+ const lines = this.lines.length > 0 ? this.lines : [""];
27
+ const block = lines.map((line, index) => {
28
+ if (this.kind === "call" || index === 0) return this.renderTop(line, renderWidth);
29
+ const isLast = index === lines.length - 1;
30
+ return isLast ? this.renderBottom(line, renderWidth) : this.renderBody(line, renderWidth);
31
+ });
32
+ const spacerLine = " ".repeat(renderWidth);
33
+ const ruleLine = this.theme.fg("borderMuted", "─".repeat(renderWidth));
34
+ return [spacerLine, ruleLine, ...block, ruleLine, spacerLine];
35
+ }
36
+
37
+ invalidate(): void {}
38
+
39
+ private color(text: string): string {
40
+ return this.colorFn(text);
41
+ }
42
+
43
+ private pendingColor(): string {
44
+ const colors = ["warning", "accent", "muted"] as const;
45
+ return colors[Math.floor(Date.now() / 180) % colors.length];
46
+ }
47
+
48
+ private fit(text: string, width: number): string {
49
+ const clipped = truncateToWidth(text, Math.max(1, width), "");
50
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
51
+ }
52
+
53
+ private renderTop(content: string, width: number): string {
54
+ const prefix = this.options.pending ? this.theme.fg(this.pendingColor(), "● ") : this.color("● ");
55
+ const contentWidth = Math.max(1, width - visibleWidth("● "));
56
+ return `${prefix}${this.fit(content, contentWidth)}`;
57
+ }
58
+
59
+ private renderBody(content: string, width: number): string {
60
+ const prefix = this.theme.fg("borderMuted", "├─ ");
61
+ const contentWidth = Math.max(1, width - visibleWidth("├─ "));
62
+ return `${prefix}${this.fit(content, contentWidth)}`;
63
+ }
64
+
65
+ private renderBottom(content: string, width: number): string {
66
+ const prefix = this.color("└─ ");
67
+ const contentWidth = Math.max(1, width - visibleWidth("└─ "));
68
+ return `${prefix}${this.fit(content, contentWidth)}`;
69
+ }
70
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as constants from "./constants.js";
3
+
4
+ // ── constants ──────────────────────────────────────────────────────────────
5
+
6
+ describe("ANSI constants", () => {
7
+ it("exports ANSI_PURPLE and ANSI_RESET", () => {
8
+ expect(constants.ANSI_PURPLE).toMatch(/^\x1b\[38;5;\d+m$/);
9
+ expect(constants.ANSI_RESET).toBe("\x1b[0m");
10
+ });
11
+
12
+ it("exports emoji arrays", () => {
13
+ expect(constants.USER_MESSAGE_EMOJIS.length).toBeGreaterThan(0);
14
+ expect(constants.ASSISTANT_MESSAGE_EMOJIS.length).toBeGreaterThan(0);
15
+ });
16
+
17
+ it("exports OSC133 markers", () => {
18
+ expect(constants.OSC133_ZONE_START).toBe("\x1b]133;A\x07");
19
+ expect(constants.OSC133_ZONE_END).toBe("\x1b]133;B\x07");
20
+ expect(constants.OSC133_ZONE_FINAL).toBe("\x1b]133;C\x07");
21
+ });
22
+ });
@@ -0,0 +1,91 @@
1
+ import { shortenPath } from "./path-utils.js";
2
+ import { cleanToolOutputText, previewLines } from "./text.js";
3
+ import type { CompactTheme } from "./types.js";
4
+
5
+ function midLine(theme: CompactTheme, text: string): string {
6
+ return theme.fg("toolOutput", text);
7
+ }
8
+
9
+ function mutedLine(theme: CompactTheme, text: string): string {
10
+ return theme.fg("muted", text);
11
+ }
12
+
13
+ function fileIcon(path: string): string {
14
+ return path.endsWith("/") ? "📁" : "📄";
15
+ }
16
+
17
+ function escapeRegExp(text: string): string {
18
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+ }
20
+
21
+ export function formatExpandedLines(toolName: string, text: string, theme: CompactTheme, args?: any): string[] {
22
+ const cleanText = cleanToolOutputText(text);
23
+ if (!cleanText.trim()) return [];
24
+ if (toolName === "read") return formatReadLines(cleanText, theme);
25
+ if (toolName === "bash") return formatBashLines(cleanText, theme);
26
+ if (toolName === "ls") return formatLsLines(cleanText, theme);
27
+ if (toolName === "find") return formatFindLines(cleanText, theme);
28
+ if (toolName === "grep") return formatGrepLines(cleanText, theme, args);
29
+ return formatGenericLines(cleanText, theme);
30
+ }
31
+
32
+ function formatReadLines(text: string, theme: CompactTheme): string[] {
33
+ const lines = previewLines(text, "head");
34
+ const width = String(lines.length).length;
35
+ return [
36
+ mutedLine(theme, `preview · ${lines.length} line${lines.length === 1 ? "" : "s"}`),
37
+ ...lines.map(
38
+ (line, index) => `${theme.fg("dim", String(index + 1).padStart(width, " "))} ${theme.fg("toolOutput", line)}`,
39
+ ),
40
+ ];
41
+ }
42
+
43
+ function formatBashLines(text: string, theme: CompactTheme): string[] {
44
+ const lines = previewLines(text, "tail");
45
+ return [
46
+ mutedLine(theme, `output · last ${lines.length} line${lines.length === 1 ? "" : "s"}`),
47
+ ...lines.map((line) => midLine(theme, line)),
48
+ ];
49
+ }
50
+
51
+ function formatLsLines(text: string, theme: CompactTheme): string[] {
52
+ const entries = previewLines(text, "head").sort((a, b) => {
53
+ const dirDelta = Number(b.endsWith("/")) - Number(a.endsWith("/"));
54
+ return dirDelta || a.localeCompare(b);
55
+ });
56
+ return entries.map(
57
+ (entry) => `${fileIcon(entry)} ${theme.fg(entry.endsWith("/") ? "accent" : "toolOutput", entry)}`,
58
+ );
59
+ }
60
+
61
+ function formatFindLines(text: string, theme: CompactTheme): string[] {
62
+ const entries = previewLines(text, "head").map((entry) => shortenPath(entry));
63
+ return entries.map((entry) => `${fileIcon(entry)} ${theme.fg("toolOutput", entry)}`);
64
+ }
65
+
66
+ function formatGrepLines(text: string, theme: CompactTheme, args?: any): string[] {
67
+ const pattern = typeof args?.pattern === "string" && args.pattern.length > 0 ? args.pattern : "";
68
+ const matcher = pattern ? new RegExp(escapeRegExp(pattern), "gi") : null;
69
+ const highlight = (value: string) => {
70
+ if (!matcher) return theme.fg("toolOutput", value);
71
+ matcher.lastIndex = 0;
72
+ return theme.fg(
73
+ "toolOutput",
74
+ value.replace(matcher, (match) => theme.fg("warning", match)),
75
+ );
76
+ };
77
+ return previewLines(text, "head").map((line) => {
78
+ const match = line.match(/^([^:\n]+):(\d+):(.*)$/);
79
+ if (!match) return highlight(line);
80
+ return `${theme.fg("accent", shortenPath(match[1] ?? ""))}${theme.fg("dim", `:${match[2]}:`)}${highlight(match[3] ?? "")}`;
81
+ });
82
+ }
83
+
84
+ function formatGenericLines(text: string, theme: CompactTheme): string[] {
85
+ try {
86
+ const parsed = JSON.parse(text);
87
+ return previewLines(JSON.stringify(parsed, null, 2), "head").map((line) => midLine(theme, line));
88
+ } catch {
89
+ return previewLines(text, "head").map((line) => midLine(theme, line));
90
+ }
91
+ }
@@ -0,0 +1,41 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { describe, expect, it } from "vitest";
3
+ import { DottedParagraph, ThinkingParagraph } from "./message-frame.js";
4
+
5
+ const markdownTheme = {
6
+ heading: (s: string) => s,
7
+ link: (s: string) => s,
8
+ linkUrl: (s: string) => s,
9
+ code: (s: string) => s,
10
+ codeBlock: (s: string) => s,
11
+ codeBlockBorder: (s: string) => s,
12
+ quote: (s: string) => s,
13
+ quoteBorder: (s: string) => s,
14
+ hr: (s: string) => s,
15
+ listBullet: (s: string) => s,
16
+ bold: (s: string) => s,
17
+ italic: (s: string) => s,
18
+ strikethrough: (s: string) => s,
19
+ underline: (s: string) => s,
20
+ highlightCode: (code: string) => code.split("\n"),
21
+ };
22
+
23
+ describe("message frame paragraphs", () => {
24
+ it("keeps thinking lines within width", () => {
25
+ const paragraph = new ThinkingParagraph("x".repeat(200), markdownTheme as any);
26
+ const lines = paragraph.render(40);
27
+ expect(lines.every((line) => visibleWidth(line) <= 40)).toBe(true);
28
+ expect(lines[0]).toContain("✻ thinking:");
29
+ });
30
+
31
+ it("cleans old persisted ANSI completion artifacts and keeps only the latest completion", () => {
32
+ const paragraph = new DottedParagraph(
33
+ "Answer\n\n【38;2;128;128;128m✓ · Spelunked · 2s】\n\n✓ · Transmuted · 4s",
34
+ markdownTheme as any,
35
+ );
36
+ const rendered = paragraph.render(80).join("\n");
37
+ expect(rendered).not.toContain("【38");
38
+ expect(rendered).not.toContain("Spelunked");
39
+ expect(rendered).toContain("✓ · Transmuted · 4s");
40
+ });
41
+ });
@@ -1,15 +1,17 @@
1
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import {
3
- ASSISTANT_MESSAGE_EMOJIS,
4
- OSC133_ZONE_END,
5
- OSC133_ZONE_FINAL,
6
- OSC133_ZONE_START,
7
- USER_MESSAGE_EMOJIS,
8
- } from "./constants.js";
1
+ import { Markdown, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { OSC133_ZONE_END, OSC133_ZONE_FINAL, OSC133_ZONE_START, USER_MESSAGE_EMOJIS } from "./constants.js";
9
3
  import type { CompactTheme } from "./types.js";
10
4
 
5
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
6
+ const TRANSPARENT_BG = "\x1b[49m";
7
+ const RESET = "\x1b[0m";
8
+
11
9
  // ── Helpers ──────────────────────────────────────────────────────
12
10
 
11
+ export function stripAnsi(text: string): string {
12
+ return text.replace(ANSI_RE, "");
13
+ }
14
+
13
15
  export function padVisible(text: string, width: number): string {
14
16
  const clipped = truncateToWidth(text, width, "");
15
17
  return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
@@ -23,51 +25,229 @@ export function randomUserMessageMarker(): string {
23
25
  return USER_MESSAGE_EMOJIS[Math.floor(Math.random() * USER_MESSAGE_EMOJIS.length)] ?? "✨";
24
26
  }
25
27
 
26
- export function randomAssistantMessageMarker(): string {
27
- return ASSISTANT_MESSAGE_EMOJIS[Math.floor(Math.random() * ASSISTANT_MESSAGE_EMOJIS.length)] ?? "🤖";
28
- }
29
-
30
28
  export function trimVisualBlankLines(lines: string[]): string[] {
31
29
  let start = 0;
32
30
  let end = lines.length;
33
- while (start < end && stripUserZoneMarkers(lines[start] ?? "").trim() === "") start++;
34
- while (end > start && stripUserZoneMarkers(lines[end - 1] ?? "").trim() === "") end--;
31
+ while (start < end && stripUserZoneMarkers(stripAnsi(lines[start] ?? "")).trim() === "") start++;
32
+ while (end > start && stripUserZoneMarkers(stripAnsi(lines[end - 1] ?? "")).trim() === "") end--;
35
33
  return lines.slice(start, end);
36
34
  }
37
35
 
38
- // ── Framing ──────────────────────────────────────────────────────
36
+ function stripBackgroundAnsi(text: string): string {
37
+ return text.replace(/\x1b\[([0-9;]*)m/g, (_match, paramsText: string) => {
38
+ const params = paramsText === "" ? ["0"] : paramsText.split(";");
39
+ const kept: string[] = [];
40
+ for (let i = 0; i < params.length; i++) {
41
+ const code = Number(params[i] || "0");
42
+ if (code === 48) {
43
+ const mode = Number(params[i + 1] || "0");
44
+ i += mode === 2 ? 4 : mode === 5 ? 2 : 0;
45
+ continue;
46
+ }
47
+ if (code === 49 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107)) continue;
48
+ kept.push(params[i]!);
49
+ }
50
+ return kept.length === 0 ? "" : `\x1b[${kept.join(";")}m`;
51
+ });
52
+ }
53
+
54
+ function trimAnsiRight(text: string): string {
55
+ let trimmed = text;
56
+ while (true) {
57
+ const next = trimmed.replace(/[ \t]+((?:\x1b\[[0-9;]*m)*)$/g, "$1");
58
+ if (next === trimmed) return trimmed;
59
+ trimmed = next;
60
+ }
61
+ }
62
+
63
+ function cleanMessageLine(line: string): string {
64
+ return `${TRANSPARENT_BG}${trimAnsiRight(stripBackgroundAnsi(stripUserZoneMarkers(line)))}${TRANSPARENT_BG}`;
65
+ }
66
+
67
+ function borderFn(theme: CompactTheme): (text: string) => string {
68
+ return (text: string) => theme.fg("borderMuted", text);
69
+ }
70
+
71
+ function roundedUserBorder(width: number, top: boolean, theme: CompactTheme, markerText: string): string {
72
+ const border = borderFn(theme);
73
+ if (width <= 1) return `${border("│")}${RESET}${TRANSPARENT_BG}`;
74
+ const left = top ? "╭" : "╰";
75
+ const right = top ? "╮" : "╯";
76
+ if (!top || width < 14)
77
+ return `${border(left + "─".repeat(Math.max(0, width - 2)) + right)}${RESET}${TRANSPARENT_BG}`;
78
+ const label = ` ${markerText} User `;
79
+ const prefix = "─";
80
+ const suffixWidth = Math.max(0, width - 2 - visibleWidth(prefix) - visibleWidth(label));
81
+ return `${border(left + prefix)}${TRANSPARENT_BG}${theme.fg("error", label)}${border("─".repeat(suffixWidth) + right)}${RESET}${TRANSPARENT_BG}`;
82
+ }
83
+
84
+ function borderedMessageLine(line: string, width: number, theme: CompactTheme): string {
85
+ const border = borderFn(theme);
86
+ const innerWidth = Math.max(1, width - 4);
87
+ const content = truncateToWidth(cleanMessageLine(line), innerWidth, "");
88
+ return `${border("│")}${TRANSPARENT_BG} ${padVisible(content, innerWidth)} ${border("│")}${RESET}${TRANSPARENT_BG}`;
89
+ }
90
+
91
+ // ── User message framing: cc-tools geometry + edb emoji marker ─────
39
92
 
40
- export function frameMessage(
41
- lines: string[],
42
- width: number,
43
- theme: CompactTheme,
44
- markerText: string,
45
- borderColor: string,
46
- markerColor: string,
47
- ): string[] {
93
+ export function frameUserMessage(lines: string[], width: number, theme: CompactTheme, markerText: string): string[] {
48
94
  if (width < 6) return lines;
49
- const innerWidth = Math.max(1, width - 2);
50
- const border = (text: string) => theme.fg(borderColor, text);
51
- const marker = theme.fg(markerColor, markerText);
52
- const topFill = Math.max(0, innerWidth - visibleWidth(marker) - 2);
53
- const top = `${border("╭─")} ${marker}${border("─".repeat(topFill))}${border("╮")}`;
54
- const body = trimVisualBlankLines(lines).map(
55
- (line) => `${border("│")}${padVisible(stripUserZoneMarkers(line).trimEnd(), innerWidth)}${border("│")}`,
95
+ const borderWidth = Math.max(1, width);
96
+ const body = trimVisualBlankLines(lines);
97
+ const rendered = [
98
+ roundedUserBorder(borderWidth, true, theme, markerText),
99
+ ...body.map((line) => borderedMessageLine(line, borderWidth, theme)),
100
+ roundedUserBorder(borderWidth, false, theme, markerText),
101
+ ];
102
+ rendered[0] = OSC133_ZONE_START + rendered[0];
103
+ rendered[rendered.length - 1] += OSC133_ZONE_END + OSC133_ZONE_FINAL;
104
+ return [...rendered, ""];
105
+ }
106
+
107
+ // ── Assistant message paragraphs: cc-tools dotted final/thinking style ─
108
+
109
+ function cleanPersistedAnsiArtifacts(line: string): string {
110
+ // Older completion lines were accidentally persisted with ANSI escapes. In some terminals
111
+ // those escapes come back through markdown as visible fragments like "【38;2;...m".
112
+ if (!line.includes("✓ ·")) return line;
113
+ return line.replace(/【(?:\d{1,3};)*\d{1,3}m/g, "").replaceAll("】", "");
114
+ }
115
+
116
+ function sanitizeRenderedTextBlockLines(lines: string[]): string[] {
117
+ return trimVisualBlankLines(lines).map((line) =>
118
+ trimAnsiRight(cleanPersistedAnsiArtifacts(stripBackgroundAnsi(line))),
56
119
  );
57
- const bottom = `${border("╰")}${border("─".repeat(innerWidth))}${border("╯")}`;
58
- return [`${OSC133_ZONE_START}${top}`, ...body, `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${bottom}`, ""];
59
120
  }
60
121
 
61
- export function frameUserMessage(lines: string[], width: number, theme: CompactTheme, markerText: string): string[] {
62
- return frameMessage(lines, width, theme, markerText, "accent", "error");
122
+ function isCompletionLine(line: string): boolean {
123
+ return /^✓ · .+ · \d/.test(stripAnsi(line).trim());
124
+ }
125
+
126
+ function muted(text: string): string {
127
+ return `\x1b[38;5;244m${text}${RESET}`;
63
128
  }
64
129
 
65
- export function frameAssistantMessage(
66
- lines: string[],
67
- width: number,
68
- theme: CompactTheme,
69
- markerText?: string,
70
- ): string[] {
71
- const marker = markerText ?? randomAssistantMessageMarker();
72
- return frameMessage(lines, width, theme, marker, "border", "toolTitle");
130
+ export class DottedParagraph {
131
+ private md: InstanceType<typeof Markdown>;
132
+ private cachedWidth?: number;
133
+ private cachedLines?: string[];
134
+
135
+ constructor(text: string, markdownTheme: ConstructorParameters<typeof Markdown>[3]) {
136
+ this.md = new Markdown(text, 0, 0, markdownTheme);
137
+ }
138
+
139
+ invalidate(): void {
140
+ this.cachedWidth = undefined;
141
+ this.cachedLines = undefined;
142
+ this.md.invalidate();
143
+ }
144
+
145
+ render(width: number): string[] {
146
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
147
+ const prefixWidth = 3;
148
+ if (width <= prefixWidth) {
149
+ this.cachedWidth = width;
150
+ this.cachedLines = [" ● "];
151
+ return this.cachedLines;
152
+ }
153
+ const lines = sanitizeRenderedTextBlockLines(this.md.render(width - prefixWidth));
154
+ let lastCompletionIndex = -1;
155
+ for (let index = lines.length - 1; index >= 0; index--) {
156
+ if (isCompletionLine(lines[index] ?? "")) {
157
+ lastCompletionIndex = index;
158
+ break;
159
+ }
160
+ }
161
+ let dotPlaced = false;
162
+ const rendered = lines.flatMap((line, index) => {
163
+ if (isCompletionLine(line)) {
164
+ if (index !== lastCompletionIndex) return [];
165
+ return [` ${muted(stripAnsi(line).trim())}`];
166
+ }
167
+ if (!dotPlaced && stripAnsi(line).trim()) {
168
+ dotPlaced = true;
169
+ return [` ● ${line}`];
170
+ }
171
+ return [` ${line}`];
172
+ });
173
+ this.cachedWidth = width;
174
+ this.cachedLines = rendered;
175
+ return rendered;
176
+ }
177
+ }
178
+
179
+ export class ThinkingParagraph {
180
+ private md: InstanceType<typeof Markdown>;
181
+ private cachedWidth?: number;
182
+ private cachedLines?: string[];
183
+
184
+ constructor(
185
+ text: string,
186
+ markdownTheme: ConstructorParameters<typeof Markdown>[3],
187
+ defaultTextStyle?: ConstructorParameters<typeof Markdown>[4],
188
+ ) {
189
+ const fallbackMuted = "\x1b[38;5;244m";
190
+ const color =
191
+ typeof defaultTextStyle?.color === "function"
192
+ ? defaultTextStyle.color
193
+ : (s: string) => `${fallbackMuted}${s}${RESET}`;
194
+ const italic = "\x1b[3m";
195
+ const wrap = (s: string) => `${italic}${color(s)}${RESET}`;
196
+ const plainTheme: ConstructorParameters<typeof Markdown>[3] = {
197
+ ...(markdownTheme as any),
198
+ heading: wrap,
199
+ link: wrap,
200
+ linkUrl: wrap,
201
+ code: wrap,
202
+ codeBlock: wrap,
203
+ codeBlockBorder: wrap,
204
+ quote: wrap,
205
+ quoteBorder: wrap,
206
+ hr: wrap,
207
+ listBullet: wrap,
208
+ bold: wrap,
209
+ italic: wrap,
210
+ strikethrough: wrap,
211
+ underline: wrap,
212
+ highlightCode: (code: string) => code.split("\n").map(wrap),
213
+ };
214
+ const plainStyle: ConstructorParameters<typeof Markdown>[4] = { italic: true, color: wrap };
215
+ this.md = new Markdown(text, 0, 0, plainTheme, plainStyle);
216
+ }
217
+
218
+ invalidate(): void {
219
+ this.cachedWidth = undefined;
220
+ this.cachedLines = undefined;
221
+ this.md.invalidate();
222
+ }
223
+
224
+ render(width: number): string[] {
225
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
226
+ const label = "✻ thinking:";
227
+ const firstPrefixWidth = visibleWidth(` ${label} `);
228
+ const continuationPrefixWidth = 3;
229
+ const prefix = `\x1b[38;5;244m${label}\x1b[0m`;
230
+ if (width <= firstPrefixWidth) {
231
+ this.cachedWidth = width;
232
+ this.cachedLines = [truncateToWidth(` ${prefix} `, width, "")];
233
+ return this.cachedLines;
234
+ }
235
+ const firstLineWidth = Math.max(1, width - firstPrefixWidth);
236
+ const continuationWidth = Math.max(1, width - continuationPrefixWidth);
237
+ const sourceLines = sanitizeRenderedTextBlockLines(this.md.render(continuationWidth));
238
+ let symbolPlaced = false;
239
+ const rendered: string[] = [];
240
+ for (const sourceLine of sourceLines) {
241
+ if (!symbolPlaced && stripAnsi(sourceLine).trim()) {
242
+ symbolPlaced = true;
243
+ const first = truncateToWidth(sourceLine, firstLineWidth, "");
244
+ rendered.push(` ${prefix} ${first}`);
245
+ continue;
246
+ }
247
+ rendered.push(` ${truncateToWidth(sourceLine, continuationWidth, "")}`);
248
+ }
249
+ this.cachedWidth = width;
250
+ this.cachedLines = rendered;
251
+ return rendered;
252
+ }
73
253
  }
package/src/patches.ts CHANGED
@@ -4,19 +4,14 @@ import {
4
4
  ToolExecutionComponent,
5
5
  UserMessageComponent,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { Markdown } from "@earendil-works/pi-tui";
7
8
  import {
8
- ASSISTANT_MESSAGE_MARKER_SYMBOL,
9
9
  ASSISTANT_MESSAGE_PATCH_SYMBOL,
10
10
  TOOL_EXECUTION_PATCH_SYMBOL,
11
11
  USER_MESSAGE_MARKER_SYMBOL,
12
12
  USER_MESSAGE_PATCH_SYMBOL,
13
13
  } from "./constants.js";
14
- import {
15
- frameAssistantMessage,
16
- frameUserMessage,
17
- randomAssistantMessageMarker,
18
- randomUserMessageMarker,
19
- } from "./message-frame.js";
14
+ import { DottedParagraph, frameUserMessage, randomUserMessageMarker, ThinkingParagraph } from "./message-frame.js";
20
15
  import { renderCall, renderResult } from "./tool-renderer.js";
21
16
  import type { BuiltinTool, BuiltinToolName, CompactTheme } from "./types.js";
22
17
 
@@ -104,23 +99,27 @@ export function installMessageRenderers(pi: ExtensionAPI): void {
104
99
  if (
105
100
  assistantProto &&
106
101
  !assistantProto[ASSISTANT_MESSAGE_PATCH_SYMBOL] &&
107
- typeof assistantProto.render === "function"
102
+ typeof assistantProto.updateContent === "function"
108
103
  ) {
109
- const originalRender = assistantProto.render as (width: number) => string[];
110
- assistantProto.render = function compactAssistantMessageRender(this: any, width: number): string[] {
111
- const rendered = originalRender.call(this, Math.max(1, width - 3));
112
- if (this?.hasToolCalls || rendered.length === 0) return rendered;
113
- const frameWidth = Math.max(1, width - 1);
114
- if (!this[ASSISTANT_MESSAGE_MARKER_SYMBOL])
115
- this[ASSISTANT_MESSAGE_MARKER_SYMBOL] = randomAssistantMessageMarker();
116
- return frameAssistantMessage(
117
- rendered,
118
- frameWidth,
119
- activeTheme ?? fallbackTheme(),
120
- this[ASSISTANT_MESSAGE_MARKER_SYMBOL],
121
- );
104
+ const originalUpdateContent = assistantProto.updateContent;
105
+ assistantProto.updateContent = function compactAssistantUpdateContent(this: any, message: any): void {
106
+ originalUpdateContent.call(this, message);
107
+ const container = this?.contentContainer;
108
+ if (!container?.children) return;
109
+ const markdownTheme = this?.markdownTheme;
110
+ for (let i = container.children.length - 1; i >= 0; i--) {
111
+ const child = container.children[i];
112
+ if (!(child instanceof Markdown)) continue;
113
+ const markdownChild = child as any;
114
+ const text = markdownChild.text;
115
+ if (typeof text !== "string" || text.length === 0) continue;
116
+ const isThinking = Boolean(markdownChild.defaultTextStyle?.italic);
117
+ container.children[i] = isThinking
118
+ ? new ThinkingParagraph(text, markdownTheme, markdownChild.defaultTextStyle)
119
+ : new DottedParagraph(text, markdownTheme);
120
+ }
122
121
  };
123
- assistantProto[ASSISTANT_MESSAGE_PATCH_SYMBOL] = { originalRender };
122
+ assistantProto[ASSISTANT_MESSAGE_PATCH_SYMBOL] = { originalUpdateContent };
124
123
  }
125
124
 
126
125
  pi.on("session_start", (_event, ctx) => {
@@ -134,7 +133,7 @@ export function installMessageRenderers(pi: ExtensionAPI): void {
134
133
  }
135
134
  const assistantState = assistantProto?.[ASSISTANT_MESSAGE_PATCH_SYMBOL];
136
135
  if (assistantState) {
137
- assistantProto.render = assistantState.originalRender;
136
+ assistantProto.updateContent = assistantState.originalUpdateContent;
138
137
  delete assistantProto[ASSISTANT_MESSAGE_PATCH_SYMBOL];
139
138
  }
140
139
  activeTheme = undefined;
@@ -155,6 +154,7 @@ export function registerDelegatingTool(
155
154
  label: name,
156
155
  description: original.description,
157
156
  promptSnippet: (original as any).promptSnippet,
157
+ promptGuidelines: (original as any).promptGuidelines,
158
158
  parameters: original.parameters as any,
159
159
  renderShell: "self",
160
160
  async execute(id: string, params: unknown, signal?: AbortSignal, onUpdate?: unknown, ctx?: any) {
@@ -0,0 +1,27 @@
1
+ import { existsSync } from "node:fs";
2
+ import { relative, resolve } from "node:path";
3
+ import { oneLine } from "./text.js";
4
+
5
+ export function pathExists(value: unknown, cwd = process.cwd()): boolean {
6
+ const path = oneLine(value);
7
+ if (!path) return false;
8
+ try {
9
+ return existsSync(resolve(cwd, path));
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ export function shortenPath(value: unknown, cwd = process.cwd()): string {
16
+ const path = oneLine(value);
17
+ if (!path) return "";
18
+ const home = process.env.HOME;
19
+ try {
20
+ const rel = relative(cwd, path);
21
+ if (rel && !rel.startsWith("..") && !rel.startsWith("/")) return rel;
22
+ if (rel === "") return ".";
23
+ } catch {
24
+ // Keep the original value for malformed path-like strings.
25
+ }
26
+ return home && path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
27
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ cleanToolOutputText,
4
+ clip,
5
+ lineCount,
6
+ oneLine,
7
+ outputWasTruncated,
8
+ previewLines,
9
+ textContent,
10
+ } from "./text.js";
11
+
12
+ describe("oneLine", () => {
13
+ it("collapses whitespace", () => {
14
+ expect(oneLine("hello world")).toBe("hello world");
15
+ });
16
+
17
+ it("trims edges", () => {
18
+ expect(oneLine(" hello ")).toBe("hello");
19
+ });
20
+
21
+ it("handles null and undefined as empty string", () => {
22
+ expect(oneLine(null)).toBe("");
23
+ expect(oneLine(undefined)).toBe("");
24
+ });
25
+ });
26
+
27
+ describe("clip", () => {
28
+ it("passes through short text", () => {
29
+ expect(clip("hello")).toBe("hello");
30
+ });
31
+
32
+ it("truncates long text with ellipsis", () => {
33
+ expect(clip("a".repeat(150))).toBe(`${"a".repeat(119)}…`);
34
+ });
35
+
36
+ it("respects custom max", () => {
37
+ expect(clip("hello world", 5)).toBe("hell…");
38
+ });
39
+
40
+ it("handles empty string", () => {
41
+ expect(clip("")).toBe("");
42
+ });
43
+ });
44
+
45
+ describe("lineCount", () => {
46
+ it("counts lines", () => {
47
+ expect(lineCount("a\nb\nc")).toBe(3);
48
+ });
49
+
50
+ it("handles CRLF", () => {
51
+ expect(lineCount("a\r\nb\r\nc")).toBe(3);
52
+ });
53
+
54
+ it("returns 0 for empty", () => {
55
+ expect(lineCount("")).toBe(0);
56
+ });
57
+ });
58
+
59
+ describe("textContent", () => {
60
+ it("extracts text from content array", () => {
61
+ const result = {
62
+ content: [
63
+ { type: "text", text: "hello" },
64
+ { type: "text", text: "world" },
65
+ ],
66
+ };
67
+ expect(textContent(result)).toBe("hello\nworld");
68
+ });
69
+
70
+ it("filters non-text items", () => {
71
+ const result = {
72
+ content: [
73
+ { type: "text", text: "hello" },
74
+ { type: "image", text: "world" },
75
+ ],
76
+ };
77
+ expect(textContent(result)).toBe("hello");
78
+ });
79
+
80
+ it("handles missing content", () => {
81
+ expect(textContent({})).toBe("");
82
+ expect(textContent(null)).toBe("");
83
+ });
84
+
85
+ it("joins multiple text items", () => {
86
+ const result = {
87
+ content: [
88
+ { type: "text", text: "line1" },
89
+ { type: "text", text: "line2" },
90
+ ],
91
+ };
92
+ expect(textContent(result)).toBe("line1\nline2");
93
+ });
94
+ });
95
+
96
+ describe("outputWasTruncated", () => {
97
+ it("detects truncation markers", () => {
98
+ expect(outputWasTruncated("Output truncated")).toBe(true);
99
+ expect(outputWasTruncated("Full output saved to: /tmp/out.txt")).toBe(true);
100
+ });
101
+
102
+ it("is case-insensitive", () => {
103
+ expect(outputWasTruncated("TRUNCATED")).toBe(true);
104
+ });
105
+
106
+ it("returns false for clean output", () => {
107
+ expect(outputWasTruncated("hello world")).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe("cleanToolOutputText", () => {
112
+ it("removes synthetic exit-code lines from tool output", () => {
113
+ expect(cleanToolOutputText("hello\nExit code: 0\n")).toBe("hello");
114
+ });
115
+ });
116
+
117
+ describe("previewLines", () => {
118
+ it("takes head lines by default", () => {
119
+ const lines = "a\nb\nc\nd\ne".split("\n");
120
+ const text = lines.join("\n");
121
+ expect(previewLines(text, "head")).toEqual(["a", "b", "c", "d", "e"]);
122
+ });
123
+
124
+ it("takes tail lines when mode is tail", () => {
125
+ const text = "a\nb\nc\nd\ne";
126
+ expect(previewLines(text, "tail", 3)).toEqual(["c", "d", "e"]);
127
+ });
128
+
129
+ it("respects limit", () => {
130
+ const text = "a\nb\nc\nd\ne";
131
+ expect(previewLines(text, "head", 3)).toEqual(["a", "b", "c"]);
132
+ });
133
+
134
+ it("clips long lines", () => {
135
+ const text = "a".repeat(200);
136
+ expect(previewLines(text, "head", 1)[0]!.length).toBeLessThanOrEqual(121);
137
+ });
138
+ });
package/src/text.ts CHANGED
@@ -27,6 +27,14 @@ export function outputWasTruncated(text: string): boolean {
27
27
  return /\btruncated\b|Full output saved to:/i.test(text);
28
28
  }
29
29
 
30
+ export function cleanToolOutputText(text: string): string {
31
+ return text
32
+ .split(/\r?\n/)
33
+ .filter((line) => !/^\s*Exit code:\s*-?\d+\s*$/i.test(line))
34
+ .join("\n")
35
+ .replace(/\s+$/g, "");
36
+ }
37
+
30
38
  export function previewLines(text: string, mode: "head" | "tail", limit = MAX_EXPANDED_LINES): string[] {
31
39
  const lines = text.replace(/\s+$/g, "").split(/\r?\n/);
32
40
  const selected = mode === "tail" ? lines.slice(-limit) : lines.slice(0, limit);
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { callLabel, isSkillPath, purple, summaryFor, toolColor, toolDisplayName, toolIcon } from "./tool-meta.js";
3
+
4
+ describe("isSkillPath", () => {
5
+ it("detects .agents/skills paths", () => {
6
+ expect(isSkillPath(".agents/skills/my-skill/SKILL.md")).toBe(true);
7
+ });
8
+
9
+ it("detects .pi/agent/skills paths", () => {
10
+ expect(isSkillPath(".pi/agent/skills/my-skill/SKILL.md")).toBe(true);
11
+ });
12
+
13
+ it("returns false for regular paths", () => {
14
+ expect(isSkillPath("/Users/foo/project/src/index.ts")).toBe(false);
15
+ });
16
+
17
+ it("returns false for non-string inputs", () => {
18
+ expect(isSkillPath(null)).toBe(false);
19
+ expect(isSkillPath(undefined)).toBe(false);
20
+ expect(isSkillPath(123)).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe("purple", () => {
25
+ it("wraps text with ANSI purple codes", () => {
26
+ const result = purple("hello");
27
+ expect(result).toContain("hello");
28
+ expect(result).toContain("\x1b[38;5;141m");
29
+ expect(result).toContain("\x1b[0m");
30
+ });
31
+ });
32
+
33
+ describe("toolColor", () => {
34
+ it("returns bashMode for bash", () => {
35
+ expect(toolColor("bash")).toBe("bashMode");
36
+ });
37
+
38
+ it("returns toolTitle for read (non-skill)", () => {
39
+ expect(toolColor("read", { path: "/tmp/foo.ts" })).toBe("toolTitle");
40
+ });
41
+
42
+ it("returns purple for read (skill file)", () => {
43
+ expect(toolColor("read", { path: ".agents/skills/my-skill/SKILL.md" })).toBe("purple");
44
+ });
45
+
46
+ it("returns success for grep", () => {
47
+ expect(toolColor("grep")).toBe("success");
48
+ });
49
+
50
+ it("returns accent for find", () => {
51
+ expect(toolColor("find")).toBe("accent");
52
+ });
53
+
54
+ it("returns warning for ls", () => {
55
+ expect(toolColor("ls")).toBe("warning");
56
+ });
57
+
58
+ it("returns toolDiffAdded for edit", () => {
59
+ expect(toolColor("edit")).toBe("toolDiffAdded");
60
+ });
61
+
62
+ it("returns accent for write", () => {
63
+ expect(toolColor("write")).toBe("accent");
64
+ });
65
+
66
+ it("returns accent for unknown tools", () => {
67
+ expect(toolColor("unknown_tool")).toBe("accent");
68
+ });
69
+ });
70
+
71
+ describe("toolIcon", () => {
72
+ it("returns correct icon per tool", () => {
73
+ expect(toolIcon("bash")).toBe("⚙️");
74
+ expect(toolIcon("read")).toBe("📖");
75
+ expect(toolIcon("grep")).toBe("🔎");
76
+ expect(toolIcon("find")).toBe("🧭");
77
+ expect(toolIcon("ls")).toBe("📁");
78
+ expect(toolIcon("edit")).toBe("✏️");
79
+ expect(toolIcon("write")).toBe("📝");
80
+ });
81
+
82
+ it("returns default icon for unknown tools", () => {
83
+ expect(toolIcon("unknown")).toBe("🧩");
84
+ });
85
+ });
86
+
87
+ describe("toolDisplayName", () => {
88
+ it("returns polished built-in display names", () => {
89
+ expect(toolDisplayName("bash")).toBe("Run");
90
+ expect(toolDisplayName("read")).toBe("Read");
91
+ expect(toolDisplayName("grep")).toBe("Search");
92
+ expect(toolDisplayName("find")).toBe("Find");
93
+ expect(toolDisplayName("ls")).toBe("List");
94
+ expect(toolDisplayName("edit")).toBe("Edit");
95
+ expect(toolDisplayName("write", { path: "definitely-new-file-for-display-name.ts" })).toBe("Create");
96
+ });
97
+
98
+ it("falls back to the raw name for unknown tools", () => {
99
+ expect(toolDisplayName("unknown_tool")).toBe("unknown_tool");
100
+ });
101
+ });
102
+
103
+ describe("callLabel", () => {
104
+ it("returns clipped command for bash", () => {
105
+ expect(callLabel("bash", { command: "ls -la" })).toBe("ls -la");
106
+ });
107
+
108
+ it("truncates long bash commands", () => {
109
+ const long = `echo ${"x".repeat(150)}`;
110
+ const result = callLabel("bash", { command: long });
111
+ expect(result.length).toBeLessThanOrEqual(143);
112
+ expect(result.endsWith("…")).toBe(true);
113
+ });
114
+
115
+ it("returns shortened path for read", () => {
116
+ expect(callLabel("read", { path: `${process.cwd()}/packages/foo.ts` })).toBe("packages/foo.ts");
117
+ });
118
+
119
+ it("formats grep with shortened path", () => {
120
+ expect(callLabel("grep", { pattern: "TODO", path: `${process.cwd()}/src` })).toBe("TODO in src");
121
+ });
122
+
123
+ it("returns default for grep without path", () => {
124
+ expect(callLabel("grep", { pattern: "TODO" })).toBe("TODO in .");
125
+ });
126
+
127
+ it("formats find with path", () => {
128
+ expect(callLabel("find", { path: "/tmp" })).toBe("/tmp");
129
+ });
130
+
131
+ it("formats edit with replacement count", () => {
132
+ expect(callLabel("edit", { path: "foo.ts", edits: [{}, {}] })).toBe("foo.ts · 2 replacements");
133
+ });
134
+
135
+ it("formats write with bytes", () => {
136
+ expect(callLabel("write", { path: "foo.ts", content: "hello world" })).toBe("foo.ts · 11 bytes");
137
+ });
138
+ });
139
+
140
+ describe("summaryFor", () => {
141
+ it("formats bash exit code", () => {
142
+ const result = { content: [{ type: "text", text: "hello\nexit 0" }] };
143
+ expect(summaryFor("bash", result)).toMatch(/exit 0/);
144
+ });
145
+
146
+ it("shows truncated marker when output was truncated", () => {
147
+ const result = { content: [{ type: "text", text: "hello\nOutput truncated" }] };
148
+ expect(summaryFor("bash", result)).toContain("truncated");
149
+ });
150
+
151
+ it("formats read line count", () => {
152
+ const result = { content: [{ type: "text", text: "line1\nline2\nline3" }] };
153
+ expect(summaryFor("read", result)).toBe("3 lines");
154
+ });
155
+
156
+ it("formats single line as singular", () => {
157
+ const result = { content: [{ type: "text", text: "line1" }] };
158
+ expect(summaryFor("read", result)).toBe("1 line");
159
+ });
160
+
161
+ it("formats ls item count", () => {
162
+ const result = { content: [{ type: "text", text: "file1\nfile2" }] };
163
+ expect(summaryFor("ls", result)).toBe("2 items");
164
+ });
165
+
166
+ it("formats edit diff stats", () => {
167
+ const result = {
168
+ content: [{ type: "text", text: "done" }],
169
+ details: { diff: "@@ -1,3 +1,4 @@\n+added1\n+added2\n-removed1\n-removed2" },
170
+ };
171
+ expect(summaryFor("edit", result)).toBe("+2 -2");
172
+ });
173
+
174
+ it("falls back to line count for edit without diff", () => {
175
+ const result = { content: [{ type: "text", text: "a\nb" }] };
176
+ expect(summaryFor("edit", result)).toBe("2 lines");
177
+ });
178
+
179
+ it("formats write line count", () => {
180
+ const result = { content: [{ type: "text", text: "line1\nline2" }] };
181
+ expect(summaryFor("write", result)).toBe("2 lines");
182
+ });
183
+
184
+ it("formats grep result count", () => {
185
+ const result = { content: [{ type: "text", text: "match1\nmatch2\nmatch3" }] };
186
+ expect(summaryFor("grep", result)).toBe("3 results");
187
+ });
188
+
189
+ it("formats find result count", () => {
190
+ const result = { content: [{ type: "text", text: "file1\nfile2\nfile3\nfile4" }] };
191
+ expect(summaryFor("find", result)).toBe("4 results");
192
+ });
193
+
194
+ it("returns '1 result' for single result", () => {
195
+ const result = { content: [{ type: "text", text: "only" }] };
196
+ expect(summaryFor("grep", result)).toBe("1 result");
197
+ });
198
+ });
package/src/tool-meta.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ANSI_PURPLE, ANSI_RESET } from "./constants.js";
2
+ import { pathExists, shortenPath } from "./path-utils.js";
2
3
  import { clip, lineCount, oneLine, outputWasTruncated, textContent } from "./text.js";
3
4
 
4
5
  // ── Skill path detection ─────────────────────────────────────────
@@ -17,6 +18,7 @@ export function purple(text: string): string {
17
18
  type ToolMeta = {
18
19
  color: string | ((args?: any) => string);
19
20
  icon: string;
21
+ displayName: string | ((args?: any) => string);
20
22
  label: (args: any) => string;
21
23
  summary: (result: any) => string;
22
24
  };
@@ -25,6 +27,7 @@ const TOOL_REGISTRY: Record<string, ToolMeta> = {
25
27
  bash: {
26
28
  color: "bashMode",
27
29
  icon: "⚙️",
30
+ displayName: "Run",
28
31
  label: (args) => clip(oneLine(args?.command), 140),
29
32
  summary: (result) => {
30
33
  const text = textContent(result);
@@ -38,7 +41,8 @@ const TOOL_REGISTRY: Record<string, ToolMeta> = {
38
41
  read: {
39
42
  color: (args) => (isSkillPath(args?.path) ? "purple" : "toolTitle"),
40
43
  icon: "📖",
41
- label: (args) => clip(oneLine(args?.path), 140),
44
+ displayName: "Read",
45
+ label: (args) => clip(shortenPath(args?.path), 140),
42
46
  summary: (result) => {
43
47
  const text = textContent(result);
44
48
  const lines = lineCount(text);
@@ -49,9 +53,10 @@ const TOOL_REGISTRY: Record<string, ToolMeta> = {
49
53
  grep: {
50
54
  color: "success",
51
55
  icon: "🔎",
56
+ displayName: "Search",
52
57
  label: (args) => {
53
58
  const pattern = oneLine(args?.pattern);
54
- const path = oneLine(args?.path ?? args?.glob ?? ".");
59
+ const path = shortenPath(args?.path ?? args?.glob ?? ".");
55
60
  return clip(`${pattern}${path ? ` in ${path}` : ""}`, 140);
56
61
  },
57
62
  summary: (result) => {
@@ -64,7 +69,12 @@ const TOOL_REGISTRY: Record<string, ToolMeta> = {
64
69
  find: {
65
70
  color: "accent",
66
71
  icon: "🧭",
67
- label: (args) => clip(oneLine(args?.path ?? args?.pattern ?? "."), 140),
72
+ displayName: "Find",
73
+ label: (args) => {
74
+ const pattern = oneLine(args?.pattern);
75
+ const path = shortenPath(args?.path ?? ".") || ".";
76
+ return clip(pattern && pattern !== path ? `${pattern} in ${path}` : path, 140);
77
+ },
68
78
  summary: (result) => {
69
79
  const text = textContent(result);
70
80
  const lines = lineCount(text);
@@ -75,7 +85,8 @@ const TOOL_REGISTRY: Record<string, ToolMeta> = {
75
85
  ls: {
76
86
  color: "warning",
77
87
  icon: "📁",
78
- label: (args) => clip(oneLine(args?.path), 140),
88
+ displayName: "List",
89
+ label: (args) => clip(shortenPath(args?.path) || ".", 140),
79
90
  summary: (result) => {
80
91
  const text = textContent(result);
81
92
  const lines = lineCount(text);
@@ -86,10 +97,11 @@ const TOOL_REGISTRY: Record<string, ToolMeta> = {
86
97
  edit: {
87
98
  color: "toolDiffAdded",
88
99
  icon: "✏️",
100
+ displayName: "Edit",
89
101
  label: (args) => {
90
102
  const count = Array.isArray(args?.edits) ? args.edits.length : args?.oldText && args?.newText ? 1 : 0;
91
103
  return clip(
92
- `${oneLine(args?.path ?? args?.file_path)}${count ? ` · ${count} replacement${count === 1 ? "" : "s"}` : ""}`,
104
+ `${shortenPath(args?.path ?? args?.file_path)}${count ? ` · ${count} replacement${count === 1 ? "" : "s"}` : ""}`,
93
105
  140,
94
106
  );
95
107
  },
@@ -110,9 +122,10 @@ const TOOL_REGISTRY: Record<string, ToolMeta> = {
110
122
  write: {
111
123
  color: "accent",
112
124
  icon: "📝",
125
+ displayName: (args) => (pathExists(args?.path ?? args?.file_path) ? "Write" : "Create"),
113
126
  label: (args) => {
114
127
  const bytes = typeof args?.content === "string" ? Buffer.byteLength(args.content, "utf8") : 0;
115
- return clip(`${oneLine(args?.path ?? args?.file_path)}${bytes ? ` · ${bytes} bytes` : ""}`, 140);
128
+ return clip(`${shortenPath(args?.path ?? args?.file_path)}${bytes ? ` · ${bytes} bytes` : ""}`, 140);
116
129
  },
117
130
  summary: (result) => {
118
131
  const text = textContent(result);
@@ -126,6 +139,7 @@ const TOOL_REGISTRY: Record<string, ToolMeta> = {
126
139
  const DEFAULT_META: ToolMeta = {
127
140
  color: "accent",
128
141
  icon: "🧩",
142
+ displayName: "Tool",
129
143
  label: (args) => {
130
144
  const compactArgs = oneLine(JSON.stringify(args ?? {}));
131
145
  return clip(compactArgs === "{}" ? "" : compactArgs, 140);
@@ -151,6 +165,12 @@ export function toolIcon(toolName: string): string {
151
165
  return getMeta(toolName).icon;
152
166
  }
153
167
 
168
+ export function toolDisplayName(toolName: string, args?: any): string {
169
+ const meta = TOOL_REGISTRY[toolName];
170
+ if (!meta) return toolName;
171
+ return typeof meta.displayName === "function" ? meta.displayName(args) : meta.displayName;
172
+ }
173
+
154
174
  export function callLabel(toolName: string, args: any): string {
155
175
  return getMeta(toolName).label(args);
156
176
  }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderResult } from "./tool-renderer.js";
3
+ import type { CompactTheme } from "./types.js";
4
+
5
+ const theme: CompactTheme = {
6
+ fg: (_color, text) => text,
7
+ bold: (text) => text,
8
+ };
9
+
10
+ function textResult(text: string) {
11
+ return { content: [{ type: "text", text }] };
12
+ }
13
+
14
+ describe("renderResult expanded views", () => {
15
+ it("renders read output with line numbers", () => {
16
+ const block = renderResult("read", textResult("alpha\nbeta"), { expanded: true }, theme, {
17
+ args: { path: "file.ts" },
18
+ });
19
+ const lines = block.render(80).join("\n");
20
+ expect(lines).toContain("1 alpha");
21
+ expect(lines).toContain("2 beta");
22
+ });
23
+
24
+ it("renders ls output with file and folder icons", () => {
25
+ const block = renderResult("ls", textResult("src/\npackage.json"), { expanded: true }, theme, {
26
+ args: { path: "." },
27
+ });
28
+ const lines = block.render(80).join("\n");
29
+ expect(lines).toContain("📁 src/");
30
+ expect(lines).toContain("📄 package.json");
31
+ });
32
+
33
+ it("renders grep output with path and line metadata", () => {
34
+ const block = renderResult("grep", textResult("src/a.ts:12:const value = 1"), { expanded: true }, theme, {
35
+ args: { pattern: "value", path: "src" },
36
+ });
37
+ const lines = block.render(80).join("\n");
38
+ expect(lines).toContain("src/a.ts:12:const value = 1");
39
+ });
40
+ });
@@ -1,8 +1,9 @@
1
1
  import { keyHint } from "@earendil-works/pi-coding-agent";
2
+ import { BranchToolBlock, EmptyBlock } from "./branch-tool-block.js";
2
3
  import { MAX_EXPANDED_LINES } from "./constants.js";
3
- import { lineCount, previewLines, textContent } from "./text.js";
4
- import { EmptyBlock, ToolBlock } from "./tool-block.js";
5
- import { callLabel, purple, summaryFor, toolColor, toolIcon } from "./tool-meta.js";
4
+ import { formatExpandedLines } from "./expanded-lines.js";
5
+ import { cleanToolOutputText, lineCount, previewLines, textContent } from "./text.js";
6
+ import { callLabel, purple, summaryFor, toolColor, toolDisplayName, toolIcon } from "./tool-meta.js";
6
7
  import type { CompactTheme, ToolBlockKind } from "./types.js";
7
8
 
8
9
  // ── Color resolution ─────────────────────────────────────────────
@@ -20,7 +21,7 @@ function makeColorFn(color: string, theme: CompactTheme): (text: string) => stri
20
21
 
21
22
  function topLine(toolName: string, theme: CompactTheme, label: string, args?: any): string {
22
23
  const color = toolColor(toolName, args);
23
- const title = `${toolIcon(toolName)} ${toolName}`;
24
+ const title = `${toolIcon(toolName)} ${toolDisplayName(toolName, args)}`;
24
25
  const coloredTitle = color === "purple" ? purple(theme.bold(title)) : theme.fg(color, theme.bold(title));
25
26
  return `${coloredTitle} ${theme.fg("toolOutput", label)}`;
26
27
  }
@@ -29,6 +30,10 @@ function midLine(_toolName: string, theme: CompactTheme, text: string): string {
29
30
  return theme.fg("toolOutput", text);
30
31
  }
31
32
 
33
+ function mutedLine(theme: CompactTheme, text: string): string {
34
+ return theme.fg("muted", text);
35
+ }
36
+
32
37
  function bottomLine(_toolName: string, _theme: CompactTheme, text = ""): string {
33
38
  return text.trimEnd();
34
39
  }
@@ -42,9 +47,10 @@ function toolText(
42
47
  theme: CompactTheme,
43
48
  borderColor: string,
44
49
  args?: any,
45
- ): ToolBlock {
50
+ options?: { pending?: boolean },
51
+ ): BranchToolBlock {
46
52
  const color = resolveColor(toolName, args, borderColor);
47
- return new ToolBlock(kind, lines, theme, makeColorFn(color, theme));
53
+ return new BranchToolBlock(kind, lines, theme, makeColorFn(color, theme), options);
48
54
  }
49
55
 
50
56
  // ── Renderers ────────────────────────────────────────────────────
@@ -57,16 +63,23 @@ export function renderResult(toolName: string, result: any, options: any, theme:
57
63
  const args = context?.args;
58
64
 
59
65
  if (options?.isPartial) {
66
+ const partialText = cleanToolOutputText(textContent(result));
67
+ const mode = toolName === "bash" ? "tail" : "head";
68
+ const preview = partialText.trim()
69
+ ? previewLines(partialText, mode, 5).map((line) => midLine(toolName, theme, line))
70
+ : [];
60
71
  return toolText(
61
72
  "full",
62
73
  toolName,
63
74
  [
64
75
  topLine(toolName, theme, callLabel(toolName, args), args),
76
+ ...preview,
65
77
  bottomLine(toolName, theme, theme.fg("muted", "running…")),
66
78
  ],
67
79
  theme,
68
- "warning",
80
+ "muted",
69
81
  args,
82
+ { pending: true },
70
83
  );
71
84
  }
72
85
 
@@ -75,7 +88,7 @@ export function renderResult(toolName: string, result: any, options: any, theme:
75
88
  const failed = Boolean(context?.isError || result?.isError);
76
89
  const statusColor = failed ? "error" : "success";
77
90
  const statusIcon = failed ? "✗" : "✓";
78
- const expandHint = options?.expanded ? "" : ` ${theme.fg("dim", keyHint("app.tools.expand", "expand"))}`;
91
+ const expandHint = options?.expanded ? "" : ` ${keyHint("app.tools.expand", "expand")}`;
79
92
 
80
93
  const top = topLine(toolName, theme, callLabel(toolName, args), args);
81
94
  const bottom = bottomLine(
@@ -91,11 +104,10 @@ export function renderResult(toolName: string, result: any, options: any, theme:
91
104
 
92
105
  const diff = toolName === "edit" && typeof result?.details?.diff === "string" ? result.details.diff : "";
93
106
  const previewText = diff || text;
94
- const mode = toolName === "bash" ? "tail" : "head";
95
- const lines = previewLines(previewText, mode).map((line) => midLine(toolName, theme, line));
107
+ const lines = formatExpandedLines(toolName, previewText, theme, args);
96
108
  if (lineCount(previewText) > MAX_EXPANDED_LINES) {
97
109
  const omitted = lineCount(previewText) - MAX_EXPANDED_LINES;
98
- lines.push(midLine(toolName, theme, theme.fg("dim", `… ${omitted} more line(s)`)));
110
+ lines.push(mutedLine(theme, `… ${omitted} more line(s)`));
99
111
  }
100
112
  lines.unshift(top);
101
113
  lines.push(bottomLine(toolName, theme, `${theme.fg(statusColor, statusIcon)} ${theme.fg("toolOutput", summary)}`));