@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.
@@ -0,0 +1,312 @@
1
+ import { isAbsolute, relative } from "node:path";
2
+ import {
3
+ keyText,
4
+ type AgentToolResult,
5
+ type Theme,
6
+ type ThemeColor,
7
+ type ToolRenderResultOptions,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { Container, Spacer, Text, type Component } from "@earendil-works/pi-tui";
10
+ import { Type } from "typebox";
11
+ import { Value } from "typebox/value";
12
+ import type { LSPAny } from "vscode-languageserver-protocol";
13
+ import {
14
+ LspToolResultDetailsSchema,
15
+ type LspToolParameters,
16
+ type LspToolResultDetails,
17
+ type ServerOperationOutcome,
18
+ } from "./lsp-tool-contract.js";
19
+
20
+ /** Theme operations used by Pi LSP tool transcript rendering. */
21
+ export type LspRenderTheme = Pick<Theme, "bold" | "fg">;
22
+
23
+ const LspRenderRecordSchema = Type.Record(Type.String(), Type.Any());
24
+
25
+ function humanizeLspOperation(operation: LspToolParameters["operation"]): string {
26
+ const words = operation.replaceAll("_", " ");
27
+ return `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
28
+ }
29
+
30
+ function workspaceRelativeLspPath(cwd: string, filePath: string): string {
31
+ const normalizedPath = filePath.startsWith("@") ? filePath.slice(1) : filePath;
32
+ if (!isAbsolute(normalizedPath)) return normalizedPath;
33
+ const relativePath = relative(cwd, normalizedPath);
34
+ return relativePath !== "" && !relativePath.startsWith("..") ? relativePath : normalizedPath;
35
+ }
36
+
37
+ function lspCallTarget(parameters: LspToolParameters, cwd: string): string | undefined {
38
+ if ("file_path" in parameters) {
39
+ const filePath = workspaceRelativeLspPath(cwd, parameters.file_path);
40
+ if ("line" in parameters && "character" in parameters) {
41
+ return `${filePath}:${parameters.line}:${parameters.character}`;
42
+ }
43
+ return filePath;
44
+ }
45
+ if (parameters.operation === "apply") return parameters.preview_id;
46
+ return undefined;
47
+ }
48
+
49
+ function expansionHint(theme: LspRenderTheme): string {
50
+ return `${theme.fg("dim", ` · ${keyText("app.tools.expand")}`)}${theme.fg("muted", " to expand")}`;
51
+ }
52
+
53
+ function toolResultText(result: AgentToolResult<unknown>): string {
54
+ return result.content
55
+ .filter((item) => item.type === "text")
56
+ .map((item) => item.text)
57
+ .join("");
58
+ }
59
+
60
+ function fileCountLabel(count: number): string {
61
+ return `${count} file${count === 1 ? "" : "s"}`;
62
+ }
63
+
64
+ function parsedLspOutput(output: string): LSPAny {
65
+ try {
66
+ return JSON.parse(output);
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ function renderRecord(value: LSPAny): Record<string, LSPAny> | undefined {
73
+ return Value.Check(LspRenderRecordSchema, value) ? value : undefined;
74
+ }
75
+
76
+ function semanticLspValueCount(value: LSPAny): number {
77
+ if (value === null || value === undefined) return 0;
78
+ if (Array.isArray(value)) return value.length;
79
+ const record = renderRecord(value);
80
+ if (record === undefined) return 1;
81
+ if (Array.isArray(record.diagnostics)) return record.diagnostics.length;
82
+ if (Array.isArray(record.items)) return record.items.length;
83
+ if (Array.isArray(record.diagnosticsByUri)) {
84
+ return record.diagnosticsByUri.reduce((count: number, entry: LSPAny) => {
85
+ if (!Array.isArray(entry)) return count;
86
+ return count + semanticLspValueCount(entry[1]);
87
+ }, 0);
88
+ }
89
+ return 1;
90
+ }
91
+
92
+ function semanticLspOperationNoun(operation: LspToolParameters["operation"]): string {
93
+ switch (operation) {
94
+ case "status":
95
+ return "server";
96
+ case "diagnostics":
97
+ case "workspace_diagnostics":
98
+ return "diagnostic";
99
+ case "completion":
100
+ return "completion";
101
+ case "declaration":
102
+ case "goto_definition":
103
+ case "goto_type_definition":
104
+ case "goto_implementation":
105
+ return "location";
106
+ case "find_references":
107
+ return "reference";
108
+ case "document_symbols":
109
+ case "workspace_symbols":
110
+ return "symbol";
111
+ case "document_links":
112
+ return "link";
113
+ case "code_actions":
114
+ return "action";
115
+ default:
116
+ return "result";
117
+ }
118
+ }
119
+
120
+ function semanticLspOperationMetric(
121
+ operation: LspToolParameters["operation"],
122
+ output: string,
123
+ ): string | undefined {
124
+ const parsed = parsedLspOutput(output);
125
+ const record = renderRecord(parsed);
126
+ let count: number | undefined;
127
+ if (operation === "status" && Array.isArray(record?.servers)) {
128
+ count = record.servers.length;
129
+ } else if (operation === "code_actions" && Array.isArray(parsed)) {
130
+ count = parsed.length;
131
+ } else if (Array.isArray(record?.results)) {
132
+ count = record.results.reduce((total: number, result: LSPAny) => {
133
+ const resultRecord = renderRecord(result);
134
+ return total + semanticLspValueCount(resultRecord?.value);
135
+ }, 0);
136
+ }
137
+ if (count === undefined) return undefined;
138
+ const noun = semanticLspOperationNoun(operation);
139
+ return pluralizedCount(count, noun);
140
+ }
141
+
142
+ function pluralizedCount(count: number, noun: string): string {
143
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
144
+ }
145
+
146
+ function outcomeColor(outcome: ServerOperationOutcome["outcome"]): ThemeColor {
147
+ switch (outcome) {
148
+ case "success":
149
+ return "success";
150
+ case "timeout":
151
+ case "unavailable":
152
+ case "unsupported":
153
+ return "warning";
154
+ case "error":
155
+ return "error";
156
+ }
157
+ }
158
+
159
+ function renderOperationSummary(
160
+ details: Extract<LspToolResultDetails, { kind: "operation" }>,
161
+ theme: LspRenderTheme,
162
+ output: string,
163
+ ): string {
164
+ const failures = details.server_outcomes.filter(({ outcome }) => outcome !== "success");
165
+ const metric = semanticLspOperationMetric(details.operation, output);
166
+ if (failures.length === 0) {
167
+ const servers = details.server_outcomes.map(({ server_id }) => server_id).join(", ");
168
+ return [
169
+ theme.fg("success", "Completed"),
170
+ metric === undefined ? undefined : theme.fg("toolOutput", metric),
171
+ servers && theme.fg("muted", servers),
172
+ ]
173
+ .filter(Boolean)
174
+ .join(theme.fg("dim", " · "));
175
+ }
176
+ const succeeded = details.server_outcomes.length - failures.length;
177
+ const summary = succeeded === 0 ? "Failed" : "Completed with issues";
178
+ return [
179
+ theme.fg(succeeded === 0 ? "error" : "warning", summary),
180
+ metric === undefined ? undefined : theme.fg("toolOutput", metric),
181
+ theme.fg("warning", pluralizedCount(failures.length, "server issue")),
182
+ ]
183
+ .filter(Boolean)
184
+ .join(theme.fg("dim", " · "));
185
+ }
186
+
187
+ function renderCollapsedLspResult(
188
+ details: LspToolResultDetails,
189
+ theme: LspRenderTheme,
190
+ output: string,
191
+ ): string {
192
+ switch (details.kind) {
193
+ case "operation":
194
+ return renderOperationSummary(details, theme, output);
195
+ case "workspace_edit_preview":
196
+ return `${theme.fg("accent", "Preview ready")}${theme.fg("dim", ` · ${fileCountLabel(details.mutation_manifest.length)}`)}`;
197
+ case "workspace_edit_apply": {
198
+ const label = details.state === "applied" ? "Applied" : "Partial failure";
199
+ const color = details.state === "applied" ? "success" : "error";
200
+ return `${theme.fg(color, label)}${theme.fg("dim", ` · ${fileCountLabel(details.changed_paths.length)}`)}`;
201
+ }
202
+ }
203
+ }
204
+
205
+ function appendExpandedOperationDetails(
206
+ container: Container,
207
+ details: Extract<LspToolResultDetails, { kind: "operation" }>,
208
+ theme: LspRenderTheme,
209
+ ): void {
210
+ container.addChild(new Text(theme.fg("muted", theme.bold("Server outcomes")), 0, 0));
211
+ for (const outcome of details.server_outcomes) {
212
+ const message = outcome.message === undefined ? "" : theme.fg("muted", ` — ${outcome.message}`);
213
+ container.addChild(
214
+ new Text(
215
+ `${theme.fg(outcomeColor(outcome.outcome), outcome.outcome)} ${outcome.server_id}${message}`,
216
+ 0,
217
+ 0,
218
+ ),
219
+ );
220
+ }
221
+ if (details.spill_path !== undefined) {
222
+ container.addChild(
223
+ new Text(`${theme.fg("muted", "Result Spill:")} ${details.spill_path}`, 0, 0),
224
+ );
225
+ }
226
+ }
227
+
228
+ function appendExpandedMutationDetails(
229
+ container: Container,
230
+ details: Exclude<LspToolResultDetails, { kind: "operation" }>,
231
+ theme: LspRenderTheme,
232
+ ): void {
233
+ if (details.kind === "workspace_edit_preview") {
234
+ container.addChild(new Text(details.summary, 0, 0));
235
+ }
236
+ container.addChild(new Text(`${theme.fg("muted", "Preview:")} ${details.preview_id}`, 0, 0));
237
+ const paths =
238
+ details.kind === "workspace_edit_preview"
239
+ ? details.mutation_manifest.flatMap((entry) =>
240
+ entry.operation === "rename" ? [entry.path, entry.destination_path] : [entry.path],
241
+ )
242
+ : details.changed_paths;
243
+ for (const path of paths) container.addChild(new Text(theme.fg("muted", path), 0, 0));
244
+ }
245
+
246
+ /** Render one Pi LSP tool call using Pi's supplied theme and native expansion state. */
247
+ export function renderLspToolCall(
248
+ parameters: LspToolParameters,
249
+ theme: LspRenderTheme,
250
+ expanded: boolean,
251
+ cwd: string,
252
+ ): Component {
253
+ const container = new Container();
254
+ const target = lspCallTarget(parameters, cwd);
255
+ container.addChild(
256
+ new Text(
257
+ [
258
+ theme.fg("toolTitle", theme.bold("LSP")),
259
+ theme.fg("accent", humanizeLspOperation(parameters.operation)),
260
+ target === undefined ? undefined : theme.fg("muted", target),
261
+ ]
262
+ .filter((part) => part !== undefined)
263
+ .join(" "),
264
+ 0,
265
+ 0,
266
+ ),
267
+ );
268
+ if (expanded) {
269
+ container.addChild(new Spacer(1));
270
+ container.addChild(new Text(theme.fg("dim", JSON.stringify(parameters, undefined, 2)), 0, 0));
271
+ }
272
+ return container;
273
+ }
274
+
275
+ /** Render one Pi LSP tool result as a compact summary with exact output on expansion. */
276
+ export function renderLspToolResult(
277
+ result: AgentToolResult<LspToolResultDetails | undefined>,
278
+ options: ToolRenderResultOptions,
279
+ theme: LspRenderTheme,
280
+ isError: boolean,
281
+ ): Component {
282
+ const output = toolResultText(result);
283
+ if (options.isPartial) return new Text(theme.fg("accent", "Running…"), 0, 0);
284
+ if (isError || !Value.Check(LspToolResultDetailsSchema, result.details)) {
285
+ const visibleOutput = options.expanded
286
+ ? output
287
+ : (output.split("\n").find(Boolean) ?? "LSP failed");
288
+ const hint = !options.expanded && output.includes("\n") ? expansionHint(theme) : "";
289
+ return new Text(theme.fg(isError ? "error" : "toolOutput", `${visibleOutput}${hint}`), 0, 0);
290
+ }
291
+
292
+ if (!options.expanded) {
293
+ return new Text(
294
+ `${renderCollapsedLspResult(result.details, theme, output)}${expansionHint(theme)}`,
295
+ 0,
296
+ 0,
297
+ );
298
+ }
299
+
300
+ const container = new Container();
301
+ container.addChild(new Text(renderCollapsedLspResult(result.details, theme, output), 0, 0));
302
+ container.addChild(new Spacer(1));
303
+ if (result.details.kind === "operation") {
304
+ appendExpandedOperationDetails(container, result.details, theme);
305
+ } else {
306
+ appendExpandedMutationDetails(container, result.details, theme);
307
+ }
308
+ container.addChild(new Spacer(1));
309
+ container.addChild(new Text(theme.fg("muted", theme.bold("Output")), 0, 0));
310
+ container.addChild(new Text(theme.fg("toolOutput", output || "(no output)"), 0, 0));
311
+ return container;
312
+ }