pi-formatter 0.1.1 → 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.
Files changed (26) hide show
  1. package/README.md +55 -10
  2. package/extensions/formatter/config.ts +140 -0
  3. package/extensions/{format → formatter}/context.ts +58 -37
  4. package/extensions/{format → formatter}/dispatch.ts +114 -32
  5. package/extensions/{format → formatter}/path.ts +19 -3
  6. package/extensions/{format → formatter}/plan.ts +5 -5
  7. package/extensions/{format/runners/biome-check-write.ts → formatter/runners/biome.ts} +4 -4
  8. package/extensions/formatter/runners/config-patterns.ts +5 -0
  9. package/extensions/{format/runners/eslint-fix.ts → formatter/runners/eslint.ts} +3 -3
  10. package/extensions/{format → formatter}/runners/index.ts +10 -12
  11. package/extensions/{format/runners/markdownlint-fix.ts → formatter/runners/markdownlint.ts} +3 -3
  12. package/extensions/{format/runners/prettier-markdown.ts → formatter/runners/prettier.ts} +3 -3
  13. package/extensions/{format/runners/ruff-check-fix.ts → formatter/runners/ruff-check.ts} +3 -3
  14. package/extensions/{format → formatter}/types.ts +4 -6
  15. package/extensions/index.ts +332 -20
  16. package/package.json +1 -2
  17. package/DOCUMENTATION.md +0 -120
  18. package/extensions/format/config.ts +0 -11
  19. package/extensions/format/runners/config-patterns.ts +0 -6
  20. package/extensions/format/runners/prettier-config-write.ts +0 -11
  21. /package/extensions/{format → formatter}/runners/clang-format.ts +0 -0
  22. /package/extensions/{format → formatter}/runners/cmake-format.ts +0 -0
  23. /package/extensions/{format → formatter}/runners/helpers.ts +0 -0
  24. /package/extensions/{format → formatter}/runners/ruff-format.ts +0 -0
  25. /package/extensions/{format → formatter}/runners/shfmt.ts +0 -0
  26. /package/extensions/{format → formatter}/system.ts +0 -0
@@ -5,22 +5,22 @@ export const FORMAT_PLAN: Record<FileKind, RunnerGroup[]> = {
5
5
  cmake: [{ mode: "all", runnerIds: ["cmake-format"] }],
6
6
  markdown: [
7
7
  {
8
- mode: "all",
9
- runnerIds: ["markdownlint-fix", "prettier-markdown"],
8
+ mode: "fallback",
9
+ runnerIds: ["prettier", "markdownlint"],
10
10
  },
11
11
  ],
12
12
  json: [
13
13
  {
14
14
  mode: "fallback",
15
- runnerIds: ["biome-check-write", "prettier-config-write"],
15
+ runnerIds: ["biome", "prettier"],
16
16
  },
17
17
  ],
18
18
  shell: [{ mode: "all", runnerIds: ["shfmt"] }],
19
- python: [{ mode: "all", runnerIds: ["ruff-format", "ruff-check-fix"] }],
19
+ python: [{ mode: "all", runnerIds: ["ruff-format", "ruff-check"] }],
20
20
  jsts: [
21
21
  {
22
22
  mode: "fallback",
23
- runnerIds: ["biome-check-write", "eslint-fix", "prettier-config-write"],
23
+ runnerIds: ["biome", "eslint", "prettier"],
24
24
  },
25
25
  ],
26
26
  };
@@ -1,11 +1,11 @@
1
- import { defineRunner, direct } from "./helpers.js";
2
1
  import { BIOME_CONFIG_PATTERNS } from "./config-patterns.js";
2
+ import { defineRunner, direct } from "./helpers.js";
3
3
 
