@ian-pascoe/pi-lsp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ian Pascoe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # @ian-pascoe/pi-lsp
2
+
3
+ Configured language-server tools and post-edit diagnostics for
4
+ [Pi](https://github.com/earendil-works/pi).
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pi install npm:@ian-pascoe/pi-lsp
10
+ # or
11
+ pi install git:github.com/ian-pascoe/pi-extensions
12
+ ```
13
+
14
+ For a local checkout, run `pi -e ./packages/pi-lsp/src/index.ts`.
15
+
16
+ Install every language-server executable separately. Pi LSP contains no server catalog or
17
+ installer. It launches configured commands over stdio with the Pi process's environment and
18
+ permissions.
19
+
20
+ ## Settings
21
+
22
+ Pi LSP reads only the `lsp` key from Pi's global `settings.json` and trusted project
23
+ `.pi/settings.json`:
24
+
25
+ ```json
26
+ {
27
+ "lsp": {
28
+ "timeouts": {
29
+ "initializeMs": 45000,
30
+ "requestMs": 3000,
31
+ "diagnosticsMs": 3000,
32
+ "shutdownMs": 5000
33
+ },
34
+ "servers": {
35
+ "typescript": {
36
+ "command": "pnpm",
37
+ "args": ["exec", "tsc", "--lsp", "--stdio"],
38
+ "languages": [
39
+ {
40
+ "extensions": [".ts", ".mts", ".cts"],
41
+ "languageId": "typescript"
42
+ },
43
+ {
44
+ "extensions": [".tsx"],
45
+ "languageId": "typescriptreact"
46
+ }
47
+ ],
48
+ "rootMarkers": ["tsconfig.json", "package.json", ".git"],
49
+ "initializationOptions": {},
50
+ "settings": {},
51
+ "environment": {}
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ Every field is optional except an enabled server's non-empty `command` and `languages`. Each
59
+ language needs a non-empty `languageId` and at least one extension or exact filename. Extensions
60
+ include their leading period. `rootMarkers` are basename glob patterns; the nearest matching
61
+ ancestor becomes the server root and Pi's working directory is the fallback.
62
+
63
+ Global and project timeouts merge by field. A project server replaces the complete global server
64
+ with the same ID; set a project server to `null` to remove it. `initializationOptions` is sent only
65
+ during initialization. `settings` is used for `workspace/didChangeConfiguration` and
66
+ `workspace/configuration`. Environment strings override `process.env`; `null` removes a variable.
67
+ A malformed layer or unknown field disables LSP startup and remains visible through `status`.
68
+ Untrusted project settings are ignored.
69
+
70
+ Pi's `/reload` reloads configuration. Servers start on first use, live for one Pi session, and stay
71
+ unavailable after a process or protocol failure until `restart` or `/reload`.
72
+
73
+ ## `lsp` tool
74
+
75
+ The extension registers one strict `lsp` tool with these operations:
76
+
77
+ ```text
78
+ status capabilities restart
79
+ diagnostics workspace_diagnostics completion
80
+ hover signature_help declaration
81
+ goto_definition goto_type_definition goto_implementation
82
+ find_references document_highlights document_symbols
83
+ workspace_symbols document_links call_hierarchy
84
+ incoming_calls outgoing_calls type_hierarchy
85
+ supertypes subtypes selection_ranges
86
+ folding_ranges code_lenses inlay_hints
87
+ document_colors format_document format_range
88
+ format_on_type prepare_rename rename
89
+ code_actions apply
90
+ ```
91
+
92
+ File operations use `file_path`. Position operations also use one-based `line` and `character`;
93
+ characters count Unicode code points, regardless of the server's negotiated UTF-8, UTF-16, or
94
+ UTF-32 encoding. `selection_ranges` accepts a `positions` array. `find_references` accepts
95
+ `include_declaration`.
96
+
97
+ `workspace_symbols` requires `query` and a root-anchor `file_path`. `workspace_diagnostics`,
98
+ `capabilities`, and `restart` require `server_id` and a root-anchor `file_path`. Other reads query
99
+ every matching capable server unless narrowed by `server_id`; successful responses remain visible
100
+ when another server fails. A mutation may omit `server_id` only when exactly one matching capable
101
+ server exists.
102
+
103
+ Formatting requires `tab_size` and `insert_spaces`. It optionally accepts
104
+ `trim_trailing_whitespace`, `insert_final_newline`, and `trim_final_newlines`. Range formatting also
105
+ requires `start` and `end`; on-type formatting requires a position and `trigger_character`.
106
+ `rename` requires `new_name`. `code_actions` accepts a range and optional `only_kinds` filters.
107
+
108
+ Workspace diagnostics use protocol workspace pull when available and cached push diagnostics
109
+ otherwise. They never crawl the project to open files. Non-file result URIs such as `jar:` remain
110
+ readable.
111
+
112
+ In Pi's interactive transcript, each LSP call stays compact until tool output is expanded. The
113
+ collapsed row shows the operation, target, and outcome; the expanded row adds structured server or
114
+ mutation details followed by the exact tool output. Rendering uses Pi's active theme and native
115
+ tool-output expansion controls.
116
+
117
+ ## Workspace Edit Preview and apply
118
+
119
+ `rename`, formatting, and edit-bearing code actions return a persisted Workspace Edit Preview.
120
+ They never write immediately. Command-bearing code actions remain visible but cannot be applied.
121
+ Server-initiated `workspace/applyEdit` requests are rejected with `applied: false` and exposed as a
122
+ preview in the active tool result.
123
+
124
+ Apply a preview with `{ "operation": "apply", "preview_id": "..." }`. Before Pi's `tool_call`
125
+ hooks run, `prepareArguments()` replaces any supplied `mutation_manifest` with canonical absolute
126
+ entries shaped as:
127
+
128
+ ```json
129
+ {
130
+ "mutation_manifest": [
131
+ {
132
+ "operation": "modify",
133
+ "path": "/absolute/canonical/content/target"
134
+ },
135
+ {
136
+ "operation": "rename",
137
+ "path": "/absolute/old/name",
138
+ "destination_path": "/absolute/new/name"
139
+ }
140
+ ]
141
+ }
142
+ ```
143
+
144
+ `operation` is `create`, `modify`, `delete`, or `rename`; only rename also has
145
+ `destination_path`. A permission extension may inspect or
146
+ block this manifest. Pi LSP validates it again after all hooks, then rechecks preview state,
147
+ existence, hashes, modes, and destinations inside Pi's sorted per-file mutation queues. Paths are
148
+ not restricted to the workspace.
149
+
150
+ Application uses temporary-file replacement, preserves modes and UTF-8 BOMs, and keeps originals
151
+ for reverse rollback. Content edits follow existing symlinks and expose the canonical target;
152
+ rename and delete address the named directory entry. The guarded batch is rollback-capable, not
153
+ crash-atomic. A recovery failure reports every unrecovered path.
154
+
155
+ ## Post-edit Diagnostics
156
+
157
+ Pi LSP appends fresh diagnostics to results from:
158
+
159
+ - any `edit` or `write` tool whose input has a string `path`;
160
+ - `apply_patch` results using the current `pi-codex-conversion` success/partial-result details;
161
+ - successful or partially applied LSP previews.
162
+
163
+ Changed, created, and renamed destination files are diagnosed; deleted files are not. Every
164
+ recognized result gets an explicit outcome, including `no diagnostics`, `no configured server`,
165
+ timeout, unavailable server, or an `apply_patch` adapter-version warning. Diagnostics preserve
166
+ duplicates from independent servers and never change the original tool's success or error state.
167
+
168
+ Findings, matched-server failures, timeouts, and adapter warnings also appear in one expandable
169
+ Post-edit Diagnostics Entry after the current tool batch. Clean results and files without a
170
+ configured server stay silent in the transcript. This entry is excluded from model context; the
171
+ model sees diagnostics only in the original mutation result.
172
+
173
+ ## Limits and lifecycle
174
+
175
+ Every operation uses Pi's 2,000-line/50-KB output limit. Complete truncated text is saved as a
176
+ Result Spill and the result names its path. Each server's latest 1 MB of stderr is saved in the
177
+ session temp directory and failure messages name that path.
178
+
179
+ Documents must be valid UTF-8. Each server keeps at most 100 synchronized documents and closes
180
+ least-recently-used documents. Session shutdown requests a graceful LSP shutdown, then terminates
181
+ the process within the configured timeout.
182
+
183
+ ## Security
184
+
185
+ Trusted project settings can launch arbitrary local executables with all permissions of the Pi
186
+ process. Review settings and server binaries before trusting a project. Mutation manifests can be
187
+ blocked by another extension, but Pi LSP itself intentionally imposes no workspace path boundary.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@ian-pascoe/pi-lsp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Configured language-server tools and post-edit diagnostics for Pi",
6
+ "keywords": [
7
+ "lsp",
8
+ "pi",
9
+ "pi-extension",
10
+ "pi-package"
11
+ ],
12
+ "homepage": "https://github.com/ian-pascoe/pi-extensions#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ian-pascoe/pi-extensions/issues"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Ian Pascoe <ian.g.pascoe@gmail.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/ian-pascoe/pi-extensions.git",
21
+ "directory": "packages/pi-lsp"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "type": "module",
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "provenance": true
32
+ },
33
+ "scripts": {
34
+ "test": "vitest run --config ../../vitest.config.ts --root .",
35
+ "typecheck": "tsc --noEmit -p tsconfig.json"
36
+ },
37
+ "dependencies": {
38
+ "cross-spawn": "^7.0.6",
39
+ "vscode-languageserver-protocol": "^3.17.5"
40
+ },
41
+ "devDependencies": {
42
+ "@types/cross-spawn": "^6.0.6"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "@earendil-works/pi-tui": "*",
47
+ "typebox": "*"
48
+ },
49
+ "engines": {
50
+ "node": ">=22.19.0"
51
+ },
52
+ "pi": {
53
+ "extensions": [
54
+ "./src/index.ts"
55
+ ]
56
+ }
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./pi-lsp-extension.js";
@@ -0,0 +1,134 @@
1
+ /** A negotiated LSP character-unit encoding supported by the protocol. */
2
+ export type LspPositionEncoding = "utf-8" | "utf-16" | "utf-32";
3
+
4
+ /** A one-based source position whose character counts Unicode code points. */
5
+ export interface LspCodePointPosition {
6
+ /** One-based document line. */
7
+ readonly line: number;
8
+ /** One-based Unicode-code-point character within the line. */
9
+ readonly character: number;
10
+ }
11
+
12
+ /** A zero-based LSP protocol position whose character uses the negotiated encoding. */
13
+ export interface LspProtocolPosition {
14
+ /** Zero-based document line. */
15
+ readonly line: number;
16
+ /** Zero-based encoded character offset within the line. */
17
+ readonly character: number;
18
+ }
19
+
20
+ /** Normalize a server's negotiated position encoding, defaulting protocol omissions to UTF-16. */
21
+ export function normalizeLspPositionEncoding(encoding: string | undefined): LspPositionEncoding {
22
+ if (encoding === "utf-8") return "utf-8";
23
+ if (encoding === "utf-32") return "utf-32";
24
+ return "utf-16";
25
+ }
26
+
27
+ function documentLines(documentText: string): readonly string[] {
28
+ return documentText.split(/\r\n|[\n\r]/u);
29
+ }
30
+
31
+ function requirePositiveInteger(value: number, description: string): void {
32
+ if (!Number.isSafeInteger(value) || value < 1) {
33
+ throw new Error(`Pi LSP: ${description} must be a positive integer`);
34
+ }
35
+ }
36
+
37
+ function requireNonNegativeInteger(value: number, description: string): void {
38
+ if (!Number.isSafeInteger(value) || value < 0) {
39
+ throw new Error(`Pi LSP: ${description} must be a non-negative integer`);
40
+ }
41
+ }
42
+
43
+ function requireCodePointLine(
44
+ lines: readonly string[],
45
+ position: LspCodePointPosition,
46
+ ): readonly string[] {
47
+ requirePositiveInteger(position.line, "code-point position line");
48
+ requirePositiveInteger(position.character, "code-point position character");
49
+ const line = lines[position.line - 1];
50
+ if (line === undefined) {
51
+ throw new Error("Pi LSP: code-point position line exceeds document length");
52
+ }
53
+ return Array.from(line);
54
+ }
55
+
56
+ function requireProtocolLine(lines: readonly string[], position: LspProtocolPosition): string {
57
+ requireNonNegativeInteger(position.line, "protocol position line");
58
+ requireNonNegativeInteger(position.character, "protocol position character");
59
+ const line = lines[position.line];
60
+ if (line === undefined) {
61
+ throw new Error("Pi LSP: protocol position line exceeds document length");
62
+ }
63
+ return line;
64
+ }
65
+
66
+ function encodedCharacterLength(character: string, encoding: LspPositionEncoding): number {
67
+ switch (encoding) {
68
+ case "utf-8":
69
+ return Buffer.byteLength(character, "utf8");
70
+ case "utf-16":
71
+ return character.length;
72
+ case "utf-32":
73
+ return 1;
74
+ }
75
+ }
76
+
77
+ /** Return the negotiated LSP character length of one line of document text. */
78
+ export function measureLspPositionCharacters(text: string, encoding: LspPositionEncoding): number {
79
+ if (encoding === "utf-8") return Buffer.byteLength(text, "utf8");
80
+ if (encoding === "utf-32") return Array.from(text).length;
81
+ return text.length;
82
+ }
83
+
84
+ function protocolCharacterOffset(
85
+ characters: readonly string[],
86
+ codePointOffset: number,
87
+ encoding: LspPositionEncoding,
88
+ ): number {
89
+ let encodedOffset = 0;
90
+ for (const character of characters.slice(0, codePointOffset)) {
91
+ encodedOffset += encodedCharacterLength(character, encoding);
92
+ }
93
+ return encodedOffset;
94
+ }
95
+
96
+ /** Convert a one-based Unicode code-point position to a zero-based negotiated LSP position. */
97
+ export function convertLspCodePointPosition(
98
+ documentText: string,
99
+ position: LspCodePointPosition,
100
+ encoding: LspPositionEncoding,
101
+ ): LspProtocolPosition {
102
+ const characters = requireCodePointLine(documentLines(documentText), position);
103
+ const codePointOffset = position.character - 1;
104
+ if (codePointOffset > characters.length) {
105
+ throw new Error("Pi LSP: code-point position character exceeds line length");
106
+ }
107
+ return {
108
+ line: position.line - 1,
109
+ character: protocolCharacterOffset(characters, codePointOffset, encoding),
110
+ };
111
+ }
112
+
113
+ /** Convert a zero-based negotiated LSP position to one-based Unicode code-point coordinates. */
114
+ export function convertLspProtocolPosition(
115
+ documentText: string,
116
+ position: LspProtocolPosition,
117
+ encoding: LspPositionEncoding,
118
+ ): LspCodePointPosition {
119
+ const line = requireProtocolLine(documentLines(documentText), position);
120
+ const characters = Array.from(line);
121
+ let encodedOffset = 0;
122
+ for (let codePointOffset = 0; codePointOffset <= characters.length; codePointOffset++) {
123
+ if (encodedOffset === position.character) {
124
+ return { line: position.line + 1, character: codePointOffset + 1 };
125
+ }
126
+ const character = characters[codePointOffset];
127
+ if (character === undefined) break;
128
+ encodedOffset += encodedCharacterLength(character, encoding);
129
+ if (encodedOffset > position.character) {
130
+ throw new Error("Pi LSP: protocol position splits a Unicode character");
131
+ }
132
+ }
133
+ throw new Error("Pi LSP: protocol position character exceeds line length");
134
+ }
@@ -0,0 +1,249 @@
1
+ import { relative } from "node:path";
2
+ import { keyText, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import { Box, Container, Spacer, Text, type Component } from "@earendil-works/pi-tui";
4
+ import { type Static, Type } from "typebox";
5
+ import { Value } from "typebox/value";
6
+ import {
7
+ PostEditDiagnosticOutcomeSchema,
8
+ type PostEditDiagnosticOutcome,
9
+ } from "./lsp-post-edit-diagnostics.js";
10
+
11
+ /** Custom session entry type used for model-invisible Post-edit Diagnostics presentation. */
12
+ export const POST_EDIT_DIAGNOSTICS_ENTRY_TYPE = "pi-lsp-post-edit-diagnostics";
13
+
14
+ /** Persisted data for one model-invisible Post-edit Diagnostics Entry. */
15
+ export const PostEditDiagnosticsEntryDataSchema = Type.Object(
16
+ {
17
+ cwd: Type.String({ minLength: 1 }),
18
+ outcomes: Type.Array(PostEditDiagnosticOutcomeSchema),
19
+ },
20
+ { additionalProperties: false },
21
+ );
22
+
23
+ /** Validated data rendered by one model-invisible Post-edit Diagnostics Entry. */
24
+ export type PostEditDiagnosticsEntryData = Static<typeof PostEditDiagnosticsEntryDataSchema>;
25
+
26
+ /** Theme operations used by Post-edit Diagnostics Entry rendering. */
27
+ export type PostEditDiagnosticsEntryTheme = Pick<Theme, "bg" | "bold" | "fg">;
28
+
29
+ type ReportablePostEditDiagnosticOutcome = Exclude<
30
+ PostEditDiagnosticOutcome,
31
+ { kind: "no_diagnostics" | "no_configured_server" }
32
+ >;
33
+
34
+ type DiagnosticSeverity = "error" | "warning" | "information" | "hint" | "diagnostic";
35
+
36
+ function isReportablePostEditDiagnosticOutcome(
37
+ outcome: PostEditDiagnosticOutcome,
38
+ ): outcome is ReportablePostEditDiagnosticOutcome {
39
+ return outcome.kind !== "no_diagnostics" && outcome.kind !== "no_configured_server";
40
+ }
41
+
42
+ function diagnosticSeverity(severity: number): DiagnosticSeverity {
43
+ switch (severity) {
44
+ case 1:
45
+ return "error";
46
+ case 2:
47
+ return "warning";
48
+ case 3:
49
+ return "information";
50
+ case 4:
51
+ return "hint";
52
+ default:
53
+ return "diagnostic";
54
+ }
55
+ }
56
+
57
+ function severityColor(severity: DiagnosticSeverity): ThemeColor {
58
+ switch (severity) {
59
+ case "error":
60
+ return "error";
61
+ case "warning":
62
+ return "warning";
63
+ case "information":
64
+ return "accent";
65
+ case "hint":
66
+ case "diagnostic":
67
+ return "muted";
68
+ }
69
+ }
70
+
71
+ function pluralizedCount(count: number, singular: string, plural = `${singular}s`): string {
72
+ return `${count} ${count === 1 ? singular : plural}`;
73
+ }
74
+
75
+ function entrySummary(data: PostEditDiagnosticsEntryData, theme: PostEditDiagnosticsEntryTheme) {
76
+ const counts = new Map<DiagnosticSeverity, number>();
77
+ const files = new Set<string>();
78
+ let adapterWarnings = 0;
79
+ let timeouts = 0;
80
+ let serverIssues = 0;
81
+ for (const outcome of data.outcomes) {
82
+ switch (outcome.kind) {
83
+ case "diagnostic": {
84
+ const severity = diagnosticSeverity(outcome.diagnostic.severity);
85
+ counts.set(severity, (counts.get(severity) ?? 0) + 1);
86
+ files.add(outcome.diagnostic.path);
87
+ break;
88
+ }
89
+ case "timeout":
90
+ timeouts++;
91
+ files.add(outcome.path);
92
+ break;
93
+ case "unavailable_server":
94
+ serverIssues++;
95
+ files.add(outcome.path);
96
+ break;
97
+ case "warning":
98
+ adapterWarnings++;
99
+ break;
100
+ case "no_diagnostics":
101
+ case "no_configured_server":
102
+ break;
103
+ }
104
+ }
105
+
106
+ const warnings = (counts.get("warning") ?? 0) + adapterWarnings;
107
+ const metrics = [
108
+ [counts.get("error") ?? 0, "error", "errors", "error"],
109
+ [warnings, "warning", "warnings", "warning"],
110
+ [counts.get("information") ?? 0, "info", "info", "accent"],
111
+ [counts.get("hint") ?? 0, "hint", "hints", "muted"],
112
+ [counts.get("diagnostic") ?? 0, "diagnostic", "diagnostics", "muted"],
113
+ [timeouts, "timeout", "timeouts", "warning"],
114
+ [serverIssues, "server issue", "server issues", "warning"],
115
+ ] as const;
116
+ return [
117
+ theme.fg("toolTitle", theme.bold("Post-edit diagnostics")),
118
+ ...metrics
119
+ .filter(([count]) => count > 0)
120
+ .map(([count, singular, plural, color]) =>
121
+ theme.fg(color, pluralizedCount(count, singular, plural)),
122
+ ),
123
+ theme.fg("muted", pluralizedCount(files.size, "file")),
124
+ ].join(theme.fg("dim", " · "));
125
+ }
126
+
127
+ function displayPath(cwd: string, path: string): string {
128
+ const relativePath = relative(cwd, path);
129
+ return relativePath !== "" && !relativePath.startsWith("..") ? relativePath : path;
130
+ }
131
+
132
+ function outcomePath(outcome: ReportablePostEditDiagnosticOutcome): string | undefined {
133
+ return outcome.kind === "diagnostic"
134
+ ? outcome.diagnostic.path
135
+ : outcome.kind === "warning"
136
+ ? undefined
137
+ : outcome.path;
138
+ }
139
+
140
+ function compareReportableOutcomes(
141
+ left: ReportablePostEditDiagnosticOutcome,
142
+ right: ReportablePostEditDiagnosticOutcome,
143
+ ): number {
144
+ const leftPath = outcomePath(left) ?? "";
145
+ const rightPath = outcomePath(right) ?? "";
146
+ if (leftPath !== rightPath) return leftPath.localeCompare(rightPath);
147
+ if (left.kind === "diagnostic" && right.kind === "diagnostic") {
148
+ return (
149
+ left.diagnostic.severity - right.diagnostic.severity ||
150
+ left.diagnostic.line - right.diagnostic.line ||
151
+ left.diagnostic.character - right.diagnostic.character ||
152
+ left.diagnostic.serverId.localeCompare(right.diagnostic.serverId)
153
+ );
154
+ }
155
+ if (left.kind === "diagnostic") return -1;
156
+ if (right.kind === "diagnostic") return 1;
157
+ return left.kind.localeCompare(right.kind);
158
+ }
159
+
160
+ function diagnosticLine(
161
+ outcome: Extract<ReportablePostEditDiagnosticOutcome, { kind: "diagnostic" }>,
162
+ theme: PostEditDiagnosticsEntryTheme,
163
+ ): string {
164
+ const diagnostic = outcome.diagnostic;
165
+ const severity = diagnosticSeverity(diagnostic.severity);
166
+ return `${theme.fg(severityColor(severity), `${diagnostic.line}:${diagnostic.character}`)} ${diagnostic.message} ${theme.fg("muted", diagnostic.serverId)}`;
167
+ }
168
+
169
+ function unavailableLine(
170
+ outcome: Extract<ReportablePostEditDiagnosticOutcome, { kind: "timeout" | "unavailable_server" }>,
171
+ theme: PostEditDiagnosticsEntryTheme,
172
+ ): string {
173
+ const label = outcome.kind === "timeout" ? "Diagnostics timed out" : "Server unavailable";
174
+ return `${theme.fg("warning", label)}${outcome.serverId === undefined ? "" : ` ${theme.fg("muted", outcome.serverId)}`}`;
175
+ }
176
+
177
+ function appendExpandedOutcomes(
178
+ container: Container,
179
+ data: PostEditDiagnosticsEntryData,
180
+ theme: PostEditDiagnosticsEntryTheme,
181
+ ): void {
182
+ const reportable = data.outcomes
183
+ .filter(isReportablePostEditDiagnosticOutcome)
184
+ .sort(compareReportableOutcomes);
185
+ const outcomesByPath = new Map<string, ReportablePostEditDiagnosticOutcome[]>();
186
+ const warnings: Extract<ReportablePostEditDiagnosticOutcome, { kind: "warning" }>[] = [];
187
+ for (const outcome of reportable) {
188
+ if (outcome.kind === "warning") {
189
+ warnings.push(outcome);
190
+ continue;
191
+ }
192
+ const path = outcomePath(outcome);
193
+ if (path === undefined) continue;
194
+ const entries = outcomesByPath.get(path) ?? [];
195
+ entries.push(outcome);
196
+ outcomesByPath.set(path, entries);
197
+ }
198
+
199
+ for (const [path, outcomes] of outcomesByPath) {
200
+ container.addChild(new Spacer(1));
201
+ container.addChild(new Text(theme.fg("accent", theme.bold(displayPath(data.cwd, path))), 0, 0));
202
+ for (const outcome of outcomes) {
203
+ if (outcome.kind === "warning") continue;
204
+ container.addChild(
205
+ new Text(
206
+ outcome.kind === "diagnostic"
207
+ ? diagnosticLine(outcome, theme)
208
+ : unavailableLine(outcome, theme),
209
+ 0,
210
+ 0,
211
+ ),
212
+ );
213
+ }
214
+ }
215
+ if (warnings.length > 0) {
216
+ container.addChild(new Spacer(1));
217
+ container.addChild(new Text(theme.fg("warning", theme.bold("Warnings")), 0, 0));
218
+ for (const warning of warnings) container.addChild(new Text(warning.message, 0, 0));
219
+ }
220
+ }
221
+
222
+ /** Create a Post-edit Diagnostics Entry only when outcomes deserve transcript attention. */
223
+ export function createPostEditDiagnosticsEntryData(
224
+ cwd: string,
225
+ outcomes: readonly PostEditDiagnosticOutcome[],
226
+ ): PostEditDiagnosticsEntryData | undefined {
227
+ const reportable = outcomes.filter(isReportablePostEditDiagnosticOutcome);
228
+ return reportable.length === 0
229
+ ? undefined
230
+ : Value.Parse(PostEditDiagnosticsEntryDataSchema, { cwd, outcomes: reportable });
231
+ }
232
+
233
+ /** Render one model-invisible Post-edit Diagnostics Entry with native Pi expansion and theming. */
234
+ export function renderPostEditDiagnosticsEntry(
235
+ data: PostEditDiagnosticsEntryData,
236
+ expanded: boolean,
237
+ theme: PostEditDiagnosticsEntryTheme,
238
+ ): Component {
239
+ const container = new Container();
240
+ const hint = expanded
241
+ ? ""
242
+ : `${theme.fg("dim", ` · ${keyText("app.tools.expand")}`)}${theme.fg("muted", " to expand")}`;
243
+ container.addChild(new Text(`${entrySummary(data, theme)}${hint}`, 0, 0));
244
+ if (expanded) appendExpandedOutcomes(container, data, theme);
245
+
246
+ const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
247
+ box.addChild(container);
248
+ return box;
249
+ }