@agnishc/edb-compact-tools 0.10.6 → 0.10.9
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 +23 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/constants.test.ts +22 -0
- package/src/constants.ts +42 -0
- package/src/index.ts +2 -510
- package/src/message-frame.ts +73 -0
- package/src/patches.ts +170 -0
- package/src/text.test.ts +124 -0
- package/src/text.ts +34 -0
- package/src/tool-block.ts +64 -0
- package/src/tool-meta.test.ts +182 -0
- package/src/tool-meta.ts +160 -0
- package/src/tool-renderer.ts +103 -0
- package/src/types.ts +15 -0
|
@@ -0,0 +1,73 @@
|
|
|
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";
|
|
9
|
+
import type { CompactTheme } from "./types.js";
|
|
10
|
+
|
|
11
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export function padVisible(text: string, width: number): string {
|
|
14
|
+
const clipped = truncateToWidth(text, width, "");
|
|
15
|
+
return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stripUserZoneMarkers(line: string): string {
|
|
19
|
+
return line.replaceAll(OSC133_ZONE_START, "").replaceAll(OSC133_ZONE_END, "").replaceAll(OSC133_ZONE_FINAL, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function randomUserMessageMarker(): string {
|
|
23
|
+
return USER_MESSAGE_EMOJIS[Math.floor(Math.random() * USER_MESSAGE_EMOJIS.length)] ?? "✨";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function randomAssistantMessageMarker(): string {
|
|
27
|
+
return ASSISTANT_MESSAGE_EMOJIS[Math.floor(Math.random() * ASSISTANT_MESSAGE_EMOJIS.length)] ?? "🤖";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function trimVisualBlankLines(lines: string[]): string[] {
|
|
31
|
+
let start = 0;
|
|
32
|
+
let end = lines.length;
|
|
33
|
+
while (start < end && stripUserZoneMarkers(lines[start] ?? "").trim() === "") start++;
|
|
34
|
+
while (end > start && stripUserZoneMarkers(lines[end - 1] ?? "").trim() === "") end--;
|
|
35
|
+
return lines.slice(start, end);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Framing ──────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function frameMessage(
|
|
41
|
+
lines: string[],
|
|
42
|
+
width: number,
|
|
43
|
+
theme: CompactTheme,
|
|
44
|
+
markerText: string,
|
|
45
|
+
borderColor: string,
|
|
46
|
+
markerColor: string,
|
|
47
|
+
): string[] {
|
|
48
|
+
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("│")}`,
|
|
56
|
+
);
|
|
57
|
+
const bottom = `${border("╰")}${border("─".repeat(innerWidth))}${border("╯")}`;
|
|
58
|
+
return [`${OSC133_ZONE_START}${top}`, ...body, `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${bottom}`, ""];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function frameUserMessage(lines: string[], width: number, theme: CompactTheme, markerText: string): string[] {
|
|
62
|
+
return frameMessage(lines, width, theme, markerText, "accent", "error");
|
|
63
|
+
}
|
|
64
|
+
|
|
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");
|
|
73
|
+
}
|
package/src/patches.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AssistantMessageComponent,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
ToolExecutionComponent,
|
|
5
|
+
UserMessageComponent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
ASSISTANT_MESSAGE_MARKER_SYMBOL,
|
|
9
|
+
ASSISTANT_MESSAGE_PATCH_SYMBOL,
|
|
10
|
+
TOOL_EXECUTION_PATCH_SYMBOL,
|
|
11
|
+
USER_MESSAGE_MARKER_SYMBOL,
|
|
12
|
+
USER_MESSAGE_PATCH_SYMBOL,
|
|
13
|
+
} from "./constants.js";
|
|
14
|
+
import {
|
|
15
|
+
frameAssistantMessage,
|
|
16
|
+
frameUserMessage,
|
|
17
|
+
randomAssistantMessageMarker,
|
|
18
|
+
randomUserMessageMarker,
|
|
19
|
+
} from "./message-frame.js";
|
|
20
|
+
import { renderCall, renderResult } from "./tool-renderer.js";
|
|
21
|
+
import type { BuiltinTool, BuiltinToolName, CompactTheme } from "./types.js";
|
|
22
|
+
|
|
23
|
+
// ── Shared state ─────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
let activeTheme: CompactTheme | undefined;
|
|
26
|
+
|
|
27
|
+
function fallbackTheme(): CompactTheme {
|
|
28
|
+
return {
|
|
29
|
+
fg: (_color: any, text: string) => text,
|
|
30
|
+
bg: (_color: any, text: string) => text,
|
|
31
|
+
bold: (text: string) => text,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Generic tool renderer patch ──────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export function installGenericToolRendererPatch(pi: ExtensionAPI): void {
|
|
38
|
+
const proto = ToolExecutionComponent?.prototype as any;
|
|
39
|
+
if (!proto || proto[TOOL_EXECUTION_PATCH_SYMBOL]) return;
|
|
40
|
+
const originalGetCallRenderer = proto.getCallRenderer;
|
|
41
|
+
const originalGetResultRenderer = proto.getResultRenderer;
|
|
42
|
+
const originalGetRenderShell = proto.getRenderShell;
|
|
43
|
+
if (
|
|
44
|
+
typeof originalGetCallRenderer !== "function" ||
|
|
45
|
+
typeof originalGetResultRenderer !== "function" ||
|
|
46
|
+
typeof originalGetRenderShell !== "function"
|
|
47
|
+
) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
proto.getCallRenderer = function compactFallbackCallRenderer(this: any) {
|
|
52
|
+
const toolName = typeof this?.toolName === "string" ? this.toolName : "tool";
|
|
53
|
+
return (args: any, theme: CompactTheme, context: any) => renderCall(toolName, args, theme, context);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
proto.getResultRenderer = function compactFallbackResultRenderer(this: any) {
|
|
57
|
+
const toolName = typeof this?.toolName === "string" ? this.toolName : "tool";
|
|
58
|
+
return (result: any, options: any, theme: CompactTheme, context: any) =>
|
|
59
|
+
renderResult(toolName, result, options, theme, context);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
proto.getRenderShell = function compactFallbackRenderShell(this: any) {
|
|
63
|
+
return "self";
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
proto[TOOL_EXECUTION_PATCH_SYMBOL] = { originalGetCallRenderer, originalGetResultRenderer, originalGetRenderShell };
|
|
67
|
+
pi.on("session_shutdown", () => {
|
|
68
|
+
const state = proto[TOOL_EXECUTION_PATCH_SYMBOL];
|
|
69
|
+
if (!state) return;
|
|
70
|
+
proto.getCallRenderer = state.originalGetCallRenderer;
|
|
71
|
+
proto.getResultRenderer = state.originalGetResultRenderer;
|
|
72
|
+
proto.getRenderShell = state.originalGetRenderShell;
|
|
73
|
+
delete proto[TOOL_EXECUTION_PATCH_SYMBOL];
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Message renderer patches ─────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export function installMessageRenderers(pi: ExtensionAPI): void {
|
|
80
|
+
const userProto = UserMessageComponent?.prototype as any;
|
|
81
|
+
if (userProto && !userProto[USER_MESSAGE_PATCH_SYMBOL] && typeof userProto.render === "function") {
|
|
82
|
+
const originalRender = userProto.render as (width: number) => string[];
|
|
83
|
+
userProto.render = function compactUserMessageRender(this: any, width: number): string[] {
|
|
84
|
+
const box = this?.contentBox;
|
|
85
|
+
if (box) {
|
|
86
|
+
box.paddingY = 0;
|
|
87
|
+
box.setBgFn?.(undefined);
|
|
88
|
+
box.invalidate?.();
|
|
89
|
+
}
|
|
90
|
+
const frameWidth = Math.max(1, width - 1);
|
|
91
|
+
if (!this[USER_MESSAGE_MARKER_SYMBOL]) this[USER_MESSAGE_MARKER_SYMBOL] = randomUserMessageMarker();
|
|
92
|
+
const rendered = originalRender.call(this, Math.max(1, frameWidth - 2));
|
|
93
|
+
return frameUserMessage(
|
|
94
|
+
rendered,
|
|
95
|
+
frameWidth,
|
|
96
|
+
activeTheme ?? fallbackTheme(),
|
|
97
|
+
this[USER_MESSAGE_MARKER_SYMBOL],
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
userProto[USER_MESSAGE_PATCH_SYMBOL] = { originalRender };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const assistantProto = AssistantMessageComponent?.prototype as any;
|
|
104
|
+
if (
|
|
105
|
+
assistantProto &&
|
|
106
|
+
!assistantProto[ASSISTANT_MESSAGE_PATCH_SYMBOL] &&
|
|
107
|
+
typeof assistantProto.render === "function"
|
|
108
|
+
) {
|
|
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
|
+
);
|
|
122
|
+
};
|
|
123
|
+
assistantProto[ASSISTANT_MESSAGE_PATCH_SYMBOL] = { originalRender };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pi.on("session_start", (_event, ctx) => {
|
|
127
|
+
if (ctx.hasUI) activeTheme = ctx.ui.theme as unknown as CompactTheme;
|
|
128
|
+
});
|
|
129
|
+
pi.on("session_shutdown", () => {
|
|
130
|
+
const userState = userProto?.[USER_MESSAGE_PATCH_SYMBOL];
|
|
131
|
+
if (userState) {
|
|
132
|
+
userProto.render = userState.originalRender;
|
|
133
|
+
delete userProto[USER_MESSAGE_PATCH_SYMBOL];
|
|
134
|
+
}
|
|
135
|
+
const assistantState = assistantProto?.[ASSISTANT_MESSAGE_PATCH_SYMBOL];
|
|
136
|
+
if (assistantState) {
|
|
137
|
+
assistantProto.render = assistantState.originalRender;
|
|
138
|
+
delete assistantProto[ASSISTANT_MESSAGE_PATCH_SYMBOL];
|
|
139
|
+
}
|
|
140
|
+
activeTheme = undefined;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Delegating tool registration ─────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export function registerDelegatingTool(
|
|
147
|
+
pi: ExtensionAPI,
|
|
148
|
+
name: BuiltinToolName,
|
|
149
|
+
createTool: (cwd: string) => BuiltinTool,
|
|
150
|
+
): void {
|
|
151
|
+
const cwd = process.cwd();
|
|
152
|
+
const original = createTool(cwd);
|
|
153
|
+
pi.registerTool({
|
|
154
|
+
name,
|
|
155
|
+
label: name,
|
|
156
|
+
description: original.description,
|
|
157
|
+
promptSnippet: (original as any).promptSnippet,
|
|
158
|
+
parameters: original.parameters as any,
|
|
159
|
+
renderShell: "self",
|
|
160
|
+
async execute(id: string, params: unknown, signal?: AbortSignal, onUpdate?: unknown, ctx?: any) {
|
|
161
|
+
return createTool(ctx?.cwd ?? cwd).execute(id, params, signal, onUpdate, ctx) as any;
|
|
162
|
+
},
|
|
163
|
+
renderCall(args: any, theme: CompactTheme, context: any) {
|
|
164
|
+
return renderCall(name, args, theme, context);
|
|
165
|
+
},
|
|
166
|
+
renderResult(result: any, options: any, theme: CompactTheme, context: any) {
|
|
167
|
+
return renderResult(name, result, options, theme, context);
|
|
168
|
+
},
|
|
169
|
+
} as any);
|
|
170
|
+
}
|
package/src/text.test.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clip, lineCount, oneLine, outputWasTruncated, previewLines, textContent } from "./text.js";
|
|
3
|
+
|
|
4
|
+
describe("oneLine", () => {
|
|
5
|
+
it("collapses whitespace", () => {
|
|
6
|
+
expect(oneLine("hello world")).toBe("hello world");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("trims edges", () => {
|
|
10
|
+
expect(oneLine(" hello ")).toBe("hello");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("handles null and undefined as empty string", () => {
|
|
14
|
+
expect(oneLine(null)).toBe("");
|
|
15
|
+
expect(oneLine(undefined)).toBe("");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("clip", () => {
|
|
20
|
+
it("passes through short text", () => {
|
|
21
|
+
expect(clip("hello")).toBe("hello");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("truncates long text with ellipsis", () => {
|
|
25
|
+
expect(clip("a".repeat(150))).toBe(`${"a".repeat(119)}…`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("respects custom max", () => {
|
|
29
|
+
expect(clip("hello world", 5)).toBe("hell…");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("handles empty string", () => {
|
|
33
|
+
expect(clip("")).toBe("");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("lineCount", () => {
|
|
38
|
+
it("counts lines", () => {
|
|
39
|
+
expect(lineCount("a\nb\nc")).toBe(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles CRLF", () => {
|
|
43
|
+
expect(lineCount("a\r\nb\r\nc")).toBe(3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns 0 for empty", () => {
|
|
47
|
+
expect(lineCount("")).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("textContent", () => {
|
|
52
|
+
it("extracts text from content array", () => {
|
|
53
|
+
const result = {
|
|
54
|
+
content: [
|
|
55
|
+
{ type: "text", text: "hello" },
|
|
56
|
+
{ type: "text", text: "world" },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
expect(textContent(result)).toBe("hello\nworld");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("filters non-text items", () => {
|
|
63
|
+
const result = {
|
|
64
|
+
content: [
|
|
65
|
+
{ type: "text", text: "hello" },
|
|
66
|
+
{ type: "image", text: "world" },
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
expect(textContent(result)).toBe("hello");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("handles missing content", () => {
|
|
73
|
+
expect(textContent({})).toBe("");
|
|
74
|
+
expect(textContent(null)).toBe("");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("joins multiple text items", () => {
|
|
78
|
+
const result = {
|
|
79
|
+
content: [
|
|
80
|
+
{ type: "text", text: "line1" },
|
|
81
|
+
{ type: "text", text: "line2" },
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
expect(textContent(result)).toBe("line1\nline2");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("outputWasTruncated", () => {
|
|
89
|
+
it("detects truncation markers", () => {
|
|
90
|
+
expect(outputWasTruncated("Output truncated")).toBe(true);
|
|
91
|
+
expect(outputWasTruncated("Full output saved to: /tmp/out.txt")).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("is case-insensitive", () => {
|
|
95
|
+
expect(outputWasTruncated("TRUNCATED")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns false for clean output", () => {
|
|
99
|
+
expect(outputWasTruncated("hello world")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("previewLines", () => {
|
|
104
|
+
it("takes head lines by default", () => {
|
|
105
|
+
const lines = "a\nb\nc\nd\ne".split("\n");
|
|
106
|
+
const text = lines.join("\n");
|
|
107
|
+
expect(previewLines(text, "head")).toEqual(["a", "b", "c", "d", "e"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("takes tail lines when mode is tail", () => {
|
|
111
|
+
const text = "a\nb\nc\nd\ne";
|
|
112
|
+
expect(previewLines(text, "tail", 3)).toEqual(["c", "d", "e"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("respects limit", () => {
|
|
116
|
+
const text = "a\nb\nc\nd\ne";
|
|
117
|
+
expect(previewLines(text, "head", 3)).toEqual(["a", "b", "c"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("clips long lines", () => {
|
|
121
|
+
const text = "a".repeat(200);
|
|
122
|
+
expect(previewLines(text, "head", 1)[0]!.length).toBeLessThanOrEqual(121);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { MAX_COLLAPSED_TEXT, MAX_EXPANDED_LINES, MAX_LINE_CHARS } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export function oneLine(value: unknown): string {
|
|
4
|
+
return String(value ?? "")
|
|
5
|
+
.replace(/\s+/g, " ")
|
|
6
|
+
.trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function clip(text: string, max = MAX_COLLAPSED_TEXT): string {
|
|
10
|
+
return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function lineCount(text: string): number {
|
|
14
|
+
if (!text) return 0;
|
|
15
|
+
return text.split(/\r?\n/).length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function textContent(result: any): string {
|
|
19
|
+
const content = Array.isArray(result?.content) ? result.content : [];
|
|
20
|
+
return content
|
|
21
|
+
.filter((item: any) => item?.type === "text" && typeof item.text === "string")
|
|
22
|
+
.map((item: any) => item.text)
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function outputWasTruncated(text: string): boolean {
|
|
27
|
+
return /\btruncated\b|Full output saved to:/i.test(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function previewLines(text: string, mode: "head" | "tail", limit = MAX_EXPANDED_LINES): string[] {
|
|
31
|
+
const lines = text.replace(/\s+$/g, "").split(/\r?\n/);
|
|
32
|
+
const selected = mode === "tail" ? lines.slice(-limit) : lines.slice(0, limit);
|
|
33
|
+
return selected.map((line) => clip(line, MAX_LINE_CHARS));
|
|
34
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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 class ToolBlock {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly kind: ToolBlockKind,
|
|
14
|
+
private readonly lines: string[],
|
|
15
|
+
private readonly theme: CompactTheme,
|
|
16
|
+
private readonly colorFn: (text: string) => string,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
render(width: number): string[] {
|
|
20
|
+
const renderWidth = Math.max(8, width - 1);
|
|
21
|
+
const separator = this.theme.fg("borderMuted", "─".repeat(Math.max(8, Math.min(32, renderWidth))));
|
|
22
|
+
const block = this.lines.map((line, index) => {
|
|
23
|
+
if (this.kind === "call") return this.renderTop(line, renderWidth);
|
|
24
|
+
if (this.kind === "full" && index === 0) return this.renderTop(line, renderWidth);
|
|
25
|
+
const isLast = index === this.lines.length - 1;
|
|
26
|
+
return isLast ? this.renderBottom(line, renderWidth) : this.renderBody(line, renderWidth);
|
|
27
|
+
});
|
|
28
|
+
return [separator, "", ...block, ""];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
invalidate(): void {}
|
|
32
|
+
|
|
33
|
+
private color(text: string): string {
|
|
34
|
+
return this.colorFn(text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private fit(text: string, width: number): string {
|
|
38
|
+
const clipped = truncateToWidth(text, Math.max(1, width), "");
|
|
39
|
+
return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private renderTop(content: string, width: number): string {
|
|
43
|
+
const prefix = this.color("╭─ ");
|
|
44
|
+
const suffix = this.color("╮");
|
|
45
|
+
const innerWidth = Math.max(1, width - 4);
|
|
46
|
+
const fitted = truncateToWidth(content, innerWidth, "");
|
|
47
|
+
const fill = this.color("─".repeat(Math.max(0, innerWidth - visibleWidth(fitted))));
|
|
48
|
+
return `${prefix}${fitted}${fill}${suffix}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private renderBody(content: string, width: number): string {
|
|
52
|
+
const innerWidth = Math.max(1, width - 2);
|
|
53
|
+
return `${this.color("│")}${this.fit(content, innerWidth)}${this.color("│")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private renderBottom(content: string, width: number): string {
|
|
57
|
+
const prefix = this.color("╰─ ");
|
|
58
|
+
const suffix = this.color("╯");
|
|
59
|
+
const innerWidth = Math.max(1, width - 4);
|
|
60
|
+
const fitted = truncateToWidth(content, innerWidth, "");
|
|
61
|
+
const fill = this.color("─".repeat(Math.max(0, innerWidth - visibleWidth(fitted))));
|
|
62
|
+
return `${prefix}${fitted}${fill}${suffix}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { callLabel, isSkillPath, purple, summaryFor, toolColor, 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("callLabel", () => {
|
|
88
|
+
it("returns clipped command for bash", () => {
|
|
89
|
+
expect(callLabel("bash", { command: "ls -la" })).toBe("ls -la");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("truncates long bash commands", () => {
|
|
93
|
+
const long = `echo ${"x".repeat(150)}`;
|
|
94
|
+
const result = callLabel("bash", { command: long });
|
|
95
|
+
expect(result.length).toBeLessThanOrEqual(143);
|
|
96
|
+
expect(result.endsWith("…")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns clipped path for read", () => {
|
|
100
|
+
expect(callLabel("read", { path: "/tmp/foo.ts" })).toBe("/tmp/foo.ts");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("formats grep with path", () => {
|
|
104
|
+
expect(callLabel("grep", { pattern: "TODO", path: "/src" })).toBe("TODO in /src");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns default for grep without path", () => {
|
|
108
|
+
expect(callLabel("grep", { pattern: "TODO" })).toBe("TODO in .");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("formats find with path", () => {
|
|
112
|
+
expect(callLabel("find", { path: "/tmp" })).toBe("/tmp");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("formats edit with replacement count", () => {
|
|
116
|
+
expect(callLabel("edit", { path: "foo.ts", edits: [{}, {}] })).toBe("foo.ts · 2 replacements");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("formats write with bytes", () => {
|
|
120
|
+
expect(callLabel("write", { path: "foo.ts", content: "hello world" })).toBe("foo.ts · 11 bytes");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("summaryFor", () => {
|
|
125
|
+
it("formats bash exit code", () => {
|
|
126
|
+
const result = { content: [{ type: "text", text: "hello\nexit 0" }] };
|
|
127
|
+
expect(summaryFor("bash", result)).toMatch(/exit 0/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("shows truncated marker when output was truncated", () => {
|
|
131
|
+
const result = { content: [{ type: "text", text: "hello\nOutput truncated" }] };
|
|
132
|
+
expect(summaryFor("bash", result)).toContain("truncated");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("formats read line count", () => {
|
|
136
|
+
const result = { content: [{ type: "text", text: "line1\nline2\nline3" }] };
|
|
137
|
+
expect(summaryFor("read", result)).toBe("3 lines");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("formats single line as singular", () => {
|
|
141
|
+
const result = { content: [{ type: "text", text: "line1" }] };
|
|
142
|
+
expect(summaryFor("read", result)).toBe("1 line");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("formats ls item count", () => {
|
|
146
|
+
const result = { content: [{ type: "text", text: "file1\nfile2" }] };
|
|
147
|
+
expect(summaryFor("ls", result)).toBe("2 items");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("formats edit diff stats", () => {
|
|
151
|
+
const result = {
|
|
152
|
+
content: [{ type: "text", text: "done" }],
|
|
153
|
+
details: { diff: "@@ -1,3 +1,4 @@\n+added1\n+added2\n-removed1\n-removed2" },
|
|
154
|
+
};
|
|
155
|
+
expect(summaryFor("edit", result)).toBe("+2 -2");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("falls back to line count for edit without diff", () => {
|
|
159
|
+
const result = { content: [{ type: "text", text: "a\nb" }] };
|
|
160
|
+
expect(summaryFor("edit", result)).toBe("2 lines");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("formats write line count", () => {
|
|
164
|
+
const result = { content: [{ type: "text", text: "line1\nline2" }] };
|
|
165
|
+
expect(summaryFor("write", result)).toBe("2 lines");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("formats grep result count", () => {
|
|
169
|
+
const result = { content: [{ type: "text", text: "match1\nmatch2\nmatch3" }] };
|
|
170
|
+
expect(summaryFor("grep", result)).toBe("3 results");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("formats find result count", () => {
|
|
174
|
+
const result = { content: [{ type: "text", text: "file1\nfile2\nfile3\nfile4" }] };
|
|
175
|
+
expect(summaryFor("find", result)).toBe("4 results");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns '1 result' for single result", () => {
|
|
179
|
+
const result = { content: [{ type: "text", text: "only" }] };
|
|
180
|
+
expect(summaryFor("grep", result)).toBe("1 result");
|
|
181
|
+
});
|
|
182
|
+
});
|