pi-formatter 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # 🎨 pi-formatter
2
2
 
3
- A [pi](https://pi.dev) extension that auto-formats files after every `write` and
4
- `edit` tool call.
3
+ A [pi](https://pi.dev) extension that auto-formats files after `write` and
4
+ `edit` tool calls.
5
5
 
6
- The extension hooks into successful tool results, detects the file type, and
7
- runs the appropriate formatter. Failures never block the tool result, so
8
- formatting is always best-effort.
6
+ By default, formatting runs after each successful tool result. It can also be
7
+ configured to defer formatting until the agent stops and yields back to the
8
+ user.
9
9
 
10
10
  ## 📦 Install
11
11
 
@@ -15,8 +15,18 @@ pi install npm:pi-formatter
15
15
 
16
16
  ## ⚙️ What it does
17
17
 
18
- `pi-formatter` listens to successful `write` and `edit` tool calls and applies
19
- best-effort formatting. Formatter failures never block tool results.
18
+ `pi-formatter` detects file types and runs the appropriate formatter as
19
+ best-effort post-processing. Formatter failures never block tool results.
20
+
21
+ Formatting modes:
22
+
23
+ - `afterEachToolCall`: format immediately after each successful `write` or
24
+ `edit` tool result. This is the default.
25
+ - `afterAgentStop`: collect touched files during the run and format them once
26
+ the agent stops. This avoids mid-run model drift from formatter edits.
27
+
28
+ When `afterAgentStop` is active, interrupted or canceled runs are not formatted
29
+ unless `formatOnAbort` is enabled.
20
30
 
21
31
  Supported file types:
22
32
 
@@ -31,6 +41,11 @@ Supported file types:
31
41
  For JS/TS and JSON, project-configured tools are preferred first (Biome,
32
42
  ESLint), with Prettier as a fallback.
33
43
 
44
+ ## 🎮 Commands
45
+
46
+ - `/formatter`: open the interactive formatter settings editor and save changes
47
+ to `formatter.json`
48
+
34
49
  ## 🔧 Configuration
35
50
 
36
51
  Create `<agent-dir>/formatter.json`, where `<agent-dir>` is pi's agent config
@@ -38,13 +53,20 @@ folder (default: `~/.pi/agent`, overridable via `PI_CODING_AGENT_DIR`):
38
53
 
39
54
  ```json
40
55
  {
56
+ "formatMode": "afterEachToolCall",
57
+ "formatOnAbort": false,
41
58
  "commandTimeoutMs": 10000,
42
59
  "hideCallSummariesInTui": false
43
60
  }
44
61
  ```
45
62
 
63
+ - `formatMode`: formatting strategy
64
+ (`"afterEachToolCall"` | `"afterAgentStop"`, default: `"afterEachToolCall"`)
65
+ - `formatOnAbort`: in deferred mode, also format files after an interrupted or
66
+ canceled run (default: `false`)
46
67
  - `commandTimeoutMs`: timeout (ms) per formatter command (default: `10000`)
47
- - `hideCallSummariesInTui`: hide formatter pass/fail summaries in the TUI (default: `false`)
68
+ - `hideCallSummariesInTui`: hide formatter pass/fail summaries in the TUI
69
+ (default: `false`)
48
70
 
49
71
  ## 🧩 Adding formatters
50
72
 
@@ -1,17 +1,35 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ } from "node:fs";
2
7
  import { join } from "node:path";
3
8
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
9
 
10
+ export type FormatMode = "afterEachToolCall" | "afterAgentStop";
11
+
5
12
  const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
6
13
  const DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI = false;
14
+ const DEFAULT_FORMAT_MODE: FormatMode = "afterEachToolCall";
15
+ const DEFAULT_FORMAT_ON_ABORT = false;
7
16
  const FORMATTER_CONFIG_FILE = "formatter.json";
8
17
 
9
- type FormatterConfig = {
18
+ export type FormatterConfigSnapshot = {
10
19
  commandTimeoutMs: number;
11
20
  hideCallSummariesInTui: boolean;
21
+ formatMode: FormatMode;
22
+ formatOnAbort: boolean;
23
+ };
24
+
25
+ export const DEFAULT_FORMATTER_CONFIG: FormatterConfigSnapshot = {
26
+ commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
27
+ hideCallSummariesInTui: DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
28
+ formatMode: DEFAULT_FORMAT_MODE,
29
+ formatOnAbort: DEFAULT_FORMAT_ON_ABORT,
12
30
  };
13
31
 
14
- function getFormatterConfigPath(): string {
32
+ export function getFormatterConfigPath(): string {
15
33
  return join(getAgentDir(), FORMATTER_CONFIG_FILE);
16
34
  }
17
35
 
@@ -54,14 +72,35 @@ function parseBooleanValue(value: unknown, defaultValue: boolean): boolean {
54
72
  return value;
55
73
  }
56
74
 
57
- function loadFormatterConfig(): FormatterConfig {
75
+ function parseFormatMode(value: unknown, defaultValue: FormatMode): FormatMode {
76
+ if (value === "afterEachToolCall" || value === "afterAgentStop") {
77
+ return value;
78
+ }
79
+
80
+ return defaultValue;
81
+ }
82
+
83
+ function toFormatterConfigObject(
84
+ config: FormatterConfigSnapshot,
85
+ ): Record<string, unknown> {
86
+ return {
87
+ commandTimeoutMs: config.commandTimeoutMs,
88
+ hideCallSummariesInTui: config.hideCallSummariesInTui,
89
+ formatMode: config.formatMode,
90
+ formatOnAbort: config.formatOnAbort,
91
+ };
92
+ }
93
+
94
+ function writeFormatterConfigFile(content: string): void {
95
+ mkdirSync(getAgentDir(), { recursive: true });
96
+ writeFileSync(getFormatterConfigPath(), content, "utf8");
97
+ }
98
+
99
+ export function loadFormatterConfig(): FormatterConfigSnapshot {
58
100
  const config = readJsonObject(getFormatterConfigPath());
59
101
 
60
102
  if (!config) {
61
- return {
62
- commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
63
- hideCallSummariesInTui: DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
64
- };
103
+ return { ...DEFAULT_FORMATTER_CONFIG };
65
104
  }
66
105
 
67
106
  return {
@@ -73,10 +112,29 @@ function loadFormatterConfig(): FormatterConfig {
73
112
  config.hideCallSummariesInTui,
74
113
  DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
75
114
  ),
115
+ formatMode: parseFormatMode(config.formatMode, DEFAULT_FORMAT_MODE),
116
+ formatOnAbort: parseBooleanValue(
117
+ config.formatOnAbort,
118
+ DEFAULT_FORMAT_ON_ABORT,
119
+ ),
76
120
  };
77
121
  }
78
122
 
79
- const formatterConfig = loadFormatterConfig();
123
+ export function cloneFormatterConfig(
124
+ config: FormatterConfigSnapshot,
125
+ ): FormatterConfigSnapshot {
126
+ return {
127
+ commandTimeoutMs: config.commandTimeoutMs,
128
+ hideCallSummariesInTui: config.hideCallSummariesInTui,
129
+ formatMode: config.formatMode,
130
+ formatOnAbort: config.formatOnAbort,
131
+ };
132
+ }
80
133
 
81
- export const commandTimeoutMs = formatterConfig.commandTimeoutMs;
82
- export const hideCallSummariesInTui = formatterConfig.hideCallSummariesInTui;
134
+ export function writeFormatterConfigSnapshot(
135
+ config: FormatterConfigSnapshot,
136
+ ): void {
137
+ writeFormatterConfigFile(
138
+ `${JSON.stringify(toFormatterConfigObject(config), null, 2)}\n`,
139
+ );
140
+ }
@@ -1,12 +1,17 @@
1
1
  import { basename } from "node:path";
2
2
  import {
3
3
  type ExtensionAPI,
4
+ getSettingsListTheme,
4
5
  isEditToolResult,
5
6
  isWriteToolResult,
6
7
  } from "@mariozechner/pi-coding-agent";
8
+ import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
7
9
  import {
8
- commandTimeoutMs,
9
- hideCallSummariesInTui,
10
+ cloneFormatterConfig,
11
+ type FormatterConfigSnapshot,
12
+ getFormatterConfigPath,
13
+ loadFormatterConfig,
14
+ writeFormatterConfigSnapshot,
10
15
  } from "./formatter/config.js";
11
16
  import { type FormatCallSummary, formatFile } from "./formatter/dispatch.js";
12
17
  import {
@@ -42,8 +47,66 @@ function formatCallFailureSummary(summary: FormatCallSummary): string {
42
47
  return `✘ ${summary.runnerId}`;
43
48
  }
44
49
 
50
+ function resolveEventPath(rawPath: unknown, cwd: string): string | undefined {
51
+ if (typeof rawPath !== "string" || rawPath.length === 0) {
52
+ return undefined;
53
+ }
54
+
55
+ return resolveToolPath(rawPath, cwd);
56
+ }
57
+
58
+ function getFormatterSettingItems(
59
+ config: FormatterConfigSnapshot,
60
+ ): SettingItem[] {
61
+ const timeoutValues = ["2000", "5000", "10000", "30000", "60000"];
62
+
63
+ if (!timeoutValues.includes(String(config.commandTimeoutMs))) {
64
+ timeoutValues.push(String(config.commandTimeoutMs));
65
+ timeoutValues.sort(
66
+ (a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10),
67
+ );
68
+ }
69
+
70
+ return [
71
+ {
72
+ id: "formatMode",
73
+ label: "Format mode",
74
+ description:
75
+ "Choose whether formatting runs after each successful write/edit tool call or once after the agent stops.",
76
+ currentValue: config.formatMode,
77
+ values: ["afterEachToolCall", "afterAgentStop"],
78
+ },
79
+ {
80
+ id: "formatOnAbort",
81
+ label: "Format on abort",
82
+ description:
83
+ "When enabled, deferred formatting also runs if the model is interrupted or cancelled.",
84
+ currentValue: config.formatOnAbort ? "on" : "off",
85
+ values: ["off", "on"],
86
+ },
87
+ {
88
+ id: "commandTimeoutMs",
89
+ label: "Command timeout",
90
+ description: "Maximum runtime per formatter command in milliseconds.",
91
+ currentValue: String(config.commandTimeoutMs),
92
+ values: timeoutValues,
93
+ },
94
+ {
95
+ id: "hideCallSummariesInTui",
96
+ label: "Hide TUI summaries",
97
+ description:
98
+ "Hide per-run formatter pass/fail summaries in the interactive UI.",
99
+ currentValue: config.hideCallSummariesInTui ? "on" : "off",
100
+ values: ["off", "on"],
101
+ },
102
+ ];
103
+ }
104
+
45
105
  export default function (pi: ExtensionAPI) {
106
+ let formatterConfig = loadFormatterConfig();
46
107
  const formatQueueByPath = new Map<string, Promise<void>>();
108
+ const candidatePaths = new Set<string>();
109
+ const successfulPaths = new Set<string>();
47
110
 
48
111
  const enqueueFormat = async (
49
112
  filePath: string,
@@ -65,27 +128,21 @@ export default function (pi: ExtensionAPI) {
65
128
  await next;
66
129
  };
67
130
 
68
- pi.on("tool_result", async (event, ctx) => {
69
- if (event.isError) {
70
- return;
71
- }
72
-
73
- if (!isWriteToolResult(event) && !isEditToolResult(event)) {
74
- return;
75
- }
76
-
77
- const rawPath = event.input.path;
78
- if (rawPath.length === 0) {
79
- return;
80
- }
81
-
82
- const filePath = resolveToolPath(rawPath, ctx.cwd);
83
-
131
+ const formatResolvedPath = async (
132
+ filePath: string,
133
+ ctx: {
134
+ cwd: string;
135
+ hasUI: boolean;
136
+ ui: {
137
+ notify(message: string, level: "info" | "warning" | "error"): void;
138
+ };
139
+ },
140
+ ): Promise<void> => {
84
141
  if (!(await pathExists(filePath))) {
85
142
  return;
86
143
  }
87
144
 
88
- const showSummaries = !hideCallSummariesInTui && ctx.hasUI;
145
+ const showSummaries = !formatterConfig.hideCallSummariesInTui && ctx.hasUI;
89
146
  const notifyWarning = (message: string) => {
90
147
  const normalizedMessage = message.replace(/\s+/g, " ").trim();
91
148
 
@@ -117,7 +174,7 @@ export default function (pi: ExtensionAPI) {
117
174
  pi,
118
175
  ctx.cwd,
119
176
  filePath,
120
- commandTimeoutMs,
177
+ formatterConfig.commandTimeoutMs,
121
178
  summaryReporter,
122
179
  runnerWarningReporter,
123
180
  );
@@ -139,5 +196,182 @@ export default function (pi: ExtensionAPI) {
139
196
  ctx.ui.notify(formatCallFailureSummary(summary), "info");
140
197
  }
141
198
  });
199
+ };
200
+
201
+ const reloadFormatterConfig = () => {
202
+ formatterConfig = loadFormatterConfig();
203
+ };
204
+
205
+ pi.on("agent_start", async () => {
206
+ candidatePaths.clear();
207
+ successfulPaths.clear();
208
+ });
209
+
210
+ pi.on("tool_call", async (event, ctx) => {
211
+ if (formatterConfig.formatMode !== "afterAgentStop") {
212
+ return;
213
+ }
214
+
215
+ if (event.toolName !== "write" && event.toolName !== "edit") {
216
+ return;
217
+ }
218
+
219
+ const filePath = resolveEventPath(event.input.path, ctx.cwd);
220
+ if (filePath) {
221
+ candidatePaths.add(filePath);
222
+ }
223
+ });
224
+
225
+ pi.on("tool_result", async (event, ctx) => {
226
+ if (!isWriteToolResult(event) && !isEditToolResult(event)) {
227
+ return;
228
+ }
229
+
230
+ const filePath = resolveEventPath(event.input.path, ctx.cwd);
231
+ if (!filePath) {
232
+ return;
233
+ }
234
+
235
+ if (!event.isError) {
236
+ successfulPaths.add(filePath);
237
+ }
238
+
239
+ if (formatterConfig.formatMode !== "afterEachToolCall" || event.isError) {
240
+ return;
241
+ }
242
+
243
+ await formatResolvedPath(filePath, ctx);
244
+ });
245
+
246
+ pi.on("agent_end", async (event, ctx) => {
247
+ if (formatterConfig.formatMode !== "afterAgentStop") {
248
+ candidatePaths.clear();
249
+ successfulPaths.clear();
250
+ return;
251
+ }
252
+
253
+ let lastAssistantStopReason: string | undefined;
254
+ for (let index = event.messages.length - 1; index >= 0; index -= 1) {
255
+ const message = event.messages[index];
256
+ if (message.role !== "assistant") {
257
+ continue;
258
+ }
259
+
260
+ lastAssistantStopReason = message.stopReason;
261
+ break;
262
+ }
263
+
264
+ const pathsToFormat = new Set<string>();
265
+ if (lastAssistantStopReason === "aborted") {
266
+ if (formatterConfig.formatOnAbort) {
267
+ for (const filePath of candidatePaths) {
268
+ pathsToFormat.add(filePath);
269
+ }
270
+ for (const filePath of successfulPaths) {
271
+ pathsToFormat.add(filePath);
272
+ }
273
+ }
274
+ } else {
275
+ for (const filePath of successfulPaths) {
276
+ pathsToFormat.add(filePath);
277
+ }
278
+ }
279
+
280
+ candidatePaths.clear();
281
+ successfulPaths.clear();
282
+
283
+ for (const filePath of pathsToFormat) {
284
+ await formatResolvedPath(filePath, ctx);
285
+ }
286
+ });
287
+
288
+ pi.registerCommand("formatter", {
289
+ description: "Configure formatter behavior.",
290
+ handler: async (_args, ctx) => {
291
+ if (!ctx.hasUI) {
292
+ console.warn("/formatter requires interactive UI mode");
293
+ return;
294
+ }
295
+
296
+ const configPath = getFormatterConfigPath();
297
+ reloadFormatterConfig();
298
+ const draft = cloneFormatterConfig(formatterConfig);
299
+
300
+ await ctx.ui.custom((tui, theme, _kb, done) => {
301
+ const container = new Container();
302
+ container.addChild(
303
+ new Text(theme.fg("accent", theme.bold("Formatter Settings")), 1, 0),
304
+ );
305
+ container.addChild(new Text(theme.fg("dim", configPath), 1, 0));
306
+ container.addChild(new Text("", 0, 0));
307
+
308
+ const syncDraftToSettingsList = (settingsList: SettingsList) => {
309
+ settingsList.updateValue("formatMode", draft.formatMode);
310
+ settingsList.updateValue(
311
+ "formatOnAbort",
312
+ draft.formatOnAbort ? "on" : "off",
313
+ );
314
+ settingsList.updateValue(
315
+ "commandTimeoutMs",
316
+ String(draft.commandTimeoutMs),
317
+ );
318
+ settingsList.updateValue(
319
+ "hideCallSummariesInTui",
320
+ draft.hideCallSummariesInTui ? "on" : "off",
321
+ );
322
+ };
323
+
324
+ const settingsList = new SettingsList(
325
+ getFormatterSettingItems(draft),
326
+ 8,
327
+ getSettingsListTheme(),
328
+ (id, newValue) => {
329
+ const previous = cloneFormatterConfig(draft);
330
+
331
+ if (id === "formatMode") {
332
+ draft.formatMode = newValue as FormatterConfigSnapshot["formatMode"];
333
+ } else if (id === "formatOnAbort") {
334
+ draft.formatOnAbort = newValue === "on";
335
+ } else if (id === "commandTimeoutMs") {
336
+ draft.commandTimeoutMs = Number.parseInt(newValue, 10);
337
+ } else if (id === "hideCallSummariesInTui") {
338
+ draft.hideCallSummariesInTui = newValue === "on";
339
+ }
340
+
341
+ try {
342
+ writeFormatterConfigSnapshot(draft);
343
+ reloadFormatterConfig();
344
+ } catch (error) {
345
+ const message =
346
+ error instanceof Error ? error.message : String(error);
347
+ draft.commandTimeoutMs = previous.commandTimeoutMs;
348
+ draft.hideCallSummariesInTui = previous.hideCallSummariesInTui;
349
+ draft.formatMode = previous.formatMode;
350
+ draft.formatOnAbort = previous.formatOnAbort;
351
+ syncDraftToSettingsList(settingsList);
352
+ ctx.ui.notify(`Failed to save config: ${message}`, "error");
353
+ }
354
+ },
355
+ () => {
356
+ done(undefined);
357
+ },
358
+ );
359
+
360
+ container.addChild(settingsList);
361
+
362
+ return {
363
+ render(width: number) {
364
+ return container.render(width);
365
+ },
366
+ invalidate() {
367
+ container.invalidate();
368
+ },
369
+ handleInput(data: string) {
370
+ settingsList.handleInput?.(data);
371
+ tui.requestRender();
372
+ },
373
+ };
374
+ });
375
+ },
142
376
  });
143
377
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-formatter",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension that auto-formats files after write/edit tool calls.",
5
5
  "type": "module",
6
6
  "files": [