4
- const biomeCheckWriteRunner = defineRunner({
5
- id: "biome-check-write",
4
+ const biomeRunner = defineRunner({
5
+ id: "biome",
6
6
  launcher: direct("biome"),
7
7
  when: (ctx) => ctx.hasConfig(BIOME_CONFIG_PATTERNS),
8
8
  args: ["check", "--write"],
9
9
  });
10
10
 
11
- export default biomeCheckWriteRunner;
11
+ export default biomeRunner;
@@ -0,0 +1,5 @@
1
+ export const BIOME_CONFIG_PATTERNS = ["biome.json", "biome.jsonc"] as const;
2
+ export const ESLINT_CONFIG_PATTERNS = [
3
+ "eslint.config.*",
4
+ ".eslintrc*",
5
+ ] as const;
@@ -1,11 +1,11 @@
1
1
  import { ESLINT_CONFIG_PATTERNS } from "./config-patterns.js";
2
2
  import { defineRunner, direct } from "./helpers.js";
3
3
 
4
- const eslintFixRunner = defineRunner({
5
- id: "eslint-fix",
4
+ const eslintRunner = defineRunner({
5
+ id: "eslint",
6
6
  launcher: direct("eslint"),
7
7
  when: (ctx) => ctx.hasConfig(ESLINT_CONFIG_PATTERNS),
8
8
  args: ["--fix"],
9
9
  });
10
10
 
11
- export default eslintFixRunner;
11
+ export default eslintRunner;
@@ -1,26 +1,24 @@
1
1
  import type { RunnerDefinition } from "../types.js";
2
- import biomeCheckWriteRunner from "./biome-check-write.js";
2
+ import biomeRunner from "./biome.js";
3
3
  import clangFormatRunner from "./clang-format.js";
4
4
  import cmakeFormatRunner from "./cmake-format.js";
5
- import eslintFixRunner from "./eslint-fix.js";
6
- import markdownlintFixRunner from "./markdownlint-fix.js";
7
- import prettierConfigWriteRunner from "./prettier-config-write.js";
8
- import prettierMarkdownRunner from "./prettier-markdown.js";
9
- import ruffCheckFixRunner from "./ruff-check-fix.js";
5
+ import eslintRunner from "./eslint.js";
6
+ import markdownlintRunner from "./markdownlint.js";
7
+ import prettierRunner from "./prettier.js";
8
+ import ruffCheckRunner from "./ruff-check.js";
10
9
  import ruffFormatRunner from "./ruff-format.js";
11
10
  import shfmtRunner from "./shfmt.js";
12
11
 
13
12
  export const RUNNER_DEFINITIONS: RunnerDefinition[] = [
14
13
  clangFormatRunner,
15
14
  cmakeFormatRunner,
16
- markdownlintFixRunner,
17
- prettierMarkdownRunner,
18
- biomeCheckWriteRunner,
19
- eslintFixRunner,
20
- prettierConfigWriteRunner,
15
+ markdownlintRunner,
16
+ biomeRunner,
17
+ eslintRunner,
18
+ prettierRunner,
21
19
  shfmtRunner,
22
20
  ruffFormatRunner,
23
- ruffCheckFixRunner,
21
+ ruffCheckRunner,
24
22
  ];
25
23
 
26
24
  function buildRunnerRegistry(
@@ -1,9 +1,9 @@
1
1
  import { defineRunner, direct } from "./helpers.js";
2
2
 
3
- const markdownlintFixRunner = defineRunner({
4
- id: "markdownlint-fix",
3
+ const markdownlintRunner = defineRunner({
4
+ id: "markdownlint",
5
5
  launcher: direct("markdownlint"),
6
6
  args: ["--fix"],
7
7
  });
8
8
 
9
- export default markdownlintFixRunner;
9
+ export default markdownlintRunner;
@@ -1,9 +1,9 @@
1
1
  import { defineRunner, direct } from "./helpers.js";
2
2
 
3
- const prettierMarkdownRunner = defineRunner({
4
- id: "prettier-markdown",
3
+ const prettierRunner = defineRunner({
4
+ id: "prettier",
5
5
  launcher: direct("prettier"),
6
6
  args: ["--write"],
7
7
  });
8
8
 
9
- export default prettierMarkdownRunner;
9
+ export default prettierRunner;
@@ -1,9 +1,9 @@
1
1
  import { defineRunner, pypi } from "./helpers.js";
2
2
 
3
- const ruffCheckFixRunner = defineRunner({
4
- id: "ruff-check-fix",
3
+ const ruffCheckRunner = defineRunner({
4
+ id: "ruff-check",
5
5
  launcher: pypi("ruff"),
6
6
  args: ["check", "--fix"],
7
7
  });
8
8
 
9
- export default ruffCheckFixRunner;
9
+ export default ruffCheckRunner;
@@ -1,7 +1,5 @@
1
1
  import type { ExecResult } from "@mariozechner/pi-coding-agent";
2
2
 
3
- export type SourceTool = "write" | "edit";
4
-
5
3
  export type FileKind =
6
4
  | "cxx"
7
5
  | "cmake"
@@ -40,7 +38,6 @@ export interface ResolvedLauncher {
40
38
  export interface RunnerContext {
41
39
  readonly filePath: string;
42
40
  readonly cwd: string;
43
- readonly sourceTool: SourceTool;
44
41
  readonly kind: FileKind;
45
42
 
46
43
  hasCommand(command: string): Promise<boolean>;
@@ -48,7 +45,7 @@ export interface RunnerContext {
48
45
  findConfigFile(patterns: readonly string[]): Promise<string | undefined>;
49
46
  hasEditorConfigInCwd(): Promise<boolean>;
50
47
 
51
- exec(command: string, args: string[]): Promise<ExecResult | undefined>;
48
+ exec(command: string, args: string[]): Promise<ExecResult>;
52
49
  getChangedLines(): Promise<string[]>;
53
50
  getRequiredMajorVersionFromConfig(
54
51
  patterns: readonly string[],
@@ -94,8 +91,9 @@ export interface StaticRunnerDefinition extends RunnerBase {
94
91
  }
95
92
 
96
93
  export interface DynamicRunnerDefinition extends RunnerBase {
97
- buildArgs:
98
- (ctx: RunnerContext) => Promise<string[] | undefined> | string[] | undefined;
94
+ buildArgs: (
95
+ ctx: RunnerContext,
96
+ ) => Promise<string[] | undefined> | string[] | undefined;
99
97
  }
100
98
 
101
99
  export type RunnerDefinition = StaticRunnerDefinition | DynamicRunnerDefinition;
@@ -1,15 +1,112 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { commandTimeoutMs } from "./format/config.js";
3
- import { formatFile } from "./format/dispatch.js";
4
- import { pathExists, resolveToolPath } from "./format/path.js";
5
- import type { SourceTool } from "./format/types.js";
1
+ import { basename } from "node:path";
2
+ import {
3
+ type ExtensionAPI,
4
+ getSettingsListTheme,
5
+ isEditToolResult,
6
+ isWriteToolResult,
7
+ } from "@mariozechner/pi-coding-agent";
8
+ import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
9
+ import {
10
+ cloneFormatterConfig,
11
+ type FormatterConfigSnapshot,
12
+ getFormatterConfigPath,
13
+ loadFormatterConfig,
14
+ writeFormatterConfigSnapshot,
15
+ } from "./formatter/config.js";
16
+ import { type FormatCallSummary, formatFile } from "./formatter/dispatch.js";
17
+ import {
18
+ getPathForGit,
19
+ pathExists,
20
+ resolveToolPath,
21
+ } from "./formatter/path.js";
6
22
 
7
23
  function formatError(error: unknown): string {
8
24
  return error instanceof Error ? error.message : String(error);
9
25
  }
10
26
 
27
+ function formatSummaryPath(filePath: string, cwd: string): string {
28
+ const pathForDisplay = getPathForGit(filePath, cwd);
29
+ return pathForDisplay.startsWith("/")
30
+ ? basename(pathForDisplay)
31
+ : pathForDisplay;
32
+ }
33
+
34
+ function formatCallSuccessSummary(summary: FormatCallSummary): string {
35
+ return `✔︎ ${summary.runnerId}`;
36
+ }
37
+
38
+ function formatCallFailureSummary(summary: FormatCallSummary): string {
39
+ if (summary.failureMessage) {
40
+ return `✘ ${summary.runnerId}: ${summary.failureMessage}`;
41
+ }
42
+
43
+ if (summary.exitCode !== undefined) {
44
+ return `✘ ${summary.runnerId} (exit ${summary.exitCode})`;
45
+ }
46
+
47
+ return `✘ ${summary.runnerId}`;
48
+ }
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
+
11
105
  export default function (pi: ExtensionAPI) {
106
+ let formatterConfig = loadFormatterConfig();
12
107
  const formatQueueByPath = new Map<string, Promise<void>>();
108
+ const candidatePaths = new Set<string>();
109
+ const successfulPaths = new Set<string>();
13
110
 
14
111
  const enqueueFormat = async (
15
112
  filePath: string,
@@ -31,8 +128,87 @@ export default function (pi: ExtensionAPI) {
31
128
  await next;
32
129
  };
33
130
 
34
- pi.on("tool_result", async (event, ctx) => {
35
- if (event.isError) {
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> => {
141
+ if (!(await pathExists(filePath))) {
142
+ return;
143
+ }
144
+
145
+ const showSummaries = !formatterConfig.hideCallSummariesInTui && ctx.hasUI;
146
+ const notifyWarning = (message: string) => {
147
+ const normalizedMessage = message.replace(/\s+/g, " ").trim();
148
+
149
+ if (ctx.hasUI) {
150
+ ctx.ui.notify(normalizedMessage, "warning");
151
+ return;
152
+ }
153
+
154
+ console.warn(normalizedMessage);
155
+ };
156
+
157
+ await enqueueFormat(filePath, async () => {
158
+ const summaries: FormatCallSummary[] = [];
159
+ const summaryReporter = showSummaries
160
+ ? (summary: FormatCallSummary) => {
161
+ summaries.push(summary);
162
+ }
163
+ : undefined;
164
+
165
+ const runnerWarningReporter =
166
+ showSummaries && ctx.hasUI
167
+ ? () => {
168
+ // Summary mode already reports failures compactly.
169
+ }
170
+ : notifyWarning;
171
+
172
+ try {
173
+ await formatFile(
174
+ pi,
175
+ ctx.cwd,
176
+ filePath,
177
+ formatterConfig.commandTimeoutMs,
178
+ summaryReporter,
179
+ runnerWarningReporter,
180
+ );
181
+ } catch (error) {
182
+ const fileLabel = formatSummaryPath(filePath, ctx.cwd);
183
+ notifyWarning(`Failed to format ${fileLabel}: ${formatError(error)}`);
184
+ }
185
+
186
+ if (!showSummaries || summaries.length === 0) {
187
+ return;
188
+ }
189
+
190
+ for (const summary of summaries) {
191
+ if (summary.status === "succeeded") {
192
+ ctx.ui.notify(formatCallSuccessSummary(summary), "info");
193
+ continue;
194
+ }
195
+
196
+ ctx.ui.notify(formatCallFailureSummary(summary), "info");
197
+ }
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") {
36
212
  return;
37
213
  }
38
214
 
@@ -40,26 +216,162 @@ export default function (pi: ExtensionAPI) {
40
216
  return;
41
217
  }
42
218
 
43
- const rawPath = (event.input as { path?: unknown }).path;
44
- if (typeof rawPath !== "string" || rawPath.length === 0) {
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) {
45
232
  return;
46
233
  }
47
234
 
48
- const sourceTool = event.toolName as SourceTool;
49
- const filePath = resolveToolPath(rawPath, ctx.cwd);
235
+ if (!event.isError) {
236
+ successfulPaths.add(filePath);
237
+ }
50
238
 
51
- if (!(await pathExists(filePath))) {
239
+ if (formatterConfig.formatMode !== "afterEachToolCall" || event.isError) {
52
240
  return;
53
241
  }
54
242
 
55
- await enqueueFormat(filePath, async () => {
56
- try {
57
- await formatFile(pi, ctx.cwd, sourceTool, filePath, commandTimeoutMs);
58
- } catch (error) {
59
- console.warn(
60
- `[pi-formatter] Failed to format ${filePath}: ${formatError(error)}`,
61
- );
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;
62
258
  }
63
- });
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
+ },
64
376
  });
65
377
  }
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "pi-formatter",
3
- "version": "0.1.1",
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": [
7
7
  "extensions",
8
- "DOCUMENTATION.md",
9
8
  "README.md",
10
9
  "LICENSE"
11
10
  ],