pi-formatter 0.3.0 → 1.0.1

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 prompt after the agent finishes all its
7
+ work and yields control back to you. You can also format after each individual
8
+ tool call or defer formatting until the session shuts down.
9
+
10
+ To format after every individual edit instead, set `"formatMode": "tool"`.
9
11
 
10
12
  ## 📦 Install
11
13
 
@@ -20,13 +22,17 @@ 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 call.
26
+ Use this mode when you want files on disk to stay formatted after every edit,
27
+ even while the agent is still working.
28
+ - `prompt`: collect all files touched during the agent run and format them once
29
+ when the agent finishes and yields control back to you. This is the default.
30
+ Use this mode to avoid mid-run formatter interruptions while still getting
31
+ clean files after each response.
32
+ - `session`: collect files touched during the current session and format them
33
+ once at session shutdown, reload, or switch.
34
+ Use this mode when you want the fewest interruptions and are okay with
35
+ formatting only when the session ends.
30
36
 
31
37
  Supported file types:
32
38
 
@@ -53,17 +59,14 @@ folder (default: `~/.pi/agent`, overridable via `PI_CODING_AGENT_DIR`):
53
59
 
54
60
  ```json
55
61
  {
56
- "formatMode": "afterEachToolCall",
57
- "formatOnAbort": false,
62
+ "formatMode": "prompt",
58
63
  "commandTimeoutMs": 10000,
59
64
  "hideCallSummariesInTui": false
60
65
  }
61
66
  ```
62
67
 
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`)
68
+ - `formatMode`: formatting strategy (`"tool"` | `"prompt"` | `"session"`,
69
+ default: `"prompt"`). Use `"tool"` to format after every individual edit.
67
70
  - `commandTimeoutMs`: timeout (ms) per formatter command (default: `10000`)
68
71
  - `hideCallSummariesInTui`: hide formatter pass/fail summaries in the TUI
69
72
  (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" | "prompt" | "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
+ export const DEFAULT_FORMAT_MODE: FormatMode = "prompt";
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 === "prompt" || 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 prompt completes, 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", "prompt", "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 pendingPromptPaths = 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,84 @@ 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(pendingPromptPaths, 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 === "prompt") {
239
+ pendingPromptPaths.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("agent_end", async (_event, ctx) => {
247
+ if (pendingPromptPaths.size === 0) {
240
248
  return;
241
249
  }
242
250
 
243
- await formatResolvedPath(filePath, ctx);
251
+ await flushPaths(pendingPromptPaths, 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("session_switch", async (_event, ctx) => {
255
+ if (pendingPromptPaths.size === 0 && pendingSessionPaths.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 flushPendingPaths(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_shutdown", async (_event, ctx) => {
263
+ if (pendingPromptPaths.size === 0 && pendingSessionPaths.size === 0) {
264
+ return;
278
265
  }
279
266
 
280
- candidatePaths.clear();
281
- successfulPaths.clear();
282
-
283
- for (const filePath of pathsToFormat) {
284
- await formatResolvedPath(filePath, ctx);
285
- }
267
+ await flushPendingPaths(ctx);
286
268
  });
287
269
 
288
270
  pi.registerCommand("formatter", {
@@ -307,10 +289,6 @@ export default function (pi: ExtensionAPI) {
307
289
 
308
290
  const syncDraftToSettingsList = (settingsList: SettingsList) => {
309
291
  settingsList.updateValue("formatMode", draft.formatMode);
310
- settingsList.updateValue(
311
- "formatOnAbort",
312
- draft.formatOnAbort ? "on" : "off",
313
- );
314
292
  settingsList.updateValue(
315
293
  "commandTimeoutMs",
316
294
  String(draft.commandTimeoutMs),
@@ -329,9 +307,8 @@ export default function (pi: ExtensionAPI) {
329
307
  const previous = cloneFormatterConfig(draft);
330
308
 
331
309
  if (id === "formatMode") {
332
- draft.formatMode = newValue as FormatterConfigSnapshot["formatMode"];
333
- } else if (id === "formatOnAbort") {
334
- draft.formatOnAbort = newValue === "on";
310
+ draft.formatMode =
311
+ newValue as FormatterConfigSnapshot["formatMode"];
335
312
  } else if (id === "commandTimeoutMs") {
336
313
  draft.commandTimeoutMs = Number.parseInt(newValue, 10);
337
314
  } else if (id === "hideCallSummariesInTui") {
@@ -341,13 +318,26 @@ export default function (pi: ExtensionAPI) {
341
318
  try {
342
319
  writeFormatterConfigSnapshot(draft);
343
320
  reloadFormatterConfig();
321
+
322
+ if (
323
+ id === "formatMode" &&
324
+ previous.formatMode !== draft.formatMode
325
+ ) {
326
+ void flushPendingPaths(ctx).catch((error) => {
327
+ const message =
328
+ error instanceof Error ? error.message : String(error);
329
+ ctx.ui.notify(
330
+ `Failed to flush pending formats: ${message}`,
331
+ "error",
332
+ );
333
+ });
334
+ }
344
335
  } catch (error) {
345
336
  const message =
346
337
  error instanceof Error ? error.message : String(error);
347
338
  draft.commandTimeoutMs = previous.commandTimeoutMs;
348
339
  draft.hideCallSummariesInTui = previous.hideCallSummariesInTui;
349
340
  draft.formatMode = previous.formatMode;
350
- draft.formatOnAbort = previous.formatOnAbort;
351
341
  syncDraftToSettingsList(settingsList);
352
342
  ctx.ui.notify(`Failed to save config: ${message}`, "error");
353
343
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-formatter",
3
- "version": "0.3.0",
4
- "description": "Pi extension that auto-formats files after write/edit tool calls.",
3
+ "version": "1.0.1",
4
+ "description": "Pi extension that auto-formats files.",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "extensions",