pi-formatter 0.3.0 → 1.0.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
@@ -3,9 +3,11 @@
3
3
  A [pi](https://pi.dev) extension that auto-formats files after `write` and
4
4
  `edit` tool calls.
5
5
 
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.
6
+ By default, formatting runs once per turn. You can also format after each tool
7
+ call or defer formatting until the current session shuts down.
8
+
9
+ This default changed from the previous immediate-per-tool behavior. If you want
10
+ the old default, set `"formatMode": "tool"`.
9
11
 
10
12
  ## 📦 Install
11
13
 
@@ -20,13 +22,19 @@ best-effort post-processing. Formatter failures never block tool results.
20
22
 
21
23
  Formatting modes:
22
24
 
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.
25
+ - `tool`: format immediately after each successful `write` or `edit` tool
26
+ result.
27
+ Use this mode when you want the file on disk to stay formatted after every
28
+ edit, even while the agent is still working.
29
+ - `turn`: collect files touched during the current turn and format them once at
30
+ `turn_end`. This is the default.
31
+ Use this mode when you want to avoid mid-turn formatter drift while still
32
+ keeping files formatted throughout the run.
33
+ - `session`: collect files touched during the current session and format them
34
+ once at `session_shutdown`.
35
+ Use this mode when you want the fewest formatter interruptions and are okay
36
+ with formatting only when the session exits, reloads, or switches. Interrupted
37
+ runs stay pending until the session ends or changes.
30
38
 
31
39
  Supported file types:
32
40
 
@@ -53,17 +61,14 @@ folder (default: `~/.pi/agent`, overridable via `PI_CODING_AGENT_DIR`):
53
61
 
54
62
  ```json
55
63
  {
56
- "formatMode": "afterEachToolCall",
57
- "formatOnAbort": false,
64
+ "formatMode": "turn",
58
65
  "commandTimeoutMs": 10000,
59
66
  "hideCallSummariesInTui": false
60
67
  }
61
68
  ```
62
69
 
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`)
70
+ - `formatMode`: formatting strategy (`"tool"` | `"turn"` | `"session"`,
71
+ default: `"turn"`). Use `"tool"` to restore the old immediate default.
67
72
  - `commandTimeoutMs`: timeout (ms) per formatter command (default: `10000`)
68
73
  - `hideCallSummariesInTui`: hide formatter pass/fail summaries in the TUI
69
74
  (default: `false`)
@@ -1,32 +1,24 @@
1
- import {
2
- existsSync,
3
- mkdirSync,
4
- readFileSync,
5
- writeFileSync,
6
- } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
2
  import { join } from "node:path";
8
3
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
9
4
 
10
- export type FormatMode = "afterEachToolCall" | "afterAgentStop";
5
+ export type FormatMode = "tool" | "turn" | "session";
11
6
 
12
7
  const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
13
8
  const DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI = false;
14
- const DEFAULT_FORMAT_MODE: FormatMode = "afterEachToolCall";
15
- const DEFAULT_FORMAT_ON_ABORT = false;
9
+ const DEFAULT_FORMAT_MODE: FormatMode = "turn";
16
10
  const FORMATTER_CONFIG_FILE = "formatter.json";
17
11
 
18
12
  export type FormatterConfigSnapshot = {
19
13
  commandTimeoutMs: number;
20
14
  hideCallSummariesInTui: boolean;
21
15
  formatMode: FormatMode;
22
- formatOnAbort: boolean;
23
16
  };
24
17
 
25
18
  export const DEFAULT_FORMATTER_CONFIG: FormatterConfigSnapshot = {
26
19
  commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
27
20
  hideCallSummariesInTui: DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
28
21
  formatMode: DEFAULT_FORMAT_MODE,
29
- formatOnAbort: DEFAULT_FORMAT_ON_ABORT,
30
22
  };
31
23
 
32
24
  export function getFormatterConfigPath(): string {
@@ -73,7 +65,7 @@ function parseBooleanValue(value: unknown, defaultValue: boolean): boolean {
73
65
  }
74
66
 
75
67
  function parseFormatMode(value: unknown, defaultValue: FormatMode): FormatMode {
76
- if (value === "afterEachToolCall" || value === "afterAgentStop") {
68
+ if (value === "tool" || value === "turn" || value === "session") {
77
69
  return value;
78
70
  }
79
71
 
@@ -87,7 +79,6 @@ function toFormatterConfigObject(
87
79
  commandTimeoutMs: config.commandTimeoutMs,
88
80
  hideCallSummariesInTui: config.hideCallSummariesInTui,
89
81
  formatMode: config.formatMode,
90
- formatOnAbort: config.formatOnAbort,
91
82
  };
92
83
  }
93
84
 
@@ -113,10 +104,6 @@ export function loadFormatterConfig(): FormatterConfigSnapshot {
113
104
  DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
114
105
  ),
115
106
  formatMode: parseFormatMode(config.formatMode, DEFAULT_FORMAT_MODE),
116
- formatOnAbort: parseBooleanValue(
117
- config.formatOnAbort,
118
- DEFAULT_FORMAT_ON_ABORT,
119
- ),
120
107
  };
121
108
  }
122
109
 
@@ -127,7 +114,6 @@ export function cloneFormatterConfig(
127
114
  commandTimeoutMs: config.commandTimeoutMs,
128
115
  hideCallSummariesInTui: config.hideCallSummariesInTui,
129
116
  formatMode: config.formatMode,
130
- formatOnAbort: config.formatOnAbort,
131
117
  };
132
118
  }
133
119
 
@@ -1,7 +1,11 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
- import { getPathForGit, isWithinDirectory, pathExists } from "./path.js";
4
+ import {
5
+ getRelativePathOrAbsolute,
6
+ isWithinDirectory,
7
+ pathExists,
8
+ } from "./path.js";
5
9
  import { hasCommand } from "./system.js";
6
10
  import type { FileKind, RequiredMajorVersion, RunnerContext } from "./types.js";
7
11
 
@@ -239,12 +243,12 @@ export class FormatRunContext implements RunnerContext {
239
243
  }
240
244
 
241
245
  private async resolveChangedLines(): Promise<string[]> {
242
- const gitPath = getPathForGit(this.filePath, this.cwd);
246
+ const diffPath = getRelativePathOrAbsolute(this.filePath, this.cwd);
243
247
  const rangeSet = new Set<string>();
244
248
 
245
249
  const diffArgSets = [
246
- ["diff", "--unified=0", "--", gitPath],
247
- ["diff", "--cached", "--unified=0", "--", gitPath],
250
+ ["diff", "--unified=0", "--", diffPath],
251
+ ["diff", "--cached", "--unified=0", "--", diffPath],
248
252
  ];
249
253
 
250
254
  for (const args of diffArgSets) {
@@ -54,8 +54,11 @@ export function isWithinDirectory(
54
54
  );
55
55
  }
56
56
 
57
- export function getPathForGit(filePath: string, cwd: string): string {
58
- const relPath = relative(cwd, filePath);
57
+ export function getRelativePathOrAbsolute(
58
+ filePath: string,
59
+ directory: string,
60
+ ): string {
61
+ const relPath = relative(directory, filePath);
59
62
  if (
60
63
  !relPath ||
61
64
  relPath === "." ||
@@ -1,11 +1,15 @@
1
- import { basename } from "node:path";
2
1
  import {
3
2
  type ExtensionAPI,
4
3
  getSettingsListTheme,
5
4
  isEditToolResult,
6
5
  isWriteToolResult,
7
6
  } from "@mariozechner/pi-coding-agent";
8
- import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
7
+ import {
8
+ Container,
9
+ type SettingItem,
10
+ SettingsList,
11
+ Text,
12
+ } from "@mariozechner/pi-tui";
9
13
  import {
10
14
  cloneFormatterConfig,
11
15
  type FormatterConfigSnapshot,
@@ -15,36 +19,39 @@ import {
15
19
  } from "./formatter/config.js";
16
20
  import { type FormatCallSummary, formatFile } from "./formatter/dispatch.js";
17
21
  import {
18
- getPathForGit,
22
+ getRelativePathOrAbsolute,
19
23
  pathExists,
20
24
  resolveToolPath,
21
25
  } from "./formatter/path.js";
22
26
 
27
+ function normalizeSummaryMessage(message: string): string {
28
+ return message.replace(/\s+/g, " ").trim();
29
+ }
30
+
23
31
  function formatError(error: unknown): string {
24
32
  return error instanceof Error ? error.message : String(error);
25
33
  }
26
34
 
27
- function formatSummaryPath(filePath: string, cwd: string): string {
28
- const pathForDisplay = getPathForGit(filePath, cwd);
29
- return pathForDisplay.startsWith("/")
30
- ? basename(pathForDisplay)
31
- : pathForDisplay;
32
- }
35
+ function formatCallSummary(
36
+ summary: FormatCallSummary,
37
+ fileLabel: string,
38
+ ): string {
39
+ const prefix = summary.status === "succeeded" ? "✔︎" : "✘";
40
+ const base = `${prefix} ${summary.runnerId}: ${fileLabel}`;
33
41
 
34
- function formatCallSuccessSummary(summary: FormatCallSummary): string {
35
- return `✔︎ ${summary.runnerId}`;
36
- }
42
+ if (summary.status === "succeeded") {
43
+ return base;
44
+ }
37
45
 
38
- function formatCallFailureSummary(summary: FormatCallSummary): string {
39
46
  if (summary.failureMessage) {
40
- return `✘ ${summary.runnerId}: ${summary.failureMessage}`;
47
+ return `${base}: ${normalizeSummaryMessage(summary.failureMessage)}`;
41
48
  }
42
49
 
43
50
  if (summary.exitCode !== undefined) {
44
- return `✘ ${summary.runnerId} (exit ${summary.exitCode})`;
51
+ return `${base} (exit ${summary.exitCode})`;
45
52
  }
46
53
 
47
- return `✘ ${summary.runnerId}`;
54
+ return base;
48
55
  }
49
56
 
50
57
  function resolveEventPath(rawPath: unknown, cwd: string): string | undefined {
@@ -72,17 +79,9 @@ function getFormatterSettingItems(
72
79
  id: "formatMode",
73
80
  label: "Format mode",
74
81
  description:
75
- "Choose whether formatting runs after each successful write/edit tool call or once after the agent stops.",
82
+ "Choose whether formatting runs after each successful write/edit tool call, once after each turn, or once when the session shuts down.",
76
83
  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"],
84
+ values: ["tool", "turn", "session"],
86
85
  },
87
86
  {
88
87
  id: "commandTimeoutMs",
@@ -102,11 +101,19 @@ function getFormatterSettingItems(
102
101
  ];
103
102
  }
104
103
 
104
+ type FormatterContext = {
105
+ cwd: string;
106
+ hasUI: boolean;
107
+ ui: {
108
+ notify(message: string, level: "info" | "warning" | "error"): void;
109
+ };
110
+ };
111
+
105
112
  export default function (pi: ExtensionAPI) {
106
113
  let formatterConfig = loadFormatterConfig();
107
114
  const formatQueueByPath = new Map<string, Promise<void>>();
108
- const candidatePaths = new Set<string>();
109
- const successfulPaths = new Set<string>();
115
+ const pendingTurnPaths = new Set<string>();
116
+ const pendingSessionPaths = new Set<string>();
110
117
 
111
118
  const enqueueFormat = async (
112
119
  filePath: string,
@@ -130,13 +137,7 @@ export default function (pi: ExtensionAPI) {
130
137
 
131
138
  const formatResolvedPath = async (
132
139
  filePath: string,
133
- ctx: {
134
- cwd: string;
135
- hasUI: boolean;
136
- ui: {
137
- notify(message: string, level: "info" | "warning" | "error"): void;
138
- };
139
- },
140
+ ctx: FormatterContext,
140
141
  ): Promise<void> => {
141
142
  if (!(await pathExists(filePath))) {
142
143
  return;
@@ -144,7 +145,7 @@ export default function (pi: ExtensionAPI) {
144
145
 
145
146
  const showSummaries = !formatterConfig.hideCallSummariesInTui && ctx.hasUI;
146
147
  const notifyWarning = (message: string) => {
147
- const normalizedMessage = message.replace(/\s+/g, " ").trim();
148
+ const normalizedMessage = normalizeSummaryMessage(message);
148
149
 
149
150
  if (ctx.hasUI) {
150
151
  ctx.ui.notify(normalizedMessage, "warning");
@@ -162,12 +163,11 @@ export default function (pi: ExtensionAPI) {
162
163
  }
163
164
  : undefined;
164
165
 
165
- const runnerWarningReporter =
166
- showSummaries && ctx.hasUI
167
- ? () => {
168
- // Summary mode already reports failures compactly.
169
- }
170
- : notifyWarning;
166
+ const runnerWarningReporter = showSummaries
167
+ ? () => {
168
+ // Summary mode already reports failures compactly.
169
+ }
170
+ : notifyWarning;
171
171
 
172
172
  try {
173
173
  await formatFile(
@@ -179,7 +179,7 @@ export default function (pi: ExtensionAPI) {
179
179
  runnerWarningReporter,
180
180
  );
181
181
  } catch (error) {
182
- const fileLabel = formatSummaryPath(filePath, ctx.cwd);
182
+ const fileLabel = getRelativePathOrAbsolute(filePath, ctx.cwd);
183
183
  notifyWarning(`Failed to format ${fileLabel}: ${formatError(error)}`);
184
184
  }
185
185
 
@@ -187,102 +187,92 @@ export default function (pi: ExtensionAPI) {
187
187
  return;
188
188
  }
189
189
 
190
- for (const summary of summaries) {
191
- if (summary.status === "succeeded") {
192
- ctx.ui.notify(formatCallSuccessSummary(summary), "info");
193
- continue;
194
- }
190
+ const fileLabel = getRelativePathOrAbsolute(filePath, ctx.cwd);
195
191
 
196
- ctx.ui.notify(formatCallFailureSummary(summary), "info");
192
+ for (const summary of summaries) {
193
+ ctx.ui.notify(formatCallSummary(summary, fileLabel), "info");
197
194
  }
198
195
  });
199
196
  };
200
197
 
198
+ const flushPaths = async (
199
+ paths: Set<string>,
200
+ ctx: FormatterContext,
201
+ ): Promise<void> => {
202
+ const batch = [...paths];
203
+ paths.clear();
204
+
205
+ for (const filePath of batch) {
206
+ await formatResolvedPath(filePath, ctx);
207
+ }
208
+ };
209
+
210
+ const flushPendingPaths = async (ctx: FormatterContext): Promise<void> => {
211
+ await flushPaths(pendingTurnPaths, ctx);
212
+ await flushPaths(pendingSessionPaths, ctx);
213
+ };
214
+
201
215
  const reloadFormatterConfig = () => {
202
216
  formatterConfig = loadFormatterConfig();
203
217
  };
204
218
 
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") {
219
+ pi.on("tool_result", async (event, ctx) => {
220
+ if (!isWriteToolResult(event) && !isEditToolResult(event)) {
212
221
  return;
213
222
  }
214
223
 
215
- if (event.toolName !== "write" && event.toolName !== "edit") {
224
+ if (event.isError) {
216
225
  return;
217
226
  }
218
227
 
219
228
  const filePath = resolveEventPath(event.input.path, ctx.cwd);
220
- if (filePath) {
221
- candidatePaths.add(filePath);
229
+ if (!filePath) {
230
+ return;
222
231
  }
223
- });
224
232
 
225
- pi.on("tool_result", async (event, ctx) => {
226
- if (!isWriteToolResult(event) && !isEditToolResult(event)) {
233
+ if (formatterConfig.formatMode === "tool") {
234
+ await formatResolvedPath(filePath, ctx);
227
235
  return;
228
236
  }
229
237
 
230
- const filePath = resolveEventPath(event.input.path, ctx.cwd);
231
- if (!filePath) {
238
+ if (formatterConfig.formatMode === "turn") {
239
+ pendingTurnPaths.add(filePath);
232
240
  return;
233
241
  }
234
242
 
235
- if (!event.isError) {
236
- successfulPaths.add(filePath);
237
- }
243
+ pendingSessionPaths.add(filePath);
244
+ });
238
245
 
239
- if (formatterConfig.formatMode !== "afterEachToolCall" || event.isError) {
246
+ pi.on("turn_end", async (_event, ctx) => {
247
+ if (pendingTurnPaths.size === 0) {
240
248
  return;
241
249
  }
242
250
 
243
- await formatResolvedPath(filePath, ctx);
251
+ await flushPaths(pendingTurnPaths, ctx);
244
252
  });
245
253
 
246
- pi.on("agent_end", async (event, ctx) => {
247
- if (formatterConfig.formatMode !== "afterAgentStop") {
248
- candidatePaths.clear();
249
- successfulPaths.clear();
254
+ pi.on("agent_end", async (_event, ctx) => {
255
+ if (pendingTurnPaths.size === 0) {
250
256
  return;
251
257
  }
252
258
 
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
- }
259
+ await flushPaths(pendingTurnPaths, ctx);
260
+ });
263
261
 
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
- }
262
+ pi.on("session_switch", async (_event, ctx) => {
263
+ if (pendingTurnPaths.size === 0 && pendingSessionPaths.size === 0) {
264
+ return;
278
265
  }
279
266
 
280
- candidatePaths.clear();
281
- successfulPaths.clear();
267
+ await flushPendingPaths(ctx);
268
+ });
282
269
 
283
- for (const filePath of pathsToFormat) {
284
- await formatResolvedPath(filePath, ctx);
270
+ pi.on("session_shutdown", async (_event, ctx) => {
271
+ if (pendingTurnPaths.size === 0 && pendingSessionPaths.size === 0) {
272
+ return;
285
273
  }
274
+
275
+ await flushPendingPaths(ctx);
286
276
  });
287
277
 
288
278
  pi.registerCommand("formatter", {
@@ -307,10 +297,6 @@ export default function (pi: ExtensionAPI) {
307
297
 
308
298
  const syncDraftToSettingsList = (settingsList: SettingsList) => {
309
299
  settingsList.updateValue("formatMode", draft.formatMode);
310
- settingsList.updateValue(
311
- "formatOnAbort",
312
- draft.formatOnAbort ? "on" : "off",
313
- );
314
300
  settingsList.updateValue(
315
301
  "commandTimeoutMs",
316
302
  String(draft.commandTimeoutMs),
@@ -329,9 +315,8 @@ export default function (pi: ExtensionAPI) {
329
315
  const previous = cloneFormatterConfig(draft);
330
316
 
331
317
  if (id === "formatMode") {
332
- draft.formatMode = newValue as FormatterConfigSnapshot["formatMode"];
333
- } else if (id === "formatOnAbort") {
334
- draft.formatOnAbort = newValue === "on";
318
+ draft.formatMode =
319
+ newValue as FormatterConfigSnapshot["formatMode"];
335
320
  } else if (id === "commandTimeoutMs") {
336
321
  draft.commandTimeoutMs = Number.parseInt(newValue, 10);
337
322
  } else if (id === "hideCallSummariesInTui") {
@@ -341,13 +326,26 @@ export default function (pi: ExtensionAPI) {
341
326
  try {
342
327
  writeFormatterConfigSnapshot(draft);
343
328
  reloadFormatterConfig();
329
+
330
+ if (
331
+ id === "formatMode" &&
332
+ previous.formatMode !== draft.formatMode
333
+ ) {
334
+ void flushPendingPaths(ctx).catch((error) => {
335
+ const message =
336
+ error instanceof Error ? error.message : String(error);
337
+ ctx.ui.notify(
338
+ `Failed to flush pending formats: ${message}`,
339
+ "error",
340
+ );
341
+ });
342
+ }
344
343
  } catch (error) {
345
344
  const message =
346
345
  error instanceof Error ? error.message : String(error);
347
346
  draft.commandTimeoutMs = previous.commandTimeoutMs;
348
347
  draft.hideCallSummariesInTui = previous.hideCallSummariesInTui;
349
348
  draft.formatMode = previous.formatMode;
350
- draft.formatOnAbort = previous.formatOnAbort;
351
349
  syncDraftToSettingsList(settingsList);
352
350
  ctx.ui.notify(`Failed to save config: ${message}`, "error");
353
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-formatter",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "description": "Pi extension that auto-formats files after write/edit tool calls.",
5
5
  "type": "module",
6
6
  "files": [