@agnishc/edb-compact-tools 0.10.9 → 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,13 @@
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
+
3
11
  ## [0.10.9] - 2026-05-18
4
12
 
5
13
  ## [0.10.8] - 2026-05-18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-compact-tools",
3
- "version": "0.10.9",
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,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
+ }
package/src/text.test.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { clip, lineCount, oneLine, outputWasTruncated, previewLines, textContent } from "./text.js";
2
+ import {
3
+ cleanToolOutputText,
4
+ clip,
5
+ lineCount,
6
+ oneLine,
7
+ outputWasTruncated,
8
+ previewLines,
9
+ textContent,
10
+ } from "./text.js";
3
11
 
4
12
  describe("oneLine", () => {
5
13
  it("collapses whitespace", () => {
@@ -100,6 +108,12 @@ describe("outputWasTruncated", () => {
100
108
  });
101
109
  });
102
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
+
103
117
  describe("previewLines", () => {
104
118
  it("takes head lines by default", () => {
105
119
  const lines = "a\nb\nc\nd\ne".split("\n");
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);
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { callLabel, isSkillPath, purple, summaryFor, toolColor, toolIcon } from "./tool-meta.js";
2
+ import { callLabel, isSkillPath, purple, summaryFor, toolColor, toolDisplayName, toolIcon } from "./tool-meta.js";
3
3
 
4
4
  describe("isSkillPath", () => {
5
5
  it("detects .agents/skills paths", () => {
@@ -84,6 +84,22 @@ describe("toolIcon", () => {
84
84
  });
85
85
  });
86
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
+
87
103
  describe("callLabel", () => {
88
104
  it("returns clipped command for bash", () => {
89
105
  expect(callLabel("bash", { command: "ls -la" })).toBe("ls -la");
@@ -96,12 +112,12 @@ describe("callLabel", () => {
96
112
  expect(result.endsWith("…")).toBe(true);
97
113
  });
98
114
 
99
- it("returns clipped path for read", () => {
100
- expect(callLabel("read", { path: "/tmp/foo.ts" })).toBe("/tmp/foo.ts");
115
+ it("returns shortened path for read", () => {
116
+ expect(callLabel("read", { path: `${process.cwd()}/packages/foo.ts` })).toBe("packages/foo.ts");
101
117
  });
102
118
 
103
- it("formats grep with path", () => {
104
- expect(callLabel("grep", { pattern: "TODO", path: "/src" })).toBe("TODO in /src");
119
+ it("formats grep with shortened path", () => {
120
+ expect(callLabel("grep", { pattern: "TODO", path: `${process.cwd()}/src` })).toBe("TODO in src");
105
121
  });
106
122
 
107
123
  it("returns default for grep without path", () => {
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)}`));