@bismawy/pi-arnative 0.1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/extensions/footer.ts +627 -0
- package/extensions/section-headers.ts +734 -0
- package/extensions/timestamps.ts +224 -0
- package/extensions/tools.ts +942 -0
- package/extensions/ui-render-tweaks.ts +576 -0
- package/extensions/usage.ts +314 -0
- package/lib/ansi.ts +24 -0
- package/lib/box.ts +59 -0
- package/lib/usage-store.ts +354 -0
- package/package.json +43 -0
- package/themes/arnative-cyberpunk.json +94 -0
- package/themes/arnative-dracula.json +94 -0
- package/themes/arnative-emerald.json +94 -0
- package/themes/arnative-gruvbox.json +94 -0
- package/themes/arnative-matrix.json +94 -0
- package/themes/arnative-nord.json +94 -0
- package/themes/arnative-sun.json +94 -0
- package/themes/arnative-synthwave.json +94 -0
- package/themes/arnative-violet.json +94 -0
- package/themes/arnative-zinc.json +94 -0
- package/themes/arnative.json +94 -0
- package/themes/gen-themes.mjs +272 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex/Claude-Code style tool boxes: every tool call in its own box.
|
|
3
|
+
* ╭────────────────────────────╮
|
|
4
|
+
* │ $ cmd smart… │ running (spinner, gone once finished)
|
|
5
|
+
* ╰────────────────────────────╯
|
|
6
|
+
* ╭────────────────────────────╮
|
|
7
|
+
* │ ✓ $ cmd smart… 0.1s │ final, collapsed; duration right-aligned
|
|
8
|
+
* │ first output line │
|
|
9
|
+
* ╰────────────────────────────╯
|
|
10
|
+
* Click / ctrl+e = full title + detail (output dim; edit = toolDiff*).
|
|
11
|
+
* Collapsed summary: bash/write ` first output line`; grep/find/read numeric
|
|
12
|
+
* (→ N matches / → N files / N lines); edit ` +N / -M`. Plain tools show no
|
|
13
|
+
* summary line when expanded (output is already full) — no duplicated first line.
|
|
14
|
+
* Duration is right-aligned and space-aware (title truncated via visibleWidth when
|
|
15
|
+
* it doesn't fit); empty when unmeasured (restored sessions).
|
|
16
|
+
* Colors: tool name accent, paths/links tint, box lines + dim. Execution is a
|
|
17
|
+
* pure delegate (spread of the built-in tools).
|
|
18
|
+
* "has a result" lives in context.state, read at render() -> restore/reload safe,
|
|
19
|
+
* no stale box. The gap between boxes (built-in Spacer) is stripped by a render
|
|
20
|
+
* patch (same pattern as the footer patch).
|
|
21
|
+
* Third-party tools with no renderer of their own (memory_write, scratchpad, MCP…)
|
|
22
|
+
* get the same treatment: shell "self" + box with a one-line summary and
|
|
23
|
+
* `[ctrl+o to expand]`; full detail only when expanded.
|
|
24
|
+
*/
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import {
|
|
27
|
+
createBashTool,
|
|
28
|
+
createEditTool,
|
|
29
|
+
createFindTool,
|
|
30
|
+
createGrepTool,
|
|
31
|
+
createReadTool,
|
|
32
|
+
createWriteTool,
|
|
33
|
+
getMarkdownTheme,
|
|
34
|
+
keyText,
|
|
35
|
+
ToolExecutionComponent,
|
|
36
|
+
} from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { renderBoxLines as box } from "../lib/box.ts";
|
|
40
|
+
|
|
41
|
+
type Theme = { fg(color: string, text: string): string; bg?(color: string, text: string): string };
|
|
42
|
+
type TResult = { content: Array<{ type: string; text?: string }>; isError?: boolean; details?: any };
|
|
43
|
+
type TCtx = { isError?: boolean; toolCallId?: string; args?: any; state?: Record<string, unknown> };
|
|
44
|
+
type Res = (r: TResult, th: Theme, tint: (s: string) => string, isErr: boolean, expanded: boolean) => string;
|
|
45
|
+
|
|
46
|
+
const cwd = process.cwd();
|
|
47
|
+
const TIMINGS = new Map<string, number>(); // duration ms per tool call (one run)
|
|
48
|
+
|
|
49
|
+
// Running icon : 500ms spin frames from a ticker that only runs while
|
|
50
|
+
// a tool is active (idle = no wasteful re-renders).
|
|
51
|
+
const FRAMES = ["", "", ""];
|
|
52
|
+
let ACTIVE = 0;
|
|
53
|
+
let TICK: ReturnType<typeof setInterval> | null = null;
|
|
54
|
+
let requestRenderFn: (() => void) | null = null;
|
|
55
|
+
|
|
56
|
+
function syncTicker(): void {
|
|
57
|
+
if (ACTIVE > 0 && !TICK) {
|
|
58
|
+
TICK = setInterval(() => requestRenderFn?.(), 500);
|
|
59
|
+
} else if (ACTIVE <= 0 && TICK) {
|
|
60
|
+
clearInterval(TICK);
|
|
61
|
+
TICK = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function spinIcon(): string {
|
|
66
|
+
return FRAMES[Math.floor(Date.now() / 150) % FRAMES.length];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class Lines {
|
|
70
|
+
private get: (width: number) => string[];
|
|
71
|
+
constructor(get: (width: number) => string[]) {
|
|
72
|
+
this.get = get;
|
|
73
|
+
}
|
|
74
|
+
render(width: number): string[] {
|
|
75
|
+
return this.get(width);
|
|
76
|
+
}
|
|
77
|
+
invalidate(): void {}
|
|
78
|
+
}
|
|
79
|
+
const EMPTY = new Lines(() => []);
|
|
80
|
+
|
|
81
|
+
const shortPath = (p: string): string => {
|
|
82
|
+
const home = homedir();
|
|
83
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const textOf = (r: TResult): string =>
|
|
87
|
+
r.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n").trim();
|
|
88
|
+
|
|
89
|
+
const firstLine = (s: string): string => s.split("\n").find((l) => l.trim()) ?? "";
|
|
90
|
+
|
|
91
|
+
// Summary = first line with content. Punctuation-only lines (e.g. a lone `{` from
|
|
92
|
+
// pretty-printed MCP JSON) explain nothing -> take the next one.
|
|
93
|
+
const firstMeaningful = (s: string): string => {
|
|
94
|
+
const lines = s.split("\n").filter((l) => l.trim());
|
|
95
|
+
return lines.find((l) => !/^[\s{}\[\],;:]+$/.test(l)) ?? lines[0] ?? "";
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const countLines = (s: string): number => (s ? s.split("\n").filter(Boolean).length : 0);
|
|
99
|
+
|
|
100
|
+
// Tint folder paths / URLs; everything else default.
|
|
101
|
+
export const LINK_RE = /(?:https?:\/\/[^\s"'`)}\]]+|(?:~|\.{1,2})?\/[\w.+@~%/-]+|[\w.+@~-]+(?:\/[\w.+@~%/-]+)+)/g;
|
|
102
|
+
export function paintLinks(text: string, tint: (s: string) => string): string {
|
|
103
|
+
return text.replace(LINK_RE, (m) => tint(m));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// "tint" only exists in arnative themes; other themes fall back to accent (probed per render)
|
|
107
|
+
const tintOf = (theme: Theme): ((s: string) => string) => {
|
|
108
|
+
try {
|
|
109
|
+
theme.fg("tint", "");
|
|
110
|
+
return (s) => theme.fg("tint", s);
|
|
111
|
+
} catch {
|
|
112
|
+
return (s) => theme.fg("accent", s);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Word-limited smart title: `maxWords` words + ellipsis; full text when expanded.
|
|
117
|
+
export function smartTitle(cmd: string, maxWords = 6): string {
|
|
118
|
+
const words = cmd.replace(/\r?\n/g, " ").trim().split(/\s+/).filter(Boolean);
|
|
119
|
+
if (words.length <= maxWords) return words.join(" ");
|
|
120
|
+
return `${words.slice(0, maxWords).join(" ")}…`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Result line prefix: dim (duration lives at the right end of the title).
|
|
124
|
+
export function resHead(theme: Theme): string {
|
|
125
|
+
return theme.fg("dim", "");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Title line: duration right-aligned and space-aware (title truncated when it
|
|
129
|
+
// doesn't fit); expanded = full title wrapped. All lines stay <= inner (overflow bug).
|
|
130
|
+
export function titleRow(
|
|
131
|
+
theme: Theme,
|
|
132
|
+
icon: string,
|
|
133
|
+
name: string,
|
|
134
|
+
argPart: string,
|
|
135
|
+
inner: number,
|
|
136
|
+
dur: string,
|
|
137
|
+
expanded: boolean,
|
|
138
|
+
): string[] {
|
|
139
|
+
const head = `${icon} ${name} ${argPart}`.trim();
|
|
140
|
+
const durStr = dur ? theme.fg("dim", dur) : "";
|
|
141
|
+
if (!durStr) {
|
|
142
|
+
return expanded ? wrapTextWithAnsi(head, inner) : [truncateToWidth(head, inner, "…")];
|
|
143
|
+
}
|
|
144
|
+
const durW = visibleWidth(durStr);
|
|
145
|
+
const avail = Math.max(8, inner - durW);
|
|
146
|
+
const wrapW = Math.max(4, avail - 1); // minimal 1 spasi sebelum durasi
|
|
147
|
+
const lines = expanded ? wrapTextWithAnsi(head, wrapW) : [truncateToWidth(head, wrapW, "…")];
|
|
148
|
+
const pad = Math.max(1, avail - visibleWidth(lines[0]));
|
|
149
|
+
lines[0] = `${lines[0]}${" ".repeat(pad)}${durStr}`;
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Default summary: collapsed ` first output line`, empty when expanded (the
|
|
154
|
+
// full output is right below — no duplicate first line).
|
|
155
|
+
export const resText: Res = (r, th, tint, isErr, expanded) => {
|
|
156
|
+
if (expanded && !isErr) return "";
|
|
157
|
+
const f = firstLine(textOf(r));
|
|
158
|
+
return f ? `${resHead(th)} ${paintLinks(f, tint)}` : resHead(th);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Numeric summary ` → N matches`; errors fall back to the first line (most important).
|
|
162
|
+
export const numRes =
|
|
163
|
+
(fmt: (n: number) => string): Res =>
|
|
164
|
+
(r, th, tint, isErr, expanded) => {
|
|
165
|
+
if (isErr) return resText(r, th, tint, isErr, false);
|
|
166
|
+
if (expanded) return "";
|
|
167
|
+
return `${resHead(th)} ${fmt(countLines(textOf(r)))}`;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// First markdown heading in the text (e.g. `## omaga-sync workflow (2026-08-23)`).
|
|
171
|
+
const firstHeading = (s: unknown): string =>
|
|
172
|
+
typeof s === "string" ? (s.split("\n").find((l) => /^#{1,6}\s/.test(l.trim()))?.trim() ?? "") : "";
|
|
173
|
+
|
|
174
|
+
// One-line summary for tools without their own renderer. memory_write appends the
|
|
175
|
+
// heading it just wrote (from args.content) so the line names WHAT was written
|
|
176
|
+
// instead of just "Appended to MEMORY.md".
|
|
177
|
+
export function toolSummary(name: string, text: string, args?: any): string {
|
|
178
|
+
const head = firstMeaningful(text).trim();
|
|
179
|
+
const title = name === "memory_write" ? firstHeading(args?.content) : "";
|
|
180
|
+
return title ? `${head}. ${title}` : head;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Expand hint `[ctrl+o to expand]` (key text follows the active keybinding).
|
|
184
|
+
export function expandHint(th: Theme): string {
|
|
185
|
+
let key = "ctrl+o";
|
|
186
|
+
try {
|
|
187
|
+
key = keyText("app.tools.expand") || key;
|
|
188
|
+
} catch {
|
|
189
|
+
// outside a pi session: keep the literal
|
|
190
|
+
}
|
|
191
|
+
return th.fg("dim", `[${key} to expand]`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Expanded detail: themed markdown (same layout as a normal pi message — code
|
|
195
|
+
// blocks, lists, colors) instead of a plain dim dump.
|
|
196
|
+
export const fullText = (r: TResult, _th: Theme, width = 0): string[] => {
|
|
197
|
+
const text = textOf(r);
|
|
198
|
+
if (!text) return [];
|
|
199
|
+
if (!width) return text.split("\n").map((l) => _th.fg("dim", l));
|
|
200
|
+
try {
|
|
201
|
+
return new Markdown(text, 0, 0, getMarkdownTheme()).render(Math.max(8, width));
|
|
202
|
+
} catch {
|
|
203
|
+
return text.split("\n").map((l) => _th.fg("dim", l));
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
208
|
+
const isBlankLine = (l: string): boolean => l.replace(ANSI_RE, "").trim() === "";
|
|
209
|
+
|
|
210
|
+
// Drop blank lines at both edges (the built-in Spacer) — that's the gap between boxes.
|
|
211
|
+
export function stripBlankEdges(lines: string[]): string[] {
|
|
212
|
+
let s = 0;
|
|
213
|
+
let e = lines.length;
|
|
214
|
+
while (s < e && isBlankLine(lines[s])) s++;
|
|
215
|
+
while (e > s && isBlankLine(lines[e - 1])) e--;
|
|
216
|
+
return lines.slice(s, e);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The inter-box gap is the built-in Spacer(1) of ToolExecutionComponent, stripped
|
|
220
|
+
// with a prototype render patch + global guard (same pattern as the footer patch).
|
|
221
|
+
// ONLY lines we draw ourselves (our boxes) get stripped: third-party tools use
|
|
222
|
+
// their own "self" shell too, so the marker is a name list (filled by minimal() +
|
|
223
|
+
// BOXED_TOOLS) rather than the shell. Without that bound their lines would lose
|
|
224
|
+
// the Spacer + Box padding and stick to the neighbour.
|
|
225
|
+
const OWN_BOX = new Set<string>();
|
|
226
|
+
const hasOwnRendererDef = (self: { toolDefinition?: { renderCall?: unknown; renderResult?: unknown } }): boolean =>
|
|
227
|
+
Boolean(self?.toolDefinition?.renderCall || self?.toolDefinition?.renderResult);
|
|
228
|
+
|
|
229
|
+
const GAP_KEY = Symbol.for("pi-arnative.toolGapStripped");
|
|
230
|
+
if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol, boolean>)[GAP_KEY]) {
|
|
231
|
+
(globalThis as Record<symbol, boolean>)[GAP_KEY] = true;
|
|
232
|
+
const origRender = ToolExecutionComponent.prototype.render;
|
|
233
|
+
ToolExecutionComponent.prototype.render = function (width: number): string[] {
|
|
234
|
+
const lines = origRender.call(this, width);
|
|
235
|
+
const name = (this as { toolName?: string }).toolName ?? "";
|
|
236
|
+
return OWN_BOX.has(name) || !hasOwnRendererDef(this) ? stripBlankEdges(lines) : lines;
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- Tools without their own renderer: same box, one-line summary ---
|
|
241
|
+
// Without this pi renders them as a plain bg block (name + 10 output lines).
|
|
242
|
+
// Our renderer is installed through the prototype (same pattern as the gap patch)
|
|
243
|
+
// only for tools that define no renderCall/renderResult of their own (our own
|
|
244
|
+
// tools and pi-web-access etc. are untouched).
|
|
245
|
+
//
|
|
246
|
+
// Exceptions: the pi-web-access tools that look different from our box
|
|
247
|
+
// (`web_search`, `fetch_content`, `get_search_content`; that package hardcodes
|
|
248
|
+
// their display names as "search "/"fetch "/"get_content "). Their renderer
|
|
249
|
+
// content is used as-is and only wrapped in our box. `source_check` is
|
|
250
|
+
// deliberately NOT boxed: its partial phase (curator: URLs + approval state)
|
|
251
|
+
// would disappear because our partial path returns nothing.
|
|
252
|
+
// The `todo` tool (@juicesharp/rpiv-todo) is boxed with a check-square icon
|
|
253
|
+
// (\uf14a) and the toolPendingBg/toolSuccessBg theme backgrounds.
|
|
254
|
+
const BOXED_TOOLS = new Map([
|
|
255
|
+
["web_search", "search"],
|
|
256
|
+
["fetch_content", "fetch"],
|
|
257
|
+
["get_search_content", "get_content"],
|
|
258
|
+
["todo", "todo"],
|
|
259
|
+
]);
|
|
260
|
+
const displayName = (name: string): string => BOXED_TOOLS.get(name) ?? name;
|
|
261
|
+
for (const n of BOXED_TOOLS.keys()) OWN_BOX.add(n);
|
|
262
|
+
|
|
263
|
+
// MCP tools (pi-mcp-adapter) already use the "self" shell, but we render their
|
|
264
|
+
// result ourselves: their result renderer prints the WHOLE output on error
|
|
265
|
+
// (unbounded, outside the box) and the JSON title+args in the call can be dozens
|
|
266
|
+
// of lines. A 1-line summary + [ctrl+o to expand] is tighter; their args lines
|
|
267
|
+
// still feed the call box so the invoked MCP tool stays visible.
|
|
268
|
+
const MCP_TOOL = (name: string): boolean => name === "mcp" || name === "mcpScript" || name.startsWith("mcp__");
|
|
269
|
+
const pakaiCallMereka = (name: string): boolean => BOXED_TOOLS.has(name) || MCP_TOOL(name);
|
|
270
|
+
|
|
271
|
+
// The MCP box title names the invoked tool (full args when expanded).
|
|
272
|
+
const mcpInfo = (name: string, args: any, th: Theme): string => {
|
|
273
|
+
if (!MCP_TOOL(name)) return "";
|
|
274
|
+
const tool = String(args?.tool ?? "");
|
|
275
|
+
const server = String(args?.server ?? "");
|
|
276
|
+
return tool ? th.fg("dim", `${tool}${server ? ` @ ${server}` : ""}`) : "";
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Cap third-party lines in the call box; the rest goes behind [ctrl+o to expand].
|
|
280
|
+
const CALL_ROWS = 2;
|
|
281
|
+
const capped = (th: Theme, rows: string[], expanded: boolean): string[] =>
|
|
282
|
+
expanded || rows.length <= CALL_ROWS ? rows : [...rows.slice(0, CALL_ROWS), th.fg("dim", expandHint(th))];
|
|
283
|
+
|
|
284
|
+
// Lines from a third-party renderer component (right padding trimmed, ANSI kept).
|
|
285
|
+
const componentLines = (component: any, width: number): string[] => {
|
|
286
|
+
try {
|
|
287
|
+
const lines = component?.render(Math.max(8, width));
|
|
288
|
+
return Array.isArray(lines) ? lines.map((l: string) => String(l).trimEnd()) : [];
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Status icon + third-party lines -> our box content lines.
|
|
295
|
+
const boxedRows = (icon: string, lines: string[]): string[] => {
|
|
296
|
+
const body = lines.filter((l) => !isBlankLine(l));
|
|
297
|
+
return body.length ? [`${icon} ${body[0]}`, ...body.slice(1)] : [icon];
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Subtle per-tool bg (see also toolPendingBg/toolSuccessBg for `todo`).
|
|
302
|
+
* `memory_write` uses customMessageBg: thin, violet-ish = "note", just distinct
|
|
303
|
+
* from the plain progress boxes. Use `name.startsWith("memory_")` to cover all
|
|
304
|
+
* memory tools.
|
|
305
|
+
*/
|
|
306
|
+
export const boxBgOf = (name: string): string | undefined => (name === "memory_write" ? "customMessageBg" : undefined);
|
|
307
|
+
|
|
308
|
+
const DEFAULT_TOOL_KEY = Symbol.for("pi-arnative.defaultToolBox");
|
|
309
|
+
if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol, boolean>)[DEFAULT_TOOL_KEY]) {
|
|
310
|
+
(globalThis as Record<symbol, boolean>)[DEFAULT_TOOL_KEY] = true;
|
|
311
|
+
const proto = ToolExecutionComponent.prototype as any;
|
|
312
|
+
const origCall = proto.getCallRenderer;
|
|
313
|
+
const origResult = proto.getResultRenderer;
|
|
314
|
+
const origShell = proto.getRenderShell;
|
|
315
|
+
const hasOwnRenderer = (self: any): boolean => Boolean(origCall.call(self) || origResult.call(self));
|
|
316
|
+
|
|
317
|
+
// A tool call with no registered definition (e.g. an unknown tool name that
|
|
318
|
+
// errored with "Tool X not found") makes hasRendererDefinition() false, so the
|
|
319
|
+
// constructor adds the plain contentText fallback and updateDisplay() never calls
|
|
320
|
+
// getCallRenderer/getResultRenderer — our patch is bypassed and the call renders
|
|
321
|
+
// as bare name + pretty-printed args. Claim those calls as defined so they take
|
|
322
|
+
// the same box path as any other renderer-less tool.
|
|
323
|
+
const origHasRendererDefinition = proto.hasRendererDefinition;
|
|
324
|
+
proto.hasRendererDefinition = function (): boolean {
|
|
325
|
+
return origHasRendererDefinition.call(this) || this.toolDefinition === undefined;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
proto.getRenderShell = function (): string {
|
|
329
|
+
if (pakaiCallMereka(this.toolName)) return "self";
|
|
330
|
+
return hasOwnRenderer(this) ? origShell.call(this) : "self";
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
proto.getCallRenderer = function () {
|
|
334
|
+
const name: string = this.toolName;
|
|
335
|
+
const own = origCall.call(this);
|
|
336
|
+
if (own && !pakaiCallMereka(name)) return own;
|
|
337
|
+
return (args: any, th: Theme, ctx: TCtx) =>
|
|
338
|
+
new Lines((width) => {
|
|
339
|
+
if ((ctx.state as Record<string, unknown> | undefined)?.hasResult) return [];
|
|
340
|
+
const icon = th.fg("warning", spinIcon());
|
|
341
|
+
const theirs = own ? componentLines(own.call(this, args, th, ctx), width - 6) : [];
|
|
342
|
+
if (name === "todo" && theirs.length)
|
|
343
|
+
return box(th, width, formatTodoRows(theirs, th), "toolPendingBg");
|
|
344
|
+
if (pakaiCallMereka(name) && theirs.length)
|
|
345
|
+
return box(th, width, boxedRows(icon, capped(th, theirs, Boolean(this.expanded))));
|
|
346
|
+
const inner = Math.max(8, width - 4);
|
|
347
|
+
return box(th, width, titleRow(th, icon, th.fg("accent", displayName(name)), "", inner, "", false), boxBgOf(name));
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
proto.getResultRenderer = function () {
|
|
352
|
+
const name: string = this.toolName;
|
|
353
|
+
const own = origResult.call(this);
|
|
354
|
+
// MCP: their result renderer is deliberately skipped (unbounded error dump).
|
|
355
|
+
if (own && !pakaiCallMereka(name)) return own;
|
|
356
|
+
const boxed = BOXED_TOOLS.has(name);
|
|
357
|
+
return (result: TResult, opts: { expanded: boolean; isPartial?: boolean }, th: Theme, ctx: TCtx) => {
|
|
358
|
+
if (opts.isPartial) return EMPTY;
|
|
359
|
+
((ctx.state ??= {}) as Record<string, unknown>).hasResult = true;
|
|
360
|
+
const text = textOf(result);
|
|
361
|
+
// pi-web-access reports failure via `details.error` without throwing, so
|
|
362
|
+
// ctx.isError alone is not enough: without this the error box shows ✓.
|
|
363
|
+
const isErr = Boolean(ctx.isError || result.isError || (result.details as { error?: unknown } | undefined)?.error);
|
|
364
|
+
// Errors use our own path: pi-web-access' own error box is a box inside a
|
|
365
|
+
// box. Their renderer content is only used on success.
|
|
366
|
+
const theirs =
|
|
367
|
+
boxed && !isErr
|
|
368
|
+
? own.call(this, { content: result.content, details: result.details }, opts, th, ctx)
|
|
369
|
+
: null;
|
|
370
|
+
return new Lines((width) => {
|
|
371
|
+
const tint = tintOf(th);
|
|
372
|
+
const inner = Math.max(8, width - 4);
|
|
373
|
+
const icon = isErr ? th.fg("error", "x") : th.fg("success", "✓");
|
|
374
|
+
const boxed2 = componentLines(theirs, width - 6);
|
|
375
|
+
if (name === "todo" && boxed2.length) {
|
|
376
|
+
// todo call result (status completed / in_progress)
|
|
377
|
+
const callComp = origCall.call(this)?.call(this, ctx.args, th, ctx);
|
|
378
|
+
const callLines = componentLines(callComp, width - 6);
|
|
379
|
+
const combined = [...callLines, ...boxed2];
|
|
380
|
+
const bgType = isErr ? "toolErrorBg" : "toolSuccessBg";
|
|
381
|
+
return box(th, width, formatTodoRows(combined, th), bgType);
|
|
382
|
+
}
|
|
383
|
+
if (boxed2.length) return box(th, width, boxedRows(icon, boxed2));
|
|
384
|
+
const rows = titleRow(
|
|
385
|
+
th,
|
|
386
|
+
icon,
|
|
387
|
+
th.fg("accent", displayName(name)),
|
|
388
|
+
mcpInfo(name, ctx.args, th),
|
|
389
|
+
inner,
|
|
390
|
+
"",
|
|
391
|
+
opts.expanded,
|
|
392
|
+
);
|
|
393
|
+
const summary = toolSummary(name, text, ctx.args);
|
|
394
|
+
const more = countLines(text) > 1;
|
|
395
|
+
const hint = more && !opts.expanded ? ` ${expandHint(th)}` : "";
|
|
396
|
+
rows.push(`${resHead(th)}${summary ? ` ${paintLinks(summary, tint)}` : ""}${hint}`);
|
|
397
|
+
if (opts.expanded && more) {
|
|
398
|
+
// MCP args are gone once the call box is replaced by the result.
|
|
399
|
+
if (MCP_TOOL(name)) rows.push(th.fg("dim", `args ${JSON.stringify(ctx.args ?? {})}`));
|
|
400
|
+
rows.push(...fullText(result, th, inner));
|
|
401
|
+
}
|
|
402
|
+
return box(th, width, rows, boxBgOf(name));
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// todo line format: \uf14a icon (success color) in the title, resHead prefix on the status line.
|
|
409
|
+
const formatTodoRows = (lines: string[], th: Theme): string[] => {
|
|
410
|
+
const icon = th.fg("success", "\uf14a");
|
|
411
|
+
const head = resHead(th);
|
|
412
|
+
const out: string[] = [];
|
|
413
|
+
for (let i = 0; i < lines.length; i++) {
|
|
414
|
+
let l = lines[i];
|
|
415
|
+
if (i === 0) {
|
|
416
|
+
l = l.replace(/^(\x1b\[[0-9;]*m)*todo\b/, `${icon} todo`);
|
|
417
|
+
} else {
|
|
418
|
+
l = `${head} ${l}`;
|
|
419
|
+
}
|
|
420
|
+
out.push(l);
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
// One render path for all tools (no per-tool duplication).
|
|
427
|
+
// renderCall = running box (); renderResult = final box (✓/x + right-aligned
|
|
428
|
+
// duration + summary); state.hasResult is written by renderResult and read at
|
|
429
|
+
// render() -> exactly one box, also after reload/restore. res & full are optional
|
|
430
|
+
// (default: first output line / dim detail).
|
|
431
|
+
function minimal(
|
|
432
|
+
pi: ExtensionAPI,
|
|
433
|
+
tool: { execute: (...a: any[]) => Promise<any> } & Record<string, unknown>,
|
|
434
|
+
name: (th: Theme, tint: (s: string) => string) => string,
|
|
435
|
+
call: (a: any, th: Theme, tint: (s: string) => string, expanded: boolean) => string,
|
|
436
|
+
res: Res = resText,
|
|
437
|
+
full: (r: TResult, th: Theme) => string[] = fullText,
|
|
438
|
+
): void {
|
|
439
|
+
OWN_BOX.add(String(tool.name ?? ""));
|
|
440
|
+
pi.registerTool({
|
|
441
|
+
...tool,
|
|
442
|
+
renderShell: "self",
|
|
443
|
+
async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
|
|
444
|
+
const start = performance.now();
|
|
445
|
+
ACTIVE++;
|
|
446
|
+
syncTicker();
|
|
447
|
+
try {
|
|
448
|
+
return await tool.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
449
|
+
} finally {
|
|
450
|
+
TIMINGS.set(toolCallId, Math.round(performance.now() - start));
|
|
451
|
+
ACTIVE--;
|
|
452
|
+
syncTicker();
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
renderCall(args: any, theme: Theme, context: TCtx) {
|
|
456
|
+
const st = (context.state ?? {}) as Record<string, unknown>;
|
|
457
|
+
return new Lines((width) => {
|
|
458
|
+
if (st.hasResult) return [];
|
|
459
|
+
const tint = tintOf(theme);
|
|
460
|
+
const inner = Math.max(8, width - 4);
|
|
461
|
+
const rows = titleRow(
|
|
462
|
+
theme,
|
|
463
|
+
theme.fg("warning", spinIcon()),
|
|
464
|
+
name(theme, tint),
|
|
465
|
+
call(args, theme, tint, false),
|
|
466
|
+
inner,
|
|
467
|
+
"",
|
|
468
|
+
false,
|
|
469
|
+
);
|
|
470
|
+
return box(theme, width, rows);
|
|
471
|
+
});
|
|
472
|
+
},
|
|
473
|
+
renderResult(
|
|
474
|
+
result: TResult,
|
|
475
|
+
{ expanded, isPartial }: { expanded: boolean; isPartial: boolean },
|
|
476
|
+
theme: Theme,
|
|
477
|
+
context: TCtx,
|
|
478
|
+
) {
|
|
479
|
+
if (isPartial) return EMPTY;
|
|
480
|
+
((context.state ??= {}) as Record<string, unknown>).hasResult = true;
|
|
481
|
+
const text = textOf(result);
|
|
482
|
+
const isErr = Boolean(context.isError || result.isError || text.startsWith("Error"));
|
|
483
|
+
const ms = context.toolCallId ? TIMINGS.get(context.toolCallId) : undefined;
|
|
484
|
+
const dur = ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : "";
|
|
485
|
+
const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
|
|
486
|
+
return new Lines((width) => {
|
|
487
|
+
const tint = tintOf(theme);
|
|
488
|
+
const inner = Math.max(8, width - 4);
|
|
489
|
+
const rows = titleRow(
|
|
490
|
+
theme,
|
|
491
|
+
icon,
|
|
492
|
+
name(theme, tint),
|
|
493
|
+
call(context.args ?? {}, theme, tint, expanded),
|
|
494
|
+
inner,
|
|
495
|
+
dur,
|
|
496
|
+
expanded,
|
|
497
|
+
);
|
|
498
|
+
const resLine = res(result, theme, tint, isErr, expanded);
|
|
499
|
+
if (resLine) rows.push(resLine);
|
|
500
|
+
if (expanded) rows.push(...full(result, theme));
|
|
501
|
+
return box(theme, width, rows);
|
|
502
|
+
});
|
|
503
|
+
},
|
|
504
|
+
} as any);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function arnativeTools(pi: ExtensionAPI) {
|
|
508
|
+
// Re-render source for the spinner + ticker cleanup when the session closes
|
|
509
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
510
|
+
requestRenderFn = () => {
|
|
511
|
+
try {
|
|
512
|
+
(ctx.ui as { requestRender?: () => void }).requestRender?.();
|
|
513
|
+
} catch {
|
|
514
|
+
// ignore
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
pi.on("session_shutdown", async () => {
|
|
519
|
+
if (TICK) {
|
|
520
|
+
clearInterval(TICK);
|
|
521
|
+
TICK = null;
|
|
522
|
+
}
|
|
523
|
+
requestRenderFn = null;
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// Gap is only stripped for our own boxes (see OWN_BOX above).
|
|
527
|
+
|
|
528
|
+
// bash: ` $ <6 words>…` / `✓ $ <full title when expanded> 0.1s`
|
|
529
|
+
minimal(
|
|
530
|
+
pi,
|
|
531
|
+
createBashTool(cwd),
|
|
532
|
+
(th) => th.fg("accent", "$"),
|
|
533
|
+
(a, _th, tint, expanded) => {
|
|
534
|
+
const cmd = (a.command ?? "").replace(/\r?\n/g, " ").trim();
|
|
535
|
+
return paintLinks(expanded ? cmd : smartTitle(cmd), tint);
|
|
536
|
+
},
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// read: `read path[:range]` → ` N lines`
|
|
540
|
+
minimal(
|
|
541
|
+
pi,
|
|
542
|
+
createReadTool(cwd),
|
|
543
|
+
(th) => th.fg("accent", "read"),
|
|
544
|
+
(a, _th, tint) => {
|
|
545
|
+
const range =
|
|
546
|
+
a.offset || a.limit ? `:${a.offset ?? 1}${a.limit ? `-${(a.offset ?? 1) + a.limit - 1}` : ""}` : "";
|
|
547
|
+
return `${tint(shortPath(a.path ?? ""))}${range}`;
|
|
548
|
+
},
|
|
549
|
+
numRes((n) => `${n} lines`),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// grep: `grep /pattern/ in path (glob)` → ` → N matches`
|
|
553
|
+
minimal(
|
|
554
|
+
pi,
|
|
555
|
+
createGrepTool(cwd),
|
|
556
|
+
(th) => th.fg("accent", "grep"),
|
|
557
|
+
(a, _th, tint) =>
|
|
558
|
+
`/${a.pattern ?? ""}/ in ${tint(shortPath(a.path ?? "."))}${a.glob ? ` (${a.glob})` : ""}`,
|
|
559
|
+
numRes((n) => `→ ${n} matches`),
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// find: `find pattern in path` → ` → N files`
|
|
563
|
+
minimal(
|
|
564
|
+
pi,
|
|
565
|
+
createFindTool(cwd),
|
|
566
|
+
(th) => th.fg("accent", "find"),
|
|
567
|
+
(a, _th, tint) => `${a.pattern ?? ""} in ${tint(shortPath(a.path ?? "."))}`,
|
|
568
|
+
numRes((n) => `→ ${n} files`),
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
// write: `write path`, default res (first output line)
|
|
572
|
+
minimal(
|
|
573
|
+
pi,
|
|
574
|
+
createWriteTool(cwd),
|
|
575
|
+
(th) => th.fg("accent", "write"),
|
|
576
|
+
(a, _th, tint) => tint(shortPath(a.path ?? "")),
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
// edit: `edit path`, ` +N / -M`, expand = diff toolDiff*
|
|
580
|
+
minimal(
|
|
581
|
+
pi,
|
|
582
|
+
createEditTool(cwd),
|
|
583
|
+
(th) => th.fg("accent", "edit"),
|
|
584
|
+
(a, _th, tint) => tint(shortPath(a.path ?? "")),
|
|
585
|
+
(r, th, _tint, isErr, expanded) => {
|
|
586
|
+
if (isErr) return resText(r, th, (s) => s, isErr, false);
|
|
587
|
+
const diff: string = r.details?.diff ?? "";
|
|
588
|
+
let add = 0;
|
|
589
|
+
let del = 0;
|
|
590
|
+
for (const l of diff.split("\n")) {
|
|
591
|
+
if (l.startsWith("+") && !l.startsWith("+++")) add++;
|
|
592
|
+
else if (l.startsWith("-") && !l.startsWith("---")) del++;
|
|
593
|
+
}
|
|
594
|
+
return `${resHead(th)} ${th.fg("toolDiffAdded", `+${add}`)} / ${th.fg("toolDiffRemoved", `-${del}`)}`;
|
|
595
|
+
},
|
|
596
|
+
(r, th) => {
|
|
597
|
+
const diff: string = r.details?.diff ?? "";
|
|
598
|
+
if (!diff) return fullText(r, th);
|
|
599
|
+
return diff.split("\n").map((l) =>
|
|
600
|
+
l.startsWith("+") && !l.startsWith("+++")
|
|
601
|
+
? th.fg("toolDiffAdded", l)
|
|
602
|
+
: l.startsWith("-") && !l.startsWith("---")
|
|
603
|
+
? th.fg("toolDiffRemoved", l)
|
|
604
|
+
: th.fg("toolDiffContext", l),
|
|
605
|
+
);
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export default arnativeTools;
|
|
611
|
+
|
|
612
|
+
// Self-check: `node extensions/tools.ts`.
|
|
613
|
+
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
|
|
614
|
+
if (isMain) {
|
|
615
|
+
const assert = (cond: boolean, msg: string) => {
|
|
616
|
+
if (!cond) {
|
|
617
|
+
console.error(`FAIL: ${msg}`);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const mark = (s: string) => `<${s}>`;
|
|
622
|
+
const th: Theme = { fg: (_c, s) => s };
|
|
623
|
+
const R = { content: [{ type: "text", text: "path:1:match a\npath:2:match b\npath:3:match c" }] };
|
|
624
|
+
|
|
625
|
+
assert(smartTitle("echo hi") === "echo hi", "teks pendek utuh");
|
|
626
|
+
assert(smartTitle("ls a b c d e f g h") === "ls a b c d e…", "potong di batas 6 kata");
|
|
627
|
+
assert(smartTitle("a b c d e f g") === "a b c d e f…", "7 kata -> 6 kata + elipsis");
|
|
628
|
+
assert(smartTitle("a b c d e f") === "a b c d e f", "6 kata utuh");
|
|
629
|
+
|
|
630
|
+
assert(paintLinks("cd /run/media/x && echo hi", mark) === "cd </run/media/x> && echo hi", "filter path absolut");
|
|
631
|
+
assert(paintLinks("git clone https://github.com/a/b", mark) === "git clone <https://github.com/a/b>", "filter link URL");
|
|
632
|
+
assert(paintLinks("ls extensions/tools.ts", mark) === "ls <extensions/tools.ts>", "filter path relatif");
|
|
633
|
+
assert(paintLinks("echo plain 2>&1", mark) === "echo plain 2>&1", "teks polos tak tersentuh");
|
|
634
|
+
|
|
635
|
+
// right-aligned duration, space-aware (overflow bug regression)
|
|
636
|
+
const t1 = titleRow(th, "✓", "$", "echo hi", 30, "0.1s", false);
|
|
637
|
+
assert(t1.length === 1 && visibleWidth(t1[0]) === 30, "durasi rata kanan pas selebar inner");
|
|
638
|
+
assert(t1[0].endsWith("0.1s"), "durasi di ujung kanan judul");
|
|
639
|
+
const t2 = titleRow(th, "✓", "$", "word ".repeat(40), 30, "0.1s", false);
|
|
640
|
+
assert(t2.length === 1 && visibleWidth(t2[0]) === 30, "collapsed: judul panjang tetap 1 baris pas");
|
|
641
|
+
const t3 = titleRow(th, "✓", "$", "word ".repeat(40), 30, "0.1s", true);
|
|
642
|
+
assert(t3.every((l) => visibleWidth(l) <= 30), "expanded: semua baris <= inner");
|
|
643
|
+
const t4 = titleRow(th, "✓", "$", "echo hi", 30, "", false);
|
|
644
|
+
assert(!t4[0].includes("0.1s"), "tanpa durasi: kosong (restore sesi lama)");
|
|
645
|
+
|
|
646
|
+
// no duplicate summary
|
|
647
|
+
assert(resText(R, th, (s) => s, false, true) === "", "expand: ringkasan kosong");
|
|
648
|
+
assert(resText(R, th, (s) => s, false, false) === " path:1:match a", "collapsed: baris pertama tampil");
|
|
649
|
+
assert(numRes((n) => `→ ${n} matches`)(R, th, (s) => s, false, false) === " → 3 matches", "grep: N matches");
|
|
650
|
+
assert(
|
|
651
|
+
numRes((n) => `→ ${n} matches`)({ content: [{ type: "text", text: "Error: bad" }] }, th, (s) => s, true, false) ===
|
|
652
|
+
" Error: bad",
|
|
653
|
+
"error: jatuh ke baris pertama",
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
// inter-box gap stripped
|
|
657
|
+
assert(stripBlankEdges(["", "a", "", "b", " ", ""]).join() === "a,,b", "baris kosong tepi dibuang");
|
|
658
|
+
assert(stripBlankEdges(["", "\x1b[2m\x1b[22m", "x"]).join() === "x", "baris ANSI kosong = blank");
|
|
659
|
+
assert(stripBlankEdges(["a"]).join() === "a", "tanpa blank tetap utuh");
|
|
660
|
+
|
|
661
|
+
// tools without their own renderer -> same box (memory_write etc.)
|
|
662
|
+
const noRenderer = { toolName: "memory_write", toolDefinition: {} };
|
|
663
|
+
// tool with no definition (e.g. "Tool glob not found") -> still a box, not plain text
|
|
664
|
+
const noDefinition = { toolName: "glob", toolDefinition: undefined, expanded: false };
|
|
665
|
+
assert(
|
|
666
|
+
ToolExecutionComponent.prototype.hasRendererDefinition.call(noDefinition) === true &&
|
|
667
|
+
ToolExecutionComponent.prototype.getRenderShell.call(noDefinition) === "self",
|
|
668
|
+
"tool tanpa definisi: shell self (kotak kita, bukan fallback polos)",
|
|
669
|
+
);
|
|
670
|
+
assert(
|
|
671
|
+
ToolExecutionComponent.prototype.hasRendererDefinition.call({ toolName: "x", toolDefinition: {} }) === true,
|
|
672
|
+
"tool dengan definisi tetap punya renderer",
|
|
673
|
+
);
|
|
674
|
+
const noDefOut = (ToolExecutionComponent.prototype.getResultRenderer.call(noDefinition) as any)(
|
|
675
|
+
{ content: [{ type: "text", text: '{\n "pattern": "**/*.ts"\n}\nTool glob not found' }] },
|
|
676
|
+
{ expanded: false, isPartial: false },
|
|
677
|
+
th,
|
|
678
|
+
{ args: { pattern: "**/*.ts" }, state: {} },
|
|
679
|
+
).render(60);
|
|
680
|
+
assert(noDefOut[0].startsWith("\u256d") && noDefOut[noDefOut.length - 1].startsWith("\u2570"), "tool tanpa definisi: hasil dibungkus kotak");
|
|
681
|
+
assert(
|
|
682
|
+
noDefOut.some((l: string) => l.includes("\u2713 glob")) &&
|
|
683
|
+
noDefOut.some((l: string) => l.includes('[ctrl+o to expand]')),
|
|
684
|
+
"tool tanpa definisi: judul + ringkasan + expand hint",
|
|
685
|
+
);
|
|
686
|
+
const withRenderer = { toolName: "bash", toolDefinition: { renderResult: () => undefined } };
|
|
687
|
+
assert(ToolExecutionComponent.prototype.getRenderShell.call(noRenderer) === "self", "tool polos -> shell self");
|
|
688
|
+
assert(
|
|
689
|
+
ToolExecutionComponent.prototype.getRenderShell.call(withRenderer) === "default",
|
|
690
|
+
"tool ber-renderer -> shell aslinya (tak disentuh)",
|
|
691
|
+
);
|
|
692
|
+
assert(
|
|
693
|
+
toolSummary("memory_write", "Appended to MEMORY.md\n\nExisting MEMORY.md preview (1 lines)", {
|
|
694
|
+
content: "## omaga-sync workflow (2026-08-23)\n- Repo: /x",
|
|
695
|
+
}) === "Appended to MEMORY.md. ## omaga-sync workflow (2026-08-23)",
|
|
696
|
+
"memory_write: ringkasan + judul seksi yang baru ditulis",
|
|
697
|
+
);
|
|
698
|
+
assert(
|
|
699
|
+
toolSummary("memory_write", "Appended to daily log: /x/y.md", { content: "- catatan tanpa judul" }) ===
|
|
700
|
+
"Appended to daily log: /x/y.md",
|
|
701
|
+
"memory_write daily: tanpa judul tetap baris pertama",
|
|
702
|
+
);
|
|
703
|
+
assert(toolSummary("todo", "line1\nline2") === "line1", "tool lain: baris pertama saja");
|
|
704
|
+
assert(
|
|
705
|
+
toolSummary("mcp", '{\n "url": "https://example.com",\n "ok": true\n}') === '"url": "https://example.com",',
|
|
706
|
+
"baris pertama hanya '{' -> ringkasan baris berikutnya (JSON MCP)",
|
|
707
|
+
);
|
|
708
|
+
assert(
|
|
709
|
+
toolSummary("mcp", '{\n "url": "https://example.com",\n "ok": true\n}', {}) === '"url": "https://example.com",' &&
|
|
710
|
+
toolSummary("mcp", "{") === "{",
|
|
711
|
+
"ringkasan JSON: pakai baris berisi, fallback ke baris pertama",
|
|
712
|
+
);
|
|
713
|
+
assert(toolSummary("memory_write", "Appended to MEMORY.md") === "Appended to MEMORY.md", "ballast: ringkasan biasa utuh");
|
|
714
|
+
|
|
715
|
+
// distinct subtle bg: memory_write only
|
|
716
|
+
assert(boxBgOf("memory_write") === "customMessageBg", "memory_write -> bg customMessageBg");
|
|
717
|
+
assert(boxBgOf("bash") === undefined && boxBgOf("todo") === undefined, "tool lain tanpa bg tambahan");
|
|
718
|
+
const bgSeen: string[] = [];
|
|
719
|
+
const thBgMem: Theme = { fg: (_c, s) => s, bg: (c, s) => (bgSeen.push(c), `<${s}>`) };
|
|
720
|
+
const bgLines = box(thBgMem, 20, ["memory_write 0.1s", " Appended to MEMORY.md"], boxBgOf("memory_write"));
|
|
721
|
+
assert(bgSeen.length === bgLines.length && bgSeen.every((c) => c === "customMessageBg"), "bg dipakai di semua baris kotak");
|
|
722
|
+
assert(bgLines.every((l) => visibleWidth(l.replace(/[<>]/g, "")) === 20), "bg tidak merusak lebar baris kotak");
|
|
723
|
+
assert(bgLines[0]!.replace(/[<>]/g, "").startsWith("╭"), "garis atas tetap utuh");
|
|
724
|
+
|
|
725
|
+
// pi-web-access web_search/fetch_content/get_search_content: isi renderer mereka, kotak kita
|
|
726
|
+
const paResult: any = { render: () => ["\x1b[32mPi Coding Agent\x1b[39m (77 matches, 77 shown)"] };
|
|
727
|
+
const paCall: any = { render: () => ["\x1b[1mget_content \x1b[22m\x1b[36mfind 4\x1b[39m"] };
|
|
728
|
+
const boxedRes = {
|
|
729
|
+
toolName: "get_search_content",
|
|
730
|
+
toolDefinition: { renderCall: () => paCall, renderResult: () => paResult },
|
|
731
|
+
};
|
|
732
|
+
assert(
|
|
733
|
+
ToolExecutionComponent.prototype.getRenderShell.call(boxedRes) === "self",
|
|
734
|
+
"pi-web-access: shell dipaksa self (ikut kotak kita, bukan blok bg)",
|
|
735
|
+
);
|
|
736
|
+
const searchRes = {
|
|
737
|
+
toolName: "web_search",
|
|
738
|
+
toolDefinition: { renderCall: () => ({ render: () => ["\x1b[1msearch \x1b[22m\x1b[36m\"site:pi.dev\"\x1b[39m"] }), renderResult: () => ({ render: () => ["\x1b[32m5 sources\x1b[39m"] }) },
|
|
739
|
+
};
|
|
740
|
+
assert(
|
|
741
|
+
ToolExecutionComponent.prototype.getRenderShell.call(searchRes) === "self",
|
|
742
|
+
"web_search: shell dipaksa self (ikut kotak kita)",
|
|
743
|
+
);
|
|
744
|
+
const bRes = ToolExecutionComponent.prototype.getResultRenderer.call(boxedRes) as any;
|
|
745
|
+
const bOut = bRes(
|
|
746
|
+
{ content: [{ type: "text", text: "raw panjang yang tak perlu ditampilkan" }], details: { matchCount: 77 } },
|
|
747
|
+
{ expanded: false, isPartial: false },
|
|
748
|
+
th,
|
|
749
|
+
{ args: {} },
|
|
750
|
+
).render(60);
|
|
751
|
+
assert(bOut[0].startsWith("╭") && bOut[bOut.length - 1].startsWith("╰"), "pi-web-access: hasil dibungkus kotak kita");
|
|
752
|
+
assert(bOut.some((l: string) => l.includes("(77 matches, 77 shown)")), "pi-web-access: info renderer mereka dipertahankan");
|
|
753
|
+
assert(!bOut.some((l: string) => l.includes("raw panjang")), "pi-web-access: teks mentah tidak dipakai saat renderer ada");
|
|
754
|
+
assert(
|
|
755
|
+
bRes(
|
|
756
|
+
{
|
|
757
|
+
content: [{ type: "text", text: "No URL specified. Provide url, urlIndex, or query." }],
|
|
758
|
+
details: { error: "No URL specified" },
|
|
759
|
+
},
|
|
760
|
+
{ expanded: false, isPartial: false },
|
|
761
|
+
th,
|
|
762
|
+
{ args: {} },
|
|
763
|
+
)
|
|
764
|
+
.render(60)
|
|
765
|
+
.some((l: string) => l.includes("x get_content")),
|
|
766
|
+
"pi-web-access: details.error -> ikon x (bukan ✓)",
|
|
767
|
+
);
|
|
768
|
+
const bErr = bRes(
|
|
769
|
+
{ content: [{ type: "text", text: "No URL specified. Provide url, urlIndex, or query." }] },
|
|
770
|
+
{ expanded: false, isPartial: false },
|
|
771
|
+
th,
|
|
772
|
+
{ args: {}, isError: true },
|
|
773
|
+
).render(60);
|
|
774
|
+
assert(
|
|
775
|
+
bErr.some((l: string) => l.includes("No URL specified")) &&
|
|
776
|
+
bErr.filter((l: string) => l.includes("╭")).length === 1,
|
|
777
|
+
"error: satu kotak saja (tanpa kotak bersarang)",
|
|
778
|
+
);
|
|
779
|
+
assert(
|
|
780
|
+
bRes({ content: [{ type: "text", text: "x" }] }, { expanded: false, isPartial: true }, th, { args: {} }).render(60)
|
|
781
|
+
.length === 0,
|
|
782
|
+
"pi-web-access: fase partial kosong (tanpa kotak ganda)",
|
|
783
|
+
);
|
|
784
|
+
const bCall = ToolExecutionComponent.prototype.getCallRenderer.call(boxedRes) as any;
|
|
785
|
+
const bCallOut = bCall({}, th, { args: {}, state: {} }).render(60);
|
|
786
|
+
assert(
|
|
787
|
+
bCallOut[0].startsWith("╭") && bCallOut.some((l: string) => l.includes("find 4")),
|
|
788
|
+
"pi-web-access: baris args renderer mereka masuk kotak",
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
// Guard: source_check is deliberately NOT boxed — its curator partial carries
|
|
792
|
+
// URLs + approval state, while our partial path returns nothing. Without this
|
|
793
|
+
// assert a later edit of BOXED_TOOLS would swallow it silently.
|
|
794
|
+
for (const n of ["source_check"]) {
|
|
795
|
+
const theirs = () => "renderer-mereka";
|
|
796
|
+
assert(
|
|
797
|
+
ToolExecutionComponent.prototype.getResultRenderer.call({ toolName: n, toolDefinition: { renderResult: theirs } })() ===
|
|
798
|
+
"renderer-mereka",
|
|
799
|
+
`${n}: sengaja tidak dipaksa kotak (partial kurator butuh URL)`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// pi-mcp-adapter: result does NOT use their renderer (unbounded error dump)
|
|
804
|
+
// -> 1-line summary + [ctrl+o to expand]; the invoked tool stays visible.
|
|
805
|
+
const mcpTheirs = () => "renderer-mcp";
|
|
806
|
+
const mcpTool = { toolName: "mcp", toolDefinition: { renderCall: mcpTheirs, renderResult: mcpTheirs } };
|
|
807
|
+
assert(
|
|
808
|
+
(ToolExecutionComponent.prototype.getResultRenderer.call(mcpTool) as any) !== mcpTheirs,
|
|
809
|
+
"mcp: renderer hasil pihak ketiga dilewati (dump error dibatasi)",
|
|
810
|
+
);
|
|
811
|
+
assert(
|
|
812
|
+
(ToolExecutionComponent.prototype.getResultRenderer.call({ toolName: "mcp__tinyfish", toolDefinition: { renderResult: mcpTheirs } }) as any) !==
|
|
813
|
+
mcpTheirs,
|
|
814
|
+
"mcp__<server>: renderer mereka dilewati (jalur kotak kita)",
|
|
815
|
+
);
|
|
816
|
+
const mcpPayload = {
|
|
817
|
+
content: [{ type: "text", text: "x Failed to call tool\nu Expected parameters:\n{\n \"required\": []\n}" }],
|
|
818
|
+
details: { error: "failed" },
|
|
819
|
+
};
|
|
820
|
+
const mcpOut = (ToolExecutionComponent.prototype.getResultRenderer.call(mcpTool) as any)(
|
|
821
|
+
mcpPayload,
|
|
822
|
+
{ expanded: false, isPartial: false },
|
|
823
|
+
th,
|
|
824
|
+
{ args: { tool: "fetch_content", server: "tinyfish" }, state: {} },
|
|
825
|
+
).render(96);
|
|
826
|
+
assert(mcpOut.length <= 6, `mcp error collapsed tetap pendek (${mcpOut.length} baris)`);
|
|
827
|
+
assert(
|
|
828
|
+
mcpOut.some((l: string) => l.includes("fetch_content @ tinyfish")),
|
|
829
|
+
"mcp: judul menyebut tool MCP yang dipanggil",
|
|
830
|
+
);
|
|
831
|
+
assert(mcpOut.some((l: string) => l.includes("[ctrl+o to expand]")), "mcp: ada petunjuk expand");
|
|
832
|
+
const mcpFull = (ToolExecutionComponent.prototype.getResultRenderer.call(mcpTool) as any)(
|
|
833
|
+
mcpPayload,
|
|
834
|
+
{ expanded: true, isPartial: false },
|
|
835
|
+
th,
|
|
836
|
+
{ args: { tool: "fetch_content", server: "tinyfish" }, state: {} },
|
|
837
|
+
).render(96);
|
|
838
|
+
assert(mcpFull.length > mcpOut.length, "mcp: expand menampilkan dump + args lengkap");
|
|
839
|
+
|
|
840
|
+
// Gap is only stripped for our own box lines.
|
|
841
|
+
// The default export registers the tools (filling OWN_BOX); a pi stub suffices.
|
|
842
|
+
arnativeTools({
|
|
843
|
+
registerTool: () => {},
|
|
844
|
+
on: () => {},
|
|
845
|
+
registerShortcut: () => {},
|
|
846
|
+
registerMessageRenderer: () => {},
|
|
847
|
+
} as never);
|
|
848
|
+
assert(
|
|
849
|
+
OWN_BOX.has("bash") && OWN_BOX.has("read") && OWN_BOX.has("edit") && OWN_BOX.has("fetch_content") && OWN_BOX.has("web_search"),
|
|
850
|
+
"OWN_BOX: tool kotak kita + tool boxed terdaftar",
|
|
851
|
+
);
|
|
852
|
+
assert(!OWN_BOX.has("mcp") && !OWN_BOX.has("source_check"), "OWN_BOX: renderer pihak lain tidak dirapatkan");
|
|
853
|
+
assert(hasOwnRendererDef(mcpTool) && !hasOwnRendererDef(noRenderer), "patokan rapat: definisi renderer tool");
|
|
854
|
+
assert(
|
|
855
|
+
capped(th, ["a", "b", "c"], false).length === CALL_ROWS + 1 &&
|
|
856
|
+
capped(th, ["a", "b", "c"], true).length === 3 &&
|
|
857
|
+
capped(th, ["a"], false).length === 1,
|
|
858
|
+
"baris call pihak ketiga dibatasi hanya saat collapsed",
|
|
859
|
+
);
|
|
860
|
+
assert(
|
|
861
|
+
mcpInfo("bash", { tool: "x" }, th) === "" && mcpInfo("mcp", { tool: "x", server: "s" }, th).includes("x @ s"),
|
|
862
|
+
"mcpInfo hanya untuk tool MCP",
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
const ctxState: TCtx = { args: { content: "## Judul\n- x" }, state: {} };
|
|
866
|
+
const resRenderer = ToolExecutionComponent.prototype.getResultRenderer.call(noRenderer) as any;
|
|
867
|
+
const out = resRenderer(
|
|
868
|
+
{ content: [{ type: "text", text: "Appended to MEMORY.md\n\nExisting MEMORY.md preview" }] },
|
|
869
|
+
{ expanded: false, isPartial: false },
|
|
870
|
+
th,
|
|
871
|
+
ctxState,
|
|
872
|
+
).render(60);
|
|
873
|
+
assert(out[0].startsWith("╭") && out[out.length - 1].startsWith("╰"), "hasil tool polos dibungkus kotak");
|
|
874
|
+
assert(
|
|
875
|
+
out.some((l: string) => l.includes("Appended to MEMORY.md. ## Judul")),
|
|
876
|
+
"kotak memuat ringkasan + judul",
|
|
877
|
+
);
|
|
878
|
+
assert(out.some((l: string) => l.includes("[ctrl+o to expand]")), "collapsed: ada petunjuk expand");
|
|
879
|
+
assert(
|
|
880
|
+
resRenderer(
|
|
881
|
+
{ content: [{ type: "text", text: "Appended to MEMORY.md\n\nExisting preview" }] },
|
|
882
|
+
{ expanded: true, isPartial: false },
|
|
883
|
+
th,
|
|
884
|
+
ctxState,
|
|
885
|
+
).render(60).length > out.length,
|
|
886
|
+
"expand: detail lengkap ikut tampil",
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
// stale box regression (restore-safe)
|
|
890
|
+
const st: Record<string, unknown> = {};
|
|
891
|
+
const comp = new Lines(() => (st.hasResult ? [] : ["row"]));
|
|
892
|
+
assert(comp.render(10).length === 1, "running: kotak tampil");
|
|
893
|
+
st.hasResult = true;
|
|
894
|
+
assert(comp.render(10).length === 0, "final: kotak running hilang (restore-safe)");
|
|
895
|
+
assert(FRAMES.includes(spinIcon()), "ikon animasi selalu frame valid");
|
|
896
|
+
ACTIVE = 3;
|
|
897
|
+
syncTicker();
|
|
898
|
+
assert(TICK !== null, "ticker hidup selama ada tool berjalan");
|
|
899
|
+
ACTIVE = 0;
|
|
900
|
+
syncTicker();
|
|
901
|
+
assert(TICK === null, "ticker mati saat idle (tanpa leak timer)");
|
|
902
|
+
|
|
903
|
+
// todo render: \uf14a + 2 lines (title & status) + background box
|
|
904
|
+
const todoCall = () => ({ render: () => ["todo → Test subject"] });
|
|
905
|
+
const todoResult = () => ({ render: () => ["● completed"] });
|
|
906
|
+
const todoTool = { toolName: "todo", toolDefinition: { renderCall: todoCall, renderResult: todoResult } };
|
|
907
|
+
const thBgTodo: Theme = {
|
|
908
|
+
fg: (_c, s) => s,
|
|
909
|
+
bg: (c, s) => `[bg:${c}]${s}[/bg]`,
|
|
910
|
+
};
|
|
911
|
+
const todoCallOut = (ToolExecutionComponent.prototype.getCallRenderer.call(todoTool) as any)({}, thBgTodo, { state: {} }).render(60);
|
|
912
|
+
assert(todoCallOut[0].includes("[bg:toolPendingBg]"), "todo call: background pending");
|
|
913
|
+
assert(todoCallOut.some((l: string) => l.includes("\uf14a todo → Test subject")), "todo call: ikon \uf14a di judul");
|
|
914
|
+
|
|
915
|
+
const todoResultOut = (ToolExecutionComponent.prototype.getResultRenderer.call(todoTool) as any)(
|
|
916
|
+
{ content: [] },
|
|
917
|
+
{ expanded: false, isPartial: false },
|
|
918
|
+
thBgTodo,
|
|
919
|
+
{ state: {}, args: {} },
|
|
920
|
+
).render(60);
|
|
921
|
+
assert(todoResultOut[0].includes("[bg:toolSuccessBg]"), "todo result: background success");
|
|
922
|
+
assert(todoResultOut.some((l: string) => l.includes("\uf14a todo → Test subject")), "todo result: ikon \uf14a di baris 1");
|
|
923
|
+
assert(todoResultOut.some((l: string) => l.includes("● completed")), "todo result: status dengan prefix resHead di baris 2");
|
|
924
|
+
|
|
925
|
+
// Expanded detail: themed markdown, error text intact and no longer plain dim.
|
|
926
|
+
const errDetail = fullText(
|
|
927
|
+
{ content: [{ type: "text", text: '{\n "pattern": "**/*.ts"\n}\nTool glob not found' }], isError: true },
|
|
928
|
+
th,
|
|
929
|
+
40,
|
|
930
|
+
);
|
|
931
|
+
const plain = errDetail.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
|
932
|
+
assert(plain.includes("Tool glob not found") && plain.includes('"pattern"'), "detail error: isi utuh");
|
|
933
|
+
assert(
|
|
934
|
+
errDetail.every((l: string) => visibleWidth(l) === 40),
|
|
935
|
+
"detail error: dirender markdown (di-wrap + dipad ke lebar), bukan split baris polos",
|
|
936
|
+
);
|
|
937
|
+
assert(
|
|
938
|
+
fullText({ content: [{ type: "text", text: "a\nb" }] }, th, 0).join("|") === th.fg("dim", "a") + "|" + th.fg("dim", "b"),
|
|
939
|
+
"tanpa width (self-check) tetap dump dim",
|
|
940
|
+
);
|
|
941
|
+
console.log("OK");
|
|
942
|
+
}
|