@gotgenes/pi-autoformat 0.1.0 → 4.0.3
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/.github/workflows/ci.yml +1 -3
- package/.github/workflows/release-please.yml +29 -0
- package/.markdownlint-cli2.yaml +14 -2
- package/.pi/extensions/pi-autoformat/config.json +3 -6
- package/.pi/prompts/README.md +59 -0
- package/.pi/prompts/plan-issue.md +64 -0
- package/.pi/prompts/retro.md +144 -0
- package/.pi/prompts/ship-issue.md +77 -0
- package/.pi/prompts/tdd-plan.md +67 -0
- package/.pi/skills/pi-extension-lifecycle/SKILL.md +256 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +39 -0
- package/CHANGELOG.md +365 -0
- package/README.md +42 -109
- package/biome.json +1 -1
- package/docs/assets/logo.png +0 -0
- package/docs/assets/logo.svg +533 -0
- package/docs/configuration.md +358 -38
- package/docs/plans/0001-initial-implementation-plan.md +17 -9
- package/docs/plans/0002-richer-tui-formatter-summaries.md +220 -0
- package/docs/plans/0003-additional-pi-mutation-tools.md +273 -0
- package/docs/plans/0004-shell-driven-mutation-coverage.md +296 -0
- package/docs/plans/0010-acceptance-test-coverage.md +240 -0
- package/docs/plans/0012-remove-unused-formatter-extensions-field.md +152 -0
- package/docs/plans/0013-fallback-chain-step-type.md +280 -0
- package/docs/plans/0014-batch-by-default-formatter-dispatch.md +195 -0
- package/docs/plans/0015-builtin-treefmt-and-treefmt-nix-support.md +290 -0
- package/docs/plans/0016-detailed-formatter-output-on-failure.md +245 -0
- package/docs/plans/0022-pi-coding-agent-types.md +201 -0
- package/docs/plans/0027-format-before-agent-exit-follow-up-turn.md +355 -0
- package/docs/plans/0031-turn-end-flush-with-change-detection.md +365 -0
- package/docs/retro/0002-richer-tui-formatter-summaries.md +47 -0
- package/docs/retro/0013-fallback-chain-step-type.md +67 -0
- package/docs/retro/0015-builtin-treefmt-and-treefmt-nix-support.md +56 -0
- package/docs/retro/0016-detailed-formatter-output-on-failure.md +60 -0
- package/docs/retro/0022-pi-coding-agent-types.md +62 -0
- package/docs/testing.md +95 -0
- package/package.json +30 -11
- package/prek.toml +2 -2
- package/schemas/pi-autoformat.schema.json +145 -21
- package/src/builtin-formatters.ts +205 -0
- package/src/command-probe.ts +66 -0
- package/src/config-loader.ts +829 -90
- package/src/custom-mutation-tools.ts +125 -0
- package/src/extension.ts +469 -82
- package/src/format-scope.ts +118 -0
- package/src/formatter-config.ts +73 -36
- package/src/formatter-executor.ts +230 -34
- package/src/formatter-output-report.ts +149 -0
- package/src/formatter-registry.ts +139 -30
- package/src/index.ts +26 -5
- package/src/prompt-autoformatter.ts +148 -23
- package/src/shell-mutation-detector.ts +572 -0
- package/src/touched-files-queue.ts +72 -11
- package/test/acceptance-event-bus.test.ts +138 -0
- package/test/acceptance.test.ts +69 -0
- package/test/builtin-formatters.test.ts +382 -0
- package/test/command-probe.test.ts +79 -0
- package/test/config-loader.test.ts +640 -21
- package/test/custom-mutation-tools.test.ts +190 -0
- package/test/extension.test.ts +1535 -158
- package/test/fallback-acceptance.test.ts +98 -0
- package/test/fixtures/event-bus-emitter.ts +26 -0
- package/test/fixtures/formatter-recorder.mjs +25 -0
- package/test/format-scope.test.ts +139 -0
- package/test/formatter-config.test.ts +56 -5
- package/test/formatter-executor.test.ts +555 -35
- package/test/formatter-output-report.test.ts +178 -0
- package/test/formatter-registry.test.ts +330 -37
- package/test/helpers/rpc.ts +146 -0
- package/test/prompt-autoformatter.test.ts +315 -22
- package/test/schema.test.ts +149 -0
- package/test/shell-mutation-detector.test.ts +221 -0
- package/test/touched-files-queue.test.ts +40 -1
- package/test/types/theme-stub.test-d.ts +42 -0
package/src/extension.ts
CHANGED
|
@@ -1,56 +1,84 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
|
|
4
|
+
import type {
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
ToolCallEvent,
|
|
8
|
+
ToolResultEvent,
|
|
9
|
+
} from "@mariozechner/pi-coding-agent";
|
|
10
|
+
|
|
4
11
|
import {
|
|
5
12
|
AUTOFORMAT_EXTENSION_ID,
|
|
6
13
|
type ConfigValidationIssue,
|
|
7
14
|
type LoadConfigResult,
|
|
8
15
|
loadAutoformatConfig,
|
|
9
16
|
} from "./config-loader.js";
|
|
17
|
+
import {
|
|
18
|
+
createCustomToolHandlers,
|
|
19
|
+
parseTouchedPayload,
|
|
20
|
+
} from "./custom-mutation-tools.js";
|
|
21
|
+
import { resolveFormatScope } from "./format-scope.js";
|
|
10
22
|
import type { AutoformatConfig } from "./formatter-config.js";
|
|
11
23
|
import type { CommandRunner, CommandRunResult } from "./formatter-executor.js";
|
|
24
|
+
import { formatRunOutputBlock } from "./formatter-output-report.js";
|
|
12
25
|
import {
|
|
13
26
|
PromptAutoformatter,
|
|
14
27
|
type PromptAutoformatterResult,
|
|
15
28
|
} from "./prompt-autoformatter.js";
|
|
29
|
+
import {
|
|
30
|
+
matchWrapper,
|
|
31
|
+
parseKnownCommand,
|
|
32
|
+
SnapshotTracker,
|
|
33
|
+
} from "./shell-mutation-detector.js";
|
|
34
|
+
import {
|
|
35
|
+
type MutationSourceHandler,
|
|
36
|
+
writeOrEditHandler,
|
|
37
|
+
} from "./touched-files-queue.js";
|
|
16
38
|
|
|
17
39
|
const execFileAsync = promisify(execFile);
|
|
18
40
|
const COMMAND_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
19
41
|
|
|
20
42
|
type NotificationType = "info" | "warning" | "error";
|
|
21
43
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Narrowed view of Pi's real `ExtensionContext`, restricted to the surface
|
|
46
|
+
* this extension actually consumes. Pi's full `ExtensionContext` requires
|
|
47
|
+
* `sessionManager`, `modelRegistry`, `model`, `signal`, `isIdle`, etc., none
|
|
48
|
+
* of which the autoformatter uses. Internal helpers take this narrow alias
|
|
49
|
+
* so test stubs do not have to fabricate the unused fields, while top-level
|
|
50
|
+
* `pi.on(...)` handlers still receive the real `ExtensionContext` from Pi.
|
|
51
|
+
*/
|
|
52
|
+
type AutoformatExtensionContext = Pick<
|
|
53
|
+
ExtensionContext,
|
|
54
|
+
"cwd" | "hasUI" | "ui"
|
|
55
|
+
>;
|
|
29
56
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Re-export of Pi's real `ExtensionAPI` under the legacy `ExtensionApiLike`
|
|
59
|
+
* name so any downstream importer that pinned to the old alias keeps working.
|
|
60
|
+
* Internal usage prefers `ExtensionAPI` directly.
|
|
61
|
+
*/
|
|
62
|
+
export type ExtensionApiLike = ExtensionAPI;
|
|
35
63
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
64
|
+
const AUTOFORMAT_STATUS_KEY = "autoformat";
|
|
65
|
+
|
|
66
|
+
function setAutoformatStatus(
|
|
67
|
+
ctx: AutoformatExtensionContext,
|
|
68
|
+
text: string | undefined,
|
|
69
|
+
): void {
|
|
70
|
+
if (!ctx.hasUI) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (typeof ctx.ui.setStatus !== "function") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
ctx.ui.setStatus(AUTOFORMAT_STATUS_KEY, text);
|
|
77
|
+
}
|
|
50
78
|
|
|
51
79
|
type PromptAutoformatterLike = Pick<
|
|
52
80
|
PromptAutoformatter,
|
|
53
|
-
"recordToolResult" | "flushPrompt"
|
|
81
|
+
"recordToolResult" | "flushPrompt" | "addTouchedPath"
|
|
54
82
|
>;
|
|
55
83
|
|
|
56
84
|
type AutoformatExtensionDependencies = {
|
|
@@ -63,13 +91,13 @@ type AutoformatExtensionDependencies = {
|
|
|
63
91
|
result: PromptAutoformatterResult,
|
|
64
92
|
options: {
|
|
65
93
|
config: AutoformatConfig;
|
|
66
|
-
ctx:
|
|
94
|
+
ctx: AutoformatExtensionContext;
|
|
67
95
|
},
|
|
68
96
|
) => void;
|
|
69
97
|
reportConfigIssues?: (
|
|
70
98
|
issues: ConfigValidationIssue[],
|
|
71
99
|
options: {
|
|
72
|
-
ctx:
|
|
100
|
+
ctx: AutoformatExtensionContext;
|
|
73
101
|
},
|
|
74
102
|
) => void;
|
|
75
103
|
};
|
|
@@ -78,6 +106,8 @@ type SessionState = {
|
|
|
78
106
|
cwd: string;
|
|
79
107
|
loadResult: LoadConfigResult;
|
|
80
108
|
autoformatter: PromptAutoformatterLike;
|
|
109
|
+
snapshotTracker: SnapshotTracker | undefined;
|
|
110
|
+
unsubscribeEventBus: (() => void) | undefined;
|
|
81
111
|
};
|
|
82
112
|
|
|
83
113
|
type ExecFileError = Error & {
|
|
@@ -141,19 +171,102 @@ function createCommandRunner(commandTimeoutMs: number): CommandRunner {
|
|
|
141
171
|
};
|
|
142
172
|
}
|
|
143
173
|
|
|
144
|
-
function createDefaultAutoformatter(
|
|
174
|
+
export function createDefaultAutoformatter(
|
|
145
175
|
cwd: string,
|
|
146
176
|
config: AutoformatConfig,
|
|
147
177
|
): PromptAutoformatterLike {
|
|
178
|
+
const scope = resolveFormatScope({ cwd, setting: config.formatScope });
|
|
179
|
+
const handlers: MutationSourceHandler[] = [writeOrEditHandler];
|
|
180
|
+
|
|
181
|
+
if (config.customMutationTools.length > 0) {
|
|
182
|
+
handlers.push(...createCustomToolHandlers(config.customMutationTools));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (config.shellMutationDetection.enabled) {
|
|
186
|
+
handlers.push(createBashMutationHandler(config));
|
|
187
|
+
}
|
|
188
|
+
|
|
148
189
|
return new PromptAutoformatter(
|
|
149
190
|
cwd,
|
|
150
191
|
config,
|
|
151
192
|
createCommandRunner(config.commandTimeoutMs),
|
|
193
|
+
{ scope, mutationHandlers: handlers },
|
|
152
194
|
);
|
|
153
195
|
}
|
|
154
196
|
|
|
197
|
+
function createBashMutationHandler(
|
|
198
|
+
config: AutoformatConfig,
|
|
199
|
+
): MutationSourceHandler {
|
|
200
|
+
const detection = config.shellMutationDetection;
|
|
201
|
+
return (toolName, payload, output) => {
|
|
202
|
+
if (toolName !== "bash") {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const command = extractBashCommand(payload);
|
|
206
|
+
if (!command) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
const candidates: string[] = [];
|
|
210
|
+
if (detection.argumentParsing) {
|
|
211
|
+
candidates.push(...parseKnownCommand(command));
|
|
212
|
+
}
|
|
213
|
+
if (detection.wrappers.length > 0) {
|
|
214
|
+
candidates.push(...matchWrapper(command, output, detection.wrappers));
|
|
215
|
+
}
|
|
216
|
+
return candidates;
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extractBashCommand(payload: unknown): string | undefined {
|
|
221
|
+
if (
|
|
222
|
+
typeof payload === "object" &&
|
|
223
|
+
payload !== null &&
|
|
224
|
+
"command" in payload &&
|
|
225
|
+
typeof (payload as { command: unknown }).command === "string"
|
|
226
|
+
) {
|
|
227
|
+
return (payload as { command: string }).command;
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function subscribeToEventBus(
|
|
233
|
+
pi: ExtensionAPI,
|
|
234
|
+
config: AutoformatConfig,
|
|
235
|
+
autoformatter: PromptAutoformatterLike,
|
|
236
|
+
): (() => void) | undefined {
|
|
237
|
+
const channelConfig = config.eventBusMutationChannel;
|
|
238
|
+
if (!channelConfig.enabled || !pi.events) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
return pi.events.on(channelConfig.channel, (data: unknown) => {
|
|
242
|
+
const paths = parseTouchedPayload(data);
|
|
243
|
+
for (const candidate of paths) {
|
|
244
|
+
autoformatter.addTouchedPath(candidate);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function extractToolOutputText(
|
|
250
|
+
content: ToolResultEvent["content"] | undefined,
|
|
251
|
+
): string {
|
|
252
|
+
if (!content) {
|
|
253
|
+
return "";
|
|
254
|
+
}
|
|
255
|
+
const parts: string[] = [];
|
|
256
|
+
for (const item of content) {
|
|
257
|
+
if (
|
|
258
|
+
item &&
|
|
259
|
+
"text" in item &&
|
|
260
|
+
typeof (item as { text?: unknown }).text === "string"
|
|
261
|
+
) {
|
|
262
|
+
parts.push((item as { text: string }).text);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return parts.join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
155
268
|
function reportMessage(
|
|
156
|
-
ctx:
|
|
269
|
+
ctx: AutoformatExtensionContext,
|
|
157
270
|
message: string,
|
|
158
271
|
type: NotificationType,
|
|
159
272
|
): void {
|
|
@@ -173,82 +286,304 @@ function reportMessage(
|
|
|
173
286
|
|
|
174
287
|
type FailureSummary = {
|
|
175
288
|
lines: string[];
|
|
176
|
-
|
|
289
|
+
failedBatchCount: number;
|
|
177
290
|
};
|
|
178
291
|
|
|
179
|
-
function
|
|
292
|
+
function formatterLabel(
|
|
293
|
+
name: string,
|
|
294
|
+
fallbackContext?: { skipped: string[] },
|
|
295
|
+
): string {
|
|
296
|
+
if (!fallbackContext || fallbackContext.skipped.length === 0) {
|
|
297
|
+
return name;
|
|
298
|
+
}
|
|
299
|
+
return `${name} (fallback after ${fallbackContext.skipped.join(", ")} unavailable)`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function summarizeFailures(
|
|
303
|
+
result: PromptAutoformatterResult,
|
|
304
|
+
config?: AutoformatConfig,
|
|
305
|
+
): FailureSummary {
|
|
180
306
|
const lines: string[] = [];
|
|
181
|
-
let
|
|
307
|
+
let failedBatchCount = 0;
|
|
308
|
+
|
|
309
|
+
for (const group of result.groups) {
|
|
310
|
+
for (const run of group.runs) {
|
|
311
|
+
if (run.success) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
failedBatchCount += 1;
|
|
315
|
+
lines.push(
|
|
316
|
+
`${formatterLabel(run.formatterName, run.fallbackContext)} (exit ${run.exitCode}): ${run.files.join(", ")}`,
|
|
317
|
+
);
|
|
318
|
+
if (config) {
|
|
319
|
+
const outputBlock = formatRunOutputBlock(run, config.formatterOutput);
|
|
320
|
+
if (outputBlock) {
|
|
321
|
+
lines.push(outputBlock);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { lines, failedBatchCount };
|
|
328
|
+
}
|
|
182
329
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
330
|
+
function summarizeFallbackUsages(result: PromptAutoformatterResult): string[] {
|
|
331
|
+
const lines: string[] = [];
|
|
332
|
+
for (const group of result.groups) {
|
|
333
|
+
for (const run of group.runs) {
|
|
334
|
+
if (!run.success) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!run.fallbackContext || run.fallbackContext.skipped.length === 0) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
lines.push(formatterLabel(run.formatterName, run.fallbackContext));
|
|
187
341
|
}
|
|
342
|
+
}
|
|
343
|
+
return lines;
|
|
344
|
+
}
|
|
188
345
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
346
|
+
function collectAllFiles(result: PromptAutoformatterResult): string[] {
|
|
347
|
+
const files: string[] = [];
|
|
348
|
+
for (const group of result.groups) {
|
|
349
|
+
files.push(...group.files);
|
|
350
|
+
}
|
|
351
|
+
return files;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function summarizeSuccessPaths(files: string[]): string | undefined {
|
|
355
|
+
if (files.length === 0 || files.length > 3) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
return files.join(", ");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
type FlushSummary = {
|
|
362
|
+
groupCount: number;
|
|
363
|
+
fileCount: number;
|
|
364
|
+
successBatchCount: number;
|
|
365
|
+
failureBatchCount: number;
|
|
366
|
+
failureLines: string[];
|
|
367
|
+
formatterLabels: string[];
|
|
368
|
+
fallbackUsages: string[];
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
function summarizeFlush(
|
|
372
|
+
result: PromptAutoformatterResult,
|
|
373
|
+
config?: AutoformatConfig,
|
|
374
|
+
): FlushSummary {
|
|
375
|
+
const failureSummary = summarizeFailures(result, config);
|
|
376
|
+
const fallbackUsages = summarizeFallbackUsages(result);
|
|
377
|
+
const fileCount = collectAllFiles(result).length;
|
|
378
|
+
|
|
379
|
+
const seen = new Set<string>();
|
|
380
|
+
const formatterLabels: string[] = [];
|
|
381
|
+
let successBatchCount = 0;
|
|
382
|
+
for (const group of result.groups) {
|
|
383
|
+
for (const run of group.runs) {
|
|
384
|
+
if (run.success) {
|
|
385
|
+
successBatchCount += 1;
|
|
386
|
+
}
|
|
387
|
+
const label = formatterLabel(run.formatterName, run.fallbackContext);
|
|
388
|
+
if (!seen.has(label)) {
|
|
389
|
+
seen.add(label);
|
|
390
|
+
formatterLabels.push(label);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
194
393
|
}
|
|
195
394
|
|
|
196
395
|
return {
|
|
197
|
-
|
|
198
|
-
|
|
396
|
+
groupCount: result.groups.length,
|
|
397
|
+
fileCount,
|
|
398
|
+
successBatchCount,
|
|
399
|
+
failureBatchCount: failureSummary.failedBatchCount,
|
|
400
|
+
failureLines: failureSummary.lines,
|
|
401
|
+
formatterLabels,
|
|
402
|
+
fallbackUsages,
|
|
199
403
|
};
|
|
200
404
|
}
|
|
201
405
|
|
|
202
|
-
|
|
406
|
+
type ThemeColorName = "success" | "warning" | "error" | "dim" | "accent";
|
|
407
|
+
|
|
408
|
+
function themed(
|
|
409
|
+
ctx: AutoformatExtensionContext,
|
|
410
|
+
color: ThemeColorName,
|
|
411
|
+
text: string,
|
|
412
|
+
): string {
|
|
413
|
+
const theme = ctx.ui.theme;
|
|
414
|
+
if (!theme || typeof theme.fg !== "function") {
|
|
415
|
+
return text;
|
|
416
|
+
}
|
|
417
|
+
// Call through the theme object so `this` stays bound. Pi's real Theme.fg
|
|
418
|
+
// is an instance method that reads `this.fgColors`; destructuring it would
|
|
419
|
+
// throw "Cannot read properties of undefined (reading 'fgColors')".
|
|
420
|
+
try {
|
|
421
|
+
return theme.fg(color, text);
|
|
422
|
+
} catch {
|
|
423
|
+
// Defensive: a theme that throws on a known color name (e.g. a partial
|
|
424
|
+
// palette) should degrade to plain text rather than break the flush.
|
|
425
|
+
return text;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function formatStatusLine(
|
|
430
|
+
summary: FlushSummary,
|
|
431
|
+
ctx: AutoformatExtensionContext,
|
|
432
|
+
): string {
|
|
433
|
+
const fileWord = summary.fileCount === 1 ? "file" : "files";
|
|
434
|
+
const formatters =
|
|
435
|
+
summary.formatterLabels.length > 0
|
|
436
|
+
? ` (${summary.formatterLabels.join(", ")})`
|
|
437
|
+
: "";
|
|
438
|
+
const label = themed(ctx, "dim", "autoformat:");
|
|
439
|
+
|
|
440
|
+
if (summary.failureBatchCount > 0) {
|
|
441
|
+
const batchWord = summary.failureBatchCount === 1 ? "batch" : "batches";
|
|
442
|
+
const mark = themed(ctx, "error", "\u2717");
|
|
443
|
+
const failureClause = themed(
|
|
444
|
+
ctx,
|
|
445
|
+
"error",
|
|
446
|
+
`${summary.failureBatchCount} ${batchWord} failed`,
|
|
447
|
+
);
|
|
448
|
+
const okSuffix =
|
|
449
|
+
summary.successBatchCount > 0
|
|
450
|
+
? themed(ctx, "dim", ` \u2014 ${summary.successBatchCount} ok`)
|
|
451
|
+
: "";
|
|
452
|
+
return `${mark} ${label} ${failureClause}${formatters}${okSuffix}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const mark = themed(ctx, "success", "\u2713");
|
|
456
|
+
return `${mark} ${label} ${summary.fileCount} ${fileWord}${formatters}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const STEERING_MAX_FILES = 10;
|
|
460
|
+
|
|
461
|
+
export function buildSteeringMessageContent(
|
|
203
462
|
result: PromptAutoformatterResult,
|
|
204
463
|
): string | undefined {
|
|
205
|
-
if (result.
|
|
464
|
+
if (result.groups.length === 0) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const changedFiles: string[] = [];
|
|
469
|
+
const failureLines: string[] = [];
|
|
470
|
+
|
|
471
|
+
for (const group of result.groups) {
|
|
472
|
+
changedFiles.push(...group.changedFiles);
|
|
473
|
+
for (const run of group.runs) {
|
|
474
|
+
if (run.success) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const fileList = run.files.join(", ");
|
|
478
|
+
failureLines.push(
|
|
479
|
+
` ${run.formatterName} (exit ${run.exitCode}) on ${fileList}:`,
|
|
480
|
+
);
|
|
481
|
+
if (run.stderr) {
|
|
482
|
+
failureLines.push(` ${run.stderr}`);
|
|
483
|
+
}
|
|
484
|
+
if (run.stdout) {
|
|
485
|
+
failureLines.push(` ${run.stdout}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (changedFiles.length === 0 && failureLines.length === 0) {
|
|
206
491
|
return undefined;
|
|
207
492
|
}
|
|
208
493
|
|
|
209
|
-
|
|
494
|
+
const parts: string[] = [];
|
|
495
|
+
|
|
496
|
+
if (changedFiles.length > 0) {
|
|
497
|
+
const shown = changedFiles.slice(0, STEERING_MAX_FILES);
|
|
498
|
+
const remaining = changedFiles.length - shown.length;
|
|
499
|
+
let list = shown.join(", ");
|
|
500
|
+
if (remaining > 0) {
|
|
501
|
+
list += `, \u2026 and ${remaining} more`;
|
|
502
|
+
}
|
|
503
|
+
parts.push(
|
|
504
|
+
`[autoformat] Formatted ${changedFiles.length} file(s): ${list}`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (failureLines.length > 0) {
|
|
509
|
+
if (changedFiles.length === 0) {
|
|
510
|
+
parts.push(["[autoformat] Failures:", ...failureLines].join("\n"));
|
|
511
|
+
} else {
|
|
512
|
+
parts.push(["Failures:", ...failureLines].join("\n"));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return parts.join("\n\n") || undefined;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function buildLegacyFailureMessage(summary: FlushSummary): string {
|
|
520
|
+
const batchWord = summary.failureBatchCount === 1 ? "batch" : "batches";
|
|
521
|
+
return [
|
|
522
|
+
`Formatter failures in ${summary.failureBatchCount} ${batchWord}:`,
|
|
523
|
+
...summary.failureLines,
|
|
524
|
+
].join("\n");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function buildLegacySuccessMessage(
|
|
528
|
+
result: PromptAutoformatterResult,
|
|
529
|
+
summary: FlushSummary,
|
|
530
|
+
): string {
|
|
531
|
+
const allFiles = collectAllFiles(result);
|
|
532
|
+
const successPaths = summarizeSuccessPaths(allFiles);
|
|
533
|
+
const fileWord = allFiles.length === 1 ? "file" : "files";
|
|
534
|
+
const baseMessage = successPaths
|
|
535
|
+
? `Autoformatted ${allFiles.length} ${fileWord}: ${successPaths}`
|
|
536
|
+
: `Autoformatted ${allFiles.length} ${fileWord}.`;
|
|
537
|
+
|
|
538
|
+
return summary.fallbackUsages.length > 0
|
|
539
|
+
? `${baseMessage} [${summary.fallbackUsages.join("; ")}]`
|
|
540
|
+
: baseMessage;
|
|
210
541
|
}
|
|
211
542
|
|
|
212
543
|
function defaultReportFlushResult(
|
|
213
544
|
result: PromptAutoformatterResult,
|
|
214
545
|
options: {
|
|
215
546
|
config: AutoformatConfig;
|
|
216
|
-
ctx:
|
|
547
|
+
ctx: AutoformatExtensionContext;
|
|
217
548
|
},
|
|
218
549
|
): void {
|
|
219
|
-
if (result.
|
|
550
|
+
if (result.groups.length === 0) {
|
|
551
|
+
setAutoformatStatus(options.ctx, undefined);
|
|
220
552
|
return;
|
|
221
553
|
}
|
|
222
554
|
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
"warning",
|
|
232
|
-
);
|
|
555
|
+
const summary = summarizeFlush(result, options.config);
|
|
556
|
+
|
|
557
|
+
if (summary.failureBatchCount > 0) {
|
|
558
|
+
const message = buildLegacyFailureMessage(summary);
|
|
559
|
+
if (options.ctx.hasUI) {
|
|
560
|
+
setAutoformatStatus(options.ctx, formatStatusLine(summary, options.ctx));
|
|
561
|
+
}
|
|
562
|
+
reportMessage(options.ctx, message, "warning");
|
|
233
563
|
return;
|
|
234
564
|
}
|
|
235
565
|
|
|
236
566
|
if (options.config.hideSummariesInTui && options.ctx.hasUI) {
|
|
567
|
+
setAutoformatStatus(options.ctx, undefined);
|
|
237
568
|
return;
|
|
238
569
|
}
|
|
239
570
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
571
|
+
if (options.ctx.hasUI) {
|
|
572
|
+
setAutoformatStatus(options.ctx, formatStatusLine(summary, options.ctx));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
244
575
|
|
|
245
|
-
reportMessage(
|
|
576
|
+
reportMessage(
|
|
577
|
+
options.ctx,
|
|
578
|
+
buildLegacySuccessMessage(result, summary),
|
|
579
|
+
"info",
|
|
580
|
+
);
|
|
246
581
|
}
|
|
247
582
|
|
|
248
583
|
function defaultReportConfigIssues(
|
|
249
584
|
issues: ConfigValidationIssue[],
|
|
250
585
|
options: {
|
|
251
|
-
ctx:
|
|
586
|
+
ctx: AutoformatExtensionContext;
|
|
252
587
|
},
|
|
253
588
|
): void {
|
|
254
589
|
if (issues.length === 0) {
|
|
@@ -277,7 +612,7 @@ function defaultReportConfigIssues(
|
|
|
277
612
|
}
|
|
278
613
|
|
|
279
614
|
export function createAutoformatExtension(
|
|
280
|
-
pi:
|
|
615
|
+
pi: ExtensionAPI,
|
|
281
616
|
dependencies: AutoformatExtensionDependencies = {},
|
|
282
617
|
): void {
|
|
283
618
|
const loadConfig =
|
|
@@ -290,7 +625,9 @@ export function createAutoformatExtension(
|
|
|
290
625
|
dependencies.reportConfigIssues ?? defaultReportConfigIssues;
|
|
291
626
|
|
|
292
627
|
let state: SessionState | undefined;
|
|
293
|
-
let pendingFlush = Promise.resolve(
|
|
628
|
+
let pendingFlush = Promise.resolve<PromptAutoformatterResult | undefined>(
|
|
629
|
+
undefined,
|
|
630
|
+
);
|
|
294
631
|
|
|
295
632
|
function ensureState(cwd: string): SessionState {
|
|
296
633
|
if (state && state.cwd === cwd) {
|
|
@@ -298,15 +635,33 @@ export function createAutoformatExtension(
|
|
|
298
635
|
}
|
|
299
636
|
|
|
300
637
|
const loadResult = loadConfig(cwd);
|
|
638
|
+
const detection = loadResult.config.shellMutationDetection;
|
|
639
|
+
const snapshotTracker =
|
|
640
|
+
detection.enabled && detection.snapshotGlobs.length > 0
|
|
641
|
+
? new SnapshotTracker({
|
|
642
|
+
cwd,
|
|
643
|
+
globs: detection.snapshotGlobs,
|
|
644
|
+
})
|
|
645
|
+
: undefined;
|
|
646
|
+
const autoformatter = createAutoformatter(cwd, loadResult.config);
|
|
647
|
+
const unsubscribeEventBus = subscribeToEventBus(
|
|
648
|
+
pi,
|
|
649
|
+
loadResult.config,
|
|
650
|
+
autoformatter,
|
|
651
|
+
);
|
|
301
652
|
state = {
|
|
302
653
|
cwd,
|
|
303
654
|
loadResult,
|
|
304
|
-
autoformatter
|
|
655
|
+
autoformatter,
|
|
656
|
+
snapshotTracker,
|
|
657
|
+
unsubscribeEventBus,
|
|
305
658
|
};
|
|
306
659
|
return state;
|
|
307
660
|
}
|
|
308
661
|
|
|
309
|
-
function queueFlush(
|
|
662
|
+
function queueFlush(
|
|
663
|
+
ctx: AutoformatExtensionContext,
|
|
664
|
+
): Promise<PromptAutoformatterResult | undefined> {
|
|
310
665
|
const sessionState = state;
|
|
311
666
|
if (!sessionState) {
|
|
312
667
|
return pendingFlush;
|
|
@@ -319,10 +674,12 @@ export function createAutoformatExtension(
|
|
|
319
674
|
config: sessionState.loadResult.config,
|
|
320
675
|
ctx,
|
|
321
676
|
});
|
|
677
|
+
return result;
|
|
322
678
|
})
|
|
323
679
|
.catch((error: unknown) => {
|
|
324
680
|
const message = error instanceof Error ? error.message : String(error);
|
|
325
681
|
reportMessage(ctx, `Unexpected runtime error: ${message}`, "warning");
|
|
682
|
+
return undefined;
|
|
326
683
|
});
|
|
327
684
|
|
|
328
685
|
return pendingFlush;
|
|
@@ -331,27 +688,55 @@ export function createAutoformatExtension(
|
|
|
331
688
|
pi.on("session_start", async (_event, ctx) => {
|
|
332
689
|
const sessionState = ensureState(ctx.cwd);
|
|
333
690
|
reportConfigIssues(sessionState.loadResult.issues, { ctx });
|
|
691
|
+
setAutoformatStatus(ctx, undefined);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
pi.on("tool_call", async (event: ToolCallEvent, ctx) => {
|
|
695
|
+
if (event.toolName !== "bash") {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const sessionState = ensureState(ctx.cwd);
|
|
699
|
+
sessionState.snapshotTracker?.before();
|
|
334
700
|
});
|
|
335
701
|
|
|
336
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
702
|
+
pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
|
|
337
703
|
if (event.isError) {
|
|
338
704
|
return;
|
|
339
705
|
}
|
|
340
706
|
|
|
341
707
|
const sessionState = ensureState(ctx.cwd);
|
|
342
|
-
|
|
708
|
+
const output = extractToolOutputText(event.content);
|
|
709
|
+
sessionState.autoformatter.recordToolResult(
|
|
710
|
+
event.toolName,
|
|
711
|
+
event.input,
|
|
712
|
+
output,
|
|
713
|
+
);
|
|
343
714
|
|
|
344
|
-
if (
|
|
345
|
-
|
|
715
|
+
if (event.toolName === "bash" && sessionState.snapshotTracker) {
|
|
716
|
+
const snapshotTouched = sessionState.snapshotTracker.after();
|
|
717
|
+
for (const touched of snapshotTouched) {
|
|
718
|
+
sessionState.autoformatter.addTouchedPath(touched);
|
|
719
|
+
}
|
|
346
720
|
}
|
|
347
721
|
});
|
|
348
722
|
|
|
349
|
-
pi.on("
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
352
|
-
|
|
723
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
724
|
+
const result = await queueFlush(ctx);
|
|
725
|
+
if (result) {
|
|
726
|
+
const content = buildSteeringMessageContent(result);
|
|
727
|
+
if (content) {
|
|
728
|
+
pi.sendMessage({
|
|
729
|
+
customType: "autoformat-steering",
|
|
730
|
+
content,
|
|
731
|
+
display: true,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
353
734
|
}
|
|
735
|
+
});
|
|
354
736
|
|
|
737
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
738
|
+
// Safety-net flush: in the normal case, turn_end has already drained
|
|
739
|
+
// the queue, so this is a no-op.
|
|
355
740
|
await queueFlush(ctx);
|
|
356
741
|
});
|
|
357
742
|
|
|
@@ -361,14 +746,16 @@ export function createAutoformatExtension(
|
|
|
361
746
|
return;
|
|
362
747
|
}
|
|
363
748
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
749
|
+
// Final safety-net flush for any touched files not yet formatted
|
|
750
|
+
// (e.g. files added via EventBus without an agent loop).
|
|
751
|
+
await queueFlush(ctx);
|
|
367
752
|
|
|
753
|
+
setAutoformatStatus(ctx, undefined);
|
|
754
|
+
sessionState.unsubscribeEventBus?.();
|
|
368
755
|
state = undefined;
|
|
369
756
|
});
|
|
370
757
|
}
|
|
371
758
|
|
|
372
|
-
export default function autoformatExtension(pi:
|
|
759
|
+
export default function autoformatExtension(pi: ExtensionAPI): void {
|
|
373
760
|
createAutoformatExtension(pi);
|
|
374
761
|
}
|