@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 +8 -0
- package/package.json +1 -1
- package/src/branch-tool-block.test.ts +29 -0
- package/src/branch-tool-block.ts +70 -0
- package/src/expanded-lines.ts +91 -0
- package/src/message-frame.test.ts +41 -0
- package/src/message-frame.ts +222 -42
- package/src/patches.ts +23 -23
- package/src/path-utils.ts +27 -0
- package/src/text.test.ts +15 -1
- package/src/text.ts +8 -0
- package/src/tool-meta.test.ts +21 -5
- package/src/tool-meta.ts +26 -6
- package/src/tool-renderer.test.ts +40 -0
- package/src/tool-renderer.ts +23 -11
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/message-frame.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
62
|
-
return
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
102
|
+
typeof assistantProto.updateContent === "function"
|
|
108
103
|
) {
|
|
109
|
-
const
|
|
110
|
-
assistantProto.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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] = {
|
|
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.
|
|
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 {
|
|
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);
|
package/src/tool-meta.test.ts
CHANGED
|
@@ -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
|
|
100
|
-
expect(callLabel("read", { path:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
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(`${
|
|
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
|
+
});
|
package/src/tool-renderer.ts
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
50
|
+
options?: { pending?: boolean },
|
|
51
|
+
): BranchToolBlock {
|
|
46
52
|
const color = resolveColor(toolName, args, borderColor);
|
|
47
|
-
return new
|
|
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
|
-
"
|
|
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 ? "" : ` ${
|
|
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
|
|
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(
|
|
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)}`));
|