gentle-pi 0.11.4 → 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.
@@ -1,6 +1,11 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { fileURLToPath } from "node:url";
4
+ import {
5
+ mergeDisabledTools,
6
+ PI_PRETTY_SUPPRESSED_TOOL_NAMES,
7
+ quietToolsEnabled,
8
+ } from "../lib/quiet-tools-config.ts";
4
9
 
5
10
  const packageJsonPath = realpathSync(
6
11
  fileURLToPath(new URL("../package.json", import.meta.url)),
@@ -13,4 +18,12 @@ const piPrettyExtension =
13
18
  ? piPrettyModule
14
19
  : piPrettyModule.default;
15
20
 
16
- export default piPrettyExtension;
21
+ export default async function gentlePiPrettyExtension(pi: unknown, deps?: unknown): Promise<unknown> {
22
+ if (quietToolsEnabled()) {
23
+ process.env.PRETTY_DISABLE_TOOLS = mergeDisabledTools(
24
+ process.env.PRETTY_DISABLE_TOOLS,
25
+ PI_PRETTY_SUPPRESSED_TOOL_NAMES,
26
+ );
27
+ }
28
+ return piPrettyExtension(pi, deps);
29
+ }
@@ -0,0 +1,218 @@
1
+ import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ createBashTool,
4
+ createEditTool,
5
+ createFindTool,
6
+ createGrepTool,
7
+ createLsTool,
8
+ createReadTool,
9
+ createWriteTool,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { Text } from "@earendil-works/pi-tui";
12
+ import { homedir } from "node:os";
13
+ import { quietToolsEnabled } from "../lib/quiet-tools-config.ts";
14
+ import { sanitizeTerminalText } from "../lib/terminal-theme.ts";
15
+
16
+ type QuietToolName = "read" | "bash" | "grep" | "find" | "ls" | "edit" | "write";
17
+ type ThemeLike = {
18
+ bold(value: string): string;
19
+ fg(color: string, value: string): string;
20
+ };
21
+
22
+ const TOOL_CREATORS = {
23
+ read: createReadTool,
24
+ bash: createBashTool,
25
+ grep: createGrepTool,
26
+ find: createFindTool,
27
+ ls: createLsTool,
28
+ edit: createEditTool,
29
+ write: createWriteTool,
30
+ } satisfies Record<QuietToolName, (cwd: string) => any>;
31
+
32
+ const COLLAPSED_COUNT_LABELS: Partial<Record<QuietToolName, string>> = {
33
+ grep: "matches",
34
+ find: "files",
35
+ ls: "entries",
36
+ };
37
+
38
+ const NO_COLLAPSED_RESULT_TOOLS = new Set<QuietToolName>(["read", "bash"]);
39
+ const COLLAPSED_TAIL_TOOLS = new Set<QuietToolName>(["edit", "write"]);
40
+ const COLLAPSED_TAIL_LINE_LIMIT = 10;
41
+
42
+ const EMPTY_RESULT_MESSAGES: Partial<Record<QuietToolName, string[]>> = {
43
+ grep: ["No matches found"],
44
+ find: ["No files found matching pattern"],
45
+ ls: ["Directory is empty"],
46
+ };
47
+
48
+ const toolCache = new Map<string, Record<QuietToolName, any>>();
49
+
50
+ function createBuiltInTools(cwd: string): Record<QuietToolName, any> {
51
+ return Object.fromEntries(
52
+ (Object.entries(TOOL_CREATORS) as [QuietToolName, (cwd: string) => any][]).map(
53
+ ([name, createTool]) => [name, createTool(cwd)],
54
+ ),
55
+ ) as Record<QuietToolName, any>;
56
+ }
57
+
58
+ function getBuiltInTools(cwd: string): Record<QuietToolName, any> {
59
+ let tools = toolCache.get(cwd);
60
+ if (!tools) {
61
+ tools = createBuiltInTools(cwd);
62
+ toolCache.set(cwd, tools);
63
+ }
64
+ return tools;
65
+ }
66
+
67
+ function shortenPath(path: unknown): string {
68
+ if (typeof path !== "string" || path.length === 0) return "";
69
+ const home = homedir();
70
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
71
+ }
72
+
73
+ function asString(value: unknown, fallback = ""): string {
74
+ return typeof value === "string" && value.length > 0 ? value : fallback;
75
+ }
76
+
77
+ export function countNonEmptyLines(text: string): number {
78
+ return text.split("\n").filter((line) => line.trim().length > 0).length;
79
+ }
80
+
81
+ export function tailLines(text: string, limit: number): string {
82
+ const lines = text.split("\n");
83
+ return lines.slice(Math.max(0, lines.length - limit)).join("\n");
84
+ }
85
+
86
+ export function extractTextContent(result: AgentToolResult<unknown>): string {
87
+ return result.content
88
+ .flatMap((content) => (content.type === "text" ? [content.text] : []))
89
+ .join("\n");
90
+ }
91
+
92
+ function safeText(value: string): string {
93
+ return sanitizeTerminalText(value);
94
+ }
95
+
96
+ function isEmptyResultMessage(toolName: QuietToolName, text: string): boolean {
97
+ const normalized = text.trim();
98
+ return EMPTY_RESULT_MESSAGES[toolName]?.some((message) => normalized.startsWith(message)) ?? false;
99
+ }
100
+
101
+ function isGitCommand(args: Record<string, unknown> | undefined): boolean {
102
+ const command = typeof args?.command === "string" ? args.command.trim() : "";
103
+ return /^(?:env\s+\S+=\S+\s+|command\s+|\w+=\S+\s+)*git(?:\s|$)/.test(command);
104
+ }
105
+
106
+ interface ToolResultFormatOptions {
107
+ expanded: boolean;
108
+ isError?: boolean;
109
+ args?: Record<string, unknown>;
110
+ }
111
+
112
+ export function formatToolResultOutput(
113
+ toolName: QuietToolName,
114
+ result: AgentToolResult<unknown>,
115
+ { expanded, isError = false, args }: ToolResultFormatOptions,
116
+ ): string {
117
+ const text = safeText(extractTextContent(result));
118
+ if (expanded || isError) return text ? `\n${text}` : "";
119
+
120
+ if (toolName === "bash" && isGitCommand(args)) {
121
+ const tail = tailLines(text, COLLAPSED_TAIL_LINE_LIMIT);
122
+ return tail ? `\n${tail}` : "";
123
+ }
124
+ if (NO_COLLAPSED_RESULT_TOOLS.has(toolName)) return "";
125
+ if (COLLAPSED_TAIL_TOOLS.has(toolName)) {
126
+ const tail = tailLines(text, COLLAPSED_TAIL_LINE_LIMIT);
127
+ return tail ? `\n${tail}` : "";
128
+ }
129
+ if (isEmptyResultMessage(toolName, text)) return "";
130
+
131
+ const summaryLabel = COLLAPSED_COUNT_LABELS[toolName];
132
+ if (!summaryLabel) return "";
133
+
134
+ const count = countNonEmptyLines(text);
135
+ return count > 0 ? ` → ${count} ${summaryLabel}` : "";
136
+ }
137
+
138
+ function lineRangeSuffix(args: Record<string, unknown>, theme: ThemeLike): string {
139
+ if (args.offset === undefined && args.limit === undefined) return "";
140
+ const startLine = typeof args.offset === "number" ? args.offset : 1;
141
+ const endLine = typeof args.limit === "number" ? startLine + args.limit - 1 : undefined;
142
+ return theme.fg("warning", `:${startLine}${endLine === undefined ? "" : `-${endLine}`}`);
143
+ }
144
+
145
+ function formatToolCall(toolName: QuietToolName, args: Record<string, unknown>, theme: ThemeLike): string {
146
+ switch (toolName) {
147
+ case "read": {
148
+ const path = safeText(shortenPath(args.path) || "...");
149
+ return `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", path)}${lineRangeSuffix(args, theme)}`;
150
+ }
151
+ case "bash": {
152
+ const command = safeText(asString(args.command, "..."));
153
+ const timeout = typeof args.timeout === "number" ? theme.fg("muted", ` (timeout ${args.timeout}s)`) : "";
154
+ return `${theme.fg("toolTitle", theme.bold(`$ ${command}`))}${timeout}`;
155
+ }
156
+ case "grep": {
157
+ let text = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${safeText(asString(args.pattern))}/`)} in ${safeText(shortenPath(args.path) || ".")}`;
158
+ if (typeof args.glob === "string") text += theme.fg("toolOutput", ` (${safeText(args.glob)})`);
159
+ if (typeof args.limit === "number") text += theme.fg("toolOutput", ` limit ${args.limit}`);
160
+ return text;
161
+ }
162
+ case "find": {
163
+ let text = `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", safeText(asString(args.pattern, "*")))} in ${safeText(shortenPath(args.path) || ".")}`;
164
+ if (typeof args.limit === "number") text += theme.fg("toolOutput", ` limit ${args.limit}`);
165
+ return text;
166
+ }
167
+ case "ls": {
168
+ let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", safeText(shortenPath(args.path) || "."))}`;
169
+ if (typeof args.limit === "number") text += theme.fg("toolOutput", ` limit ${args.limit}`);
170
+ return text;
171
+ }
172
+ case "edit":
173
+ return `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", safeText(shortenPath(args.path) || "..."))}`;
174
+ case "write": {
175
+ const content = typeof args.content === "string" ? args.content : "";
176
+ const lineInfo = content.length > 0 ? theme.fg("muted", ` (${content.split("\n").length} lines)`) : "";
177
+ return `${theme.fg("toolTitle", theme.bold("write"))} ${theme.fg("accent", safeText(shortenPath(args.path) || "..."))}${lineInfo}`;
178
+ }
179
+ }
180
+ }
181
+
182
+ function partialLabel(toolName: QuietToolName): string {
183
+ return toolName === "bash" ? "Running..." : `${toolName}...`;
184
+ }
185
+
186
+ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName): void {
187
+ const registrationTool = getBuiltInTools(process.cwd())[toolName];
188
+
189
+ pi.registerTool({
190
+ ...registrationTool,
191
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
192
+ const runtimeTool = getBuiltInTools(ctx.cwd)[toolName];
193
+ return runtimeTool.execute(toolCallId, params, signal, onUpdate, ctx);
194
+ },
195
+ renderCall(args, theme) {
196
+ return new Text(formatToolCall(toolName, args as Record<string, unknown>, theme), 0, 0);
197
+ },
198
+ renderResult(result, options, theme, context) {
199
+ if (options.isPartial) {
200
+ return new Text(theme.fg("warning", partialLabel(toolName)), 0, 0);
201
+ }
202
+ const output = formatToolResultOutput(toolName, result, {
203
+ expanded: options.expanded,
204
+ isError: options.isError,
205
+ args: context.args as Record<string, unknown> | undefined,
206
+ });
207
+ const color = options.expanded ? "toolOutput" : options.isError ? "error" : "muted";
208
+ return new Text(output ? theme.fg(color, output) : "", 0, 0);
209
+ },
210
+ });
211
+ }
212
+
213
+ export default function quietTools(pi: ExtensionAPI): void {
214
+ if (!quietToolsEnabled()) return;
215
+ for (const toolName of Object.keys(TOOL_CREATORS) as QuietToolName[]) {
216
+ registerQuietTool(pi, toolName);
217
+ }
218
+ }
@@ -0,0 +1,17 @@
1
+ export const QUIET_TOOLS_ENV = "GENTLE_PI_QUIET_TOOLS";
2
+ export const PI_PRETTY_SUPPRESSED_TOOL_NAMES = ["read", "bash", "ls", "find", "grep"] as const;
3
+
4
+ export function quietToolsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
5
+ return env[QUIET_TOOLS_ENV] !== "0";
6
+ }
7
+
8
+ export function mergeDisabledTools(existing: string | undefined, tools: readonly string[]): string {
9
+ const disabled = new Set(
10
+ (existing ?? "")
11
+ .split(",")
12
+ .map((tool) => tool.trim().toLowerCase())
13
+ .filter(Boolean),
14
+ );
15
+ for (const tool of tools) disabled.add(tool);
16
+ return [...disabled].join(",");
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,6 +49,10 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
49
49
  existsSync(join(PACKAGE_ROOT, "extensions", "pi-pretty.ts")),
50
50
  "gentle-pi must expose pi-pretty through a packaged wrapper extension",
51
51
  );
