@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 +21 -0
- package/README.md +187 -0
- package/package.json +57 -0
- package/src/index.ts +1 -0
- package/src/lsp-position-encoding.ts +134 -0
- package/src/lsp-post-edit-diagnostics-rendering.ts +249 -0
- package/src/lsp-post-edit-diagnostics.ts +291 -0
- package/src/lsp-server-client.ts +1237 -0
- package/src/lsp-server-manager.ts +519 -0
- package/src/lsp-session-files.ts +107 -0
- package/src/lsp-tool-contract.ts +468 -0
- package/src/lsp-tool-output.ts +64 -0
- package/src/lsp-tool-rendering.ts +312 -0
- package/src/lsp-tool.ts +1214 -0
- package/src/lsp-workspace-edit.ts +872 -0
- package/src/pi-lsp-extension.ts +379 -0
- package/src/pi-lsp-settings.ts +263 -0
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
|
+
}
|