52
+ assert.ok(
53
+ existsSync(join(PACKAGE_ROOT, "extensions", "quiet-tools.ts")),
54
+ "gentle-pi must expose quiet built-in tool rendering through a packaged extension",
55
+ );
52
56
  assert.ok(
53
57
  !packageJson.bundledDependencies?.includes("@heyhuynhgiabuu/pi-pretty"),
54
58
  "pi-pretty must not be bundled because its native optional dependencies are platform-specific",
@@ -105,4 +109,6 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
105
109
  assert.match(wrapper, /realpathSync/);
106
110
  assert.match(wrapper, /createRequire/);
107
111
  assert.match(wrapper, /@heyhuynhgiabuu\/pi-pretty/);
112
+ assert.match(wrapper, /PI_PRETTY_SUPPRESSED_TOOL_NAMES/);
113
+ assert.match(wrapper, /quietToolsEnabled/);
108
114
  });
@@ -0,0 +1,249 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import piPretty from "../extensions/pi-pretty.ts";
4
+ import quietTools, {
5
+ countNonEmptyLines,
6
+ extractTextContent,
7
+ formatToolResultOutput,
8
+ tailLines,
9
+ } from "../extensions/quiet-tools.ts";
10
+
11
+ const passthroughTheme = {
12
+ bold(value: string) {
13
+ return value;
14
+ },
15
+ fg(_color: string, value: string) {
16
+ return value;
17
+ },
18
+ };
19
+
20
+ function renderToString(component: { render(width: number): string[] }): string {
21
+ return component.render(120).join("\n");
22
+ }
23
+
24
+ function textResult(text: string) {
25
+ return {
26
+ content: [{ type: "text", text }],
27
+ };
28
+ }
29
+
30
+ function createPi(options: { throwOnToolConflict?: boolean } = {}) {
31
+ const tools = new Map<string, any>();
32
+ const commands = new Map<string, any>();
33
+ const hooks = new Map<string, any[]>();
34
+ return {
35
+ tools,
36
+ pi: {
37
+ registerTool(tool: any) {
38
+ if (options.throwOnToolConflict && tools.has(tool.name)) {
39
+ throw new Error(`Tool ${tool.name} already registered`);
40
+ }
41
+ tools.set(tool.name, tool);
42
+ },
43
+ registerCommand(name: string, command: any) {
44
+ commands.set(name, command);
45
+ },
46
+ on(name: string, handler: any) {
47
+ hooks.set(name, [...(hooks.get(name) ?? []), handler]);
48
+ },
49
+ },
50
+ };
51
+ }
52
+
53
+ function createSdkTool(name: string) {
54
+ return {
55
+ name,
56
+ label: name,
57
+ description: `${name} tool`,
58
+ parameters: { type: "object", properties: {} },
59
+ execute: async () => textResult(`${name} result`),
60
+ };
61
+ }
62
+
63
+ const fakePiPrettyDeps = {
64
+ sdk: {
65
+ createReadTool: () => createSdkTool("read"),
66
+ createBashTool: () => createSdkTool("bash"),
67
+ createLsTool: () => createSdkTool("ls"),
68
+ createFindTool: () => createSdkTool("find"),
69
+ createGrepTool: () => createSdkTool("grep"),
70
+ },
71
+ };
72
+
73
+ function withEnv<T>(updates: Record<string, string | undefined>, run: () => T): T {
74
+ const previous = Object.fromEntries(Object.keys(updates).map((key) => [key, process.env[key]]));
75
+ try {
76
+ for (const [key, value] of Object.entries(updates)) {
77
+ if (value === undefined) delete process.env[key];
78
+ else process.env[key] = value;
79
+ }
80
+ return run();
81
+ } finally {
82
+ for (const [key, value] of Object.entries(previous)) {
83
+ if (value === undefined) delete process.env[key];
84
+ else process.env[key] = value;
85
+ }
86
+ }
87
+ }
88
+
89
+ async function withEnvAsync<T>(updates: Record<string, string | undefined>, run: () => Promise<T>): Promise<T> {
90
+ const previous = Object.fromEntries(Object.keys(updates).map((key) => [key, process.env[key]]));
91
+ try {
92
+ for (const [key, value] of Object.entries(updates)) {
93
+ if (value === undefined) delete process.env[key];
94
+ else process.env[key] = value;
95
+ }
96
+ return await run();
97
+ } finally {
98
+ for (const [key, value] of Object.entries(previous)) {
99
+ if (value === undefined) delete process.env[key];
100
+ else process.env[key] = value;
101
+ }
102
+ }
103
+ }
104
+
105
+ test("quiet tool rendering registers noisy built-in tools", () => {
106
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => {
107
+ const { pi, tools } = createPi();
108
+
109
+ quietTools(pi as any);
110
+
111
+ for (const toolName of ["read", "bash", "grep", "find", "ls", "edit", "write"]) {
112
+ const tool = tools.get(toolName);
113
+ assert.ok(tool, `missing quiet renderer for ${toolName}`);
114
+ assert.equal(typeof tool.execute, "function", `${toolName} must delegate execution`);
115
+ assert.ok(tool.parameters, `${toolName} must preserve built-in parameters`);
116
+ }
117
+ });
118
+ });
119
+
120
+ test("quiet tool rendering can be disabled by env", () => {
121
+ withEnv({ GENTLE_PI_QUIET_TOOLS: "0" }, () => {
122
+ const { pi, tools } = createPi();
123
+
124
+ quietTools(pi as any);
125
+
126
+ assert.equal(tools.size, 0);
127
+ });
128
+ });
129
+
130
+ test("pi-pretty suppresses overlapping tools before quiet tools register", async () => {
131
+ await withEnvAsync(
132
+ { GENTLE_PI_QUIET_TOOLS: undefined, PRETTY_DISABLE_TOOLS: "multi_grep" },
133
+ async () => {
134
+ const { pi, tools } = createPi({ throwOnToolConflict: true });
135
+
136
+ await piPretty(pi as any, fakePiPrettyDeps as any);
137
+ quietTools(pi as any);
138
+
139
+ for (const toolName of ["read", "bash", "grep", "find", "ls", "edit", "write"]) {
140
+ assert.ok(tools.has(toolName), `missing quiet tool ${toolName}`);
141
+ }
142
+ assert.equal(process.env.PRETTY_DISABLE_TOOLS, "multi_grep,read,bash,ls,find,grep");
143
+ },
144
+ );
145
+ });
146
+
147
+ test("pi-pretty suppression is skipped when quiet tools are disabled", async () => {
148
+ await withEnvAsync(
149
+ { GENTLE_PI_QUIET_TOOLS: "0", PRETTY_DISABLE_TOOLS: undefined },
150
+ async () => {
151
+ const { pi, tools } = createPi();
152
+
153
+ await piPretty(pi as any, fakePiPrettyDeps as any);
154
+ quietTools(pi as any);
155
+
156
+ for (const toolName of ["read", "bash", "grep", "find", "ls"]) {
157
+ assert.ok(tools.has(toolName), `pi-pretty should keep ${toolName} when quiet tools are disabled`);
158
+ }
159
+ assert.equal(process.env.PRETTY_DISABLE_TOOLS, undefined);
160
+ },
161
+ );
162
+ });
163
+
164
+ test("quiet tool rendering hides noisy result bodies while collapsed and restores them when expanded", () => {
165
+ const { pi, tools } = createPi();
166
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
167
+
168
+ const cases = [
169
+ { tool: "read", text: "first line\nsecond line", hidden: "first line", expanded: "second line" },
170
+ { tool: "bash", text: "stdout line\nstderr line", hidden: "stdout line", expanded: "stderr line" },
171
+ { tool: "grep", text: "src/a.ts:1:match\nsrc/b.ts:2:match", hidden: "src/a.ts", expanded: "src/b.ts" },
172
+ { tool: "find", text: "src/a.ts\nsrc/b.ts", hidden: "src/a.ts", expanded: "src/b.ts" },
173
+ { tool: "ls", text: "file-a.ts\nfile-b.ts", hidden: "file-a.ts", expanded: "file-b.ts" },
174
+ ];
175
+
176
+ for (const entry of cases) {
177
+ const tool = tools.get(entry.tool);
178
+ const collapsed = renderToString(
179
+ tool.renderResult(textResult(entry.text), { expanded: false, isPartial: false }, passthroughTheme, {}),
180
+ );
181
+ const expanded = renderToString(
182
+ tool.renderResult(textResult(entry.text), { expanded: true, isPartial: false }, passthroughTheme, {}),
183
+ );
184
+
185
+ assert.doesNotMatch(collapsed, new RegExp(entry.hidden.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `${entry.tool} collapsed output must not include result body`);
186
+ assert.match(expanded, new RegExp(entry.expanded.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `${entry.tool} expanded output must include full result body`);
187
+ }
188
+ });
189
+
190
+ test("quiet tool rendering keeps compact collapsed summaries for search and listing tools", () => {
191
+ assert.equal(countNonEmptyLines("a\n\n b \n"), 2);
192
+ assert.equal(extractTextContent(textResult("alpha\nbeta") as any), "alpha\nbeta");
193
+ assert.equal(formatToolResultOutput("grep", textResult("a\nb\n") as any, { expanded: false }), " → 2 matches");
194
+ assert.equal(formatToolResultOutput("find", textResult("a\nb\n") as any, { expanded: false }), " → 2 files");
195
+ assert.equal(formatToolResultOutput("ls", textResult("a\nb\n") as any, { expanded: false }), " → 2 entries");
196
+ assert.equal(formatToolResultOutput("grep", textResult("No matches found") as any, { expanded: false }), "");
197
+ assert.equal(formatToolResultOutput("find", textResult("No files found matching pattern") as any, { expanded: false }), "");
198
+ assert.equal(formatToolResultOutput("ls", textResult("Directory is empty") as any, { expanded: false }), "");
199
+ assert.equal(formatToolResultOutput("read", textResult("a\nb\n") as any, { expanded: false }), "");
200
+ assert.equal(formatToolResultOutput("bash", textResult("a\nb\n") as any, { expanded: false }), "");
201
+ assert.equal(formatToolResultOutput("bash", textResult("a\nb\n") as any, { expanded: false, args: { command: "git diff" } }), "\na\nb\n");
202
+ assert.equal(formatToolResultOutput("bash", textResult("a\nb\n") as any, { expanded: false, args: { command: "git -C repo status" } }), "\na\nb\n");
203
+ assert.equal(formatToolResultOutput("bash", textResult("a\nb\n") as any, { expanded: false, args: { command: "echo git diff" } }), "");
204
+ assert.equal(formatToolResultOutput("edit", textResult("updated") as any, { expanded: false }), "\nupdated");
205
+ assert.equal(formatToolResultOutput("write", textResult("wrote") as any, { expanded: false }), "\nwrote");
206
+ assert.equal(formatToolResultOutput("grep", textResult("a\nb\n") as any, { expanded: true }), "\na\nb\n");
207
+ assert.equal(formatToolResultOutput("read", textResult("ENOENT: missing file") as any, { expanded: false, isError: true }), "\nENOENT: missing file");
208
+ });
209
+
210
+ test("quiet tool rendering keeps collapsed git bash result tails", () => {
211
+ const text = Array.from({ length: 12 }, (_, index) => `git line ${index + 1}`).join("\n");
212
+
213
+ assert.equal(formatToolResultOutput("bash", textResult(text) as any, { expanded: false, args: { command: "git diff" } }), `\n${tailLines(text, 10)}`);
214
+ assert.equal(formatToolResultOutput("bash", textResult(text) as any, { expanded: false, args: { command: "git status --short" } }), `\n${tailLines(text, 10)}`);
215
+ });
216
+
217
+ test("quiet tool rendering keeps collapsed edit and write result tails", () => {
218
+ const text = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n");
219
+
220
+ assert.equal(tailLines(text, 10), Array.from({ length: 10 }, (_, index) => `line ${index + 3}`).join("\n"));
221
+ assert.equal(formatToolResultOutput("edit", textResult(text) as any, { expanded: false }), `\n${tailLines(text, 10)}`);
222
+ assert.equal(formatToolResultOutput("write", textResult(text) as any, { expanded: false }), `\n${tailLines(text, 10)}`);
223
+ });
224
+
225
+ test("quiet tool rendering sanitizes collapsed output and call rows", () => {
226
+ const { pi, tools } = createPi();
227
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
228
+
229
+ const collapsed = renderToString(
230
+ tools.get("write").renderResult(textResult("safe\x1b[31mred\x1b[0m"), { expanded: false, isPartial: false }, passthroughTheme, {}),
231
+ );
232
+ const call = renderToString(tools.get("bash").renderCall({ command: "echo \x1b[31mred\x1b[0m" }, passthroughTheme, {}));
233
+
234
+ assert.equal(collapsed.replace(/[ \t]+$/gm, ""), "\nsafered");
235
+ assert.equal(call.trimEnd(), "$ echo red");
236
+ });
237
+
238
+ test("quiet tool rendering call rows show tool calls without result output", () => {
239
+ const { pi, tools } = createPi();
240
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
241
+
242
+ const readCall = renderToString(tools.get("read").renderCall({ path: "/tmp/example.ts", offset: 2, limit: 3 }, passthroughTheme, {}));
243
+ const bashCall = renderToString(tools.get("bash").renderCall({ command: "printf noisy", timeout: 5 }, passthroughTheme, {}));
244
+ const grepCall = renderToString(tools.get("grep").renderCall({ pattern: "needle", path: "src", glob: "*.ts" }, passthroughTheme, {}));
245
+
246
+ assert.match(readCall, /read .*example\.ts:2-4/);
247
+ assert.match(bashCall, /\$ printf noisy \(timeout 5s\)/);
248
+ assert.match(grepCall, /grep \/needle\/ in src \(\*\.ts\)/);
249
+ });
@@ -12,6 +12,7 @@ import { stripAnsi } from "../lib/terminal-theme.ts";
12
12
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
13
13
  const EXTENSIONS = [
14
14
  "extensions/gentle-ai.ts",
15
+ "extensions/quiet-tools.ts",
15
16
  "extensions/skill-registry.ts",
16
17
  "extensions/sdd-init.ts",
17
18
  "extensions/startup-banner.ts",
@@ -59,6 +60,7 @@ function createPi() {
59
60
  const hooks = new Map();
60
61
  const commands = new Map();
61
62
  const flags = new Map();
63
+ const tools = new Map();
62
64
  const flagValues = new Map([["no-skill-registry", true]]);
63
65
  let activeTools = ["read", "bash", "edit", "write"];
64
66
 
@@ -74,6 +76,9 @@ function createPi() {
74
76
  registerFlag(name, definition) {
75
77
  flags.set(name, definition);
76
78
  },
79
+ registerTool(definition) {
80
+ tools.set(definition.name, definition);
81
+ },
77
82
  getFlag(name) {
78
83
  return flagValues.get(name) ?? false;
79
84
  },
@@ -100,7 +105,7 @@ function createPi() {
100
105
  },
101
106
  };
102
107
 
103
- return { pi, hooks, commands, flags };
108
+ return { pi, hooks, commands, flags, tools };
104
109
  }
105
110
 
106
111
  function createUi() {
@@ -170,7 +175,7 @@ async function run() {
170
175
  process.env.GENTLE_PI_TEST_ASSETS_DIR = ambientTestAssetsDir;
171
176
  const globalModelsPath = join(globalConfigHome, "models.json");
172
177
  const globalSubagentsPath = join(globalAgentHome, "subagents.json");
173
- const { pi, hooks, commands, flags } = createPi();
178
+ const { pi, hooks, commands, flags, tools } = createPi();
174
179
  await loadExtensions(pi);
175
180
 
176
181
  for (const name of EXPECTED_COMMANDS) {
@@ -185,6 +190,9 @@ async function run() {
185
190
  assert.ok(hooks.has("input"), "missing input hook");
186
191
  assert.ok(hooks.has("before_agent_start"), "missing before_agent_start hook");
187
192
  assert.ok(hooks.has("tool_call"), "missing tool_call hook");
193
+ for (const toolName of ["read", "bash", "grep", "find", "ls", "edit", "write"]) {
194
+ assert.ok(tools.has(toolName), `missing quiet built-in tool renderer ${toolName}`);
195
+ }
188
196
 
189
197
  for (const entry of await readdir(join(ROOT, "assets", "agents"))) {
190
198
  if (!entry.endsWith(".md")) continue;