decorated-pi 0.2.2 → 0.4.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.
@@ -1,538 +1,203 @@
1
1
  /**
2
- * LSP Tool Definitions — diagnostics, hover, definition, references, symbols, rename
3
- *
4
- * Based on @spences10/pi-lsp by Scott Spence
5
- * https://github.com/spences10/my-pi/tree/main/packages/pi-lsp (MIT License)
6
- *
7
- * Modifications: added lsp_find_symbol, lsp_rename, multi-file lsp_diagnostics
2
+ * LSP Tool Definitions — 2 tools for Pi.
8
3
  */
9
- import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { Text } from "@earendil-works/pi-tui";
10
6
  import { Type } from "typebox";
11
- import { list_supported_languages } from "./servers.js";
12
- import {
13
- filter_diagnostics,
14
- find_symbol_matches,
15
- format_diagnostics,
16
- format_document_symbols,
17
- format_hover,
18
- format_locations,
19
- format_symbol_matches,
20
- format_tool_error,
21
- SYMBOL_KIND_NAMES,
22
- to_lsp_tool_error,
23
- type SeverityFilter,
24
- } from "./format.js";
25
- import type { LspServerManager } from "./server-manager.js";
7
+ import { LspServerManager, LspToolError, formatToolError } from "./manager.js";
26
8
 
27
- const SYMBOL_KIND_SCHEMA = Type.Union(
28
- SYMBOL_KIND_NAMES.map((name) => Type.Literal(name))
29
- );
9
+ // ─── TUI rendering ─────────────────────────────────────────────────────────
30
10
 
31
- const DIAGNOSTICS_MANY_CONCURRENCY = 8;
11
+ const LSP_RESULT_FOLD_LINES = 45;
32
12
 
33
- function make_tool_result(
34
- text: string,
35
- details: Record<string, unknown> = {}
36
- ) {
37
- return {
38
- content: [{ type: "text" as const, text }],
39
- details,
40
- };
13
+ function trimTrailingEmptyLines(lines: string[]): string[] {
14
+ let end = lines.length;
15
+ while (end > 0 && lines[end - 1] === "") end -= 1;
16
+ return lines.slice(0, end);
41
17
  }
42
18
 
43
- function make_tool_error(details: any) {
44
- return make_tool_result(format_tool_error(details), {
45
- ok: false,
46
- error: details,
47
- });
19
+ function collapseText(text: string, maxLines = LSP_RESULT_FOLD_LINES) {
20
+ const lines = trimTrailingEmptyLines(text.split("\n"));
21
+ const totalLines = lines.length;
22
+ const displayLines = lines.slice(0, maxLines);
23
+ const remainingLines = Math.max(0, totalLines - maxLines);
24
+ return { totalLines, displayLines, remainingLines };
25
+ }
26
+
27
+ function getTextContent(result: { content?: Array<{ type: string; text?: string }> }): string {
28
+ return (result.content ?? []).filter((c): c is { type: "text"; text?: string } => c.type === "text").map((c) => c.text ?? "").join("\n");
29
+ }
30
+
31
+ function formatResultText(text: string, expanded: boolean, theme: any): string {
32
+ const { totalLines, displayLines, remainingLines } = collapseText(text, expanded ? Number.MAX_SAFE_INTEGER : LSP_RESULT_FOLD_LINES);
33
+ let rendered = displayLines.join("\n") ? theme.fg("toolOutput", displayLines.join("\n")) : "";
34
+ if (!expanded && remainingLines > 0) rendered += `${theme.fg("muted", `\n... (${remainingLines} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`;
35
+ return rendered;
36
+ }
37
+
38
+ function renderLspResult(result: any, options: { expanded: boolean }, theme: any, context: any) {
39
+ const component = context.lastComponent ?? new Text("", 0, 0);
40
+ component.setText(formatResultText(getTextContent(result), options.expanded, theme));
41
+ return component;
48
42
  }
49
43
 
50
- async function map_with_concurrency<T, R>(
51
- items: T[],
52
- concurrency: number,
53
- mapper: (item: T, index: number) => Promise<R>
54
- ): Promise<R[]> {
55
- const results: R[] = [];
56
- let next_index = 0;
57
- const worker_count = Math.min(concurrency, items.length);
58
- await Promise.all(
59
- Array.from({ length: worker_count }, async () => {
60
- while (true) {
61
- const index = next_index;
62
- next_index += 1;
63
- if (index >= items.length) return;
64
- results[index] = await mapper(items[index]!, index);
65
- }
66
- })
67
- );
68
- return results;
44
+ // ─── Helpers ───────────────────────────────────────────────────────────────
45
+
46
+ type ToolResult = { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> };
47
+
48
+ function ok(text: string, details: Record<string, unknown> = {}): ToolResult {
49
+ return { content: [{ type: "text" as const, text }], details };
50
+ }
51
+
52
+ function err(details: any): ToolResult {
53
+ return ok(formatToolError(details), { ok: false, error: details });
69
54
  }
70
55
 
71
- async function with_file_state(
56
+ async function withFile(
72
57
  manager: LspServerManager,
73
58
  file: string,
74
- ctx: any,
75
- run: (result: { abs: string; uri: string; state: any }) => Promise<string>
76
- ) {
77
- const resolved = await manager.resolve_file_state(file, ctx);
78
- if (!resolved.ok) {
79
- return make_tool_error(resolved.error);
80
- }
59
+ fn: (s: { abs: string; uri: string; state: any }) => Promise<string>,
60
+ options: { timeoutMs?: number } = {},
61
+ ): Promise<ToolResult> {
62
+ const resolved = await manager.resolveFileState(file, { timeoutMs: options.timeoutMs });
63
+ if (!resolved.ok) return err(resolved.error);
81
64
  const { result } = resolved;
82
65
  try {
83
- const text = await run(result);
84
- return make_tool_result(text, {
85
- ok: true,
86
- language: result.state.language,
87
- command: result.state.command,
88
- workspace_root: result.state.workspace_root,
89
- });
66
+ const text = await fn(result);
67
+ return ok(text, { ok: true, language: result.state.language, workspace_root: result.state.workspaceRoot });
90
68
  } catch (error) {
91
- return make_tool_error(
92
- to_lsp_tool_error(
93
- result.abs,
94
- result.state.language,
95
- result.state.workspace_root,
96
- result.state.command,
97
- result.state.install_hint,
98
- error
99
- )
100
- );
69
+ if (error instanceof LspToolError) return err(error.details);
70
+ return err({ kind: "tool_execution_failed", file: result.abs, language: result.state.language, workspace_root: result.state.workspaceRoot, command: result.state.command, install_hint: result.state.installHint, message: error instanceof Error ? error.message : String(error) });
101
71
  }
102
72
  }
103
73
 
104
- export function register_lsp_tools(pi: ExtensionAPI, manager: LspServerManager) {
105
- pi.registerTool(
106
- defineTool({
107
- name: "lsp_diagnostics",
108
- label: "LSP: diagnostics",
109
- description:
110
- "Get language server diagnostics for one or more files. Default filter: error. Supports optional severity filtering.",
111
- promptSnippet: "Get language server diagnostics for one or more files",
112
- promptGuidelines: [
113
- "Use lsp_diagnostics to validate focused code changes after editing or writing before reporting completion.",
114
- ],
115
- parameters: Type.Object({
116
- files: Type.Array(Type.String(), {
117
- minItems: 1,
118
- maxItems: 100,
119
- description: "Files to check. Single file or list (relative to cwd or absolute).",
120
- }),
121
- severity: Type.Optional(
122
- Type.Array(
123
- Type.Union([
124
- Type.Literal("error"),
125
- Type.Literal("warning"),
126
- Type.Literal("info"),
127
- Type.Literal("hint"),
128
- ]),
129
- {
130
- description:
131
- "Filter to specific severity levels. Default: error. Values: error, warning, info, hint. Picking a level shows it and all more severe levels (e.g. warning → error + warning).",
132
- }
133
- )
134
- ),
135
- wait_ms: Type.Optional(
136
- Type.Number({
137
- description:
138
- "Max ms to wait for diagnostics after opening each file. Default 1500.",
139
- })
140
- ),
141
- }),
142
- execute: async (_id, params, _signal, _on_update, ctx) => {
143
- const wait_ms = params.wait_ms ?? 1500;
144
- const severities: SeverityFilter[] = params.severity ?? ["error"];
145
-
146
- const lines_with_stats = await map_with_concurrency(
147
- params.files,
148
- DIAGNOSTICS_MANY_CONCURRENCY,
149
- async (file) => {
150
- const resolved = await manager.resolve_file_state(file, ctx);
151
- if (!resolved.ok) {
152
- return {
153
- line: format_tool_error(resolved.error),
154
- diagnostics: 0,
155
- error: true,
156
- };
157
- }
158
- try {
159
- const diagnostics =
160
- await resolved.result.state.client.wait_for_diagnostics(
161
- resolved.result.uri,
162
- wait_ms
163
- );
164
- const filtered = filter_diagnostics(diagnostics, severities);
165
- let errors = 0, warnings = 0, infos = 0;
166
- for (const d of filtered) {
167
- if (d.severity === 1) errors++;
168
- else if (d.severity === 2) warnings++;
169
- else infos++;
170
- }
171
- return {
172
- line: format_diagnostics(resolved.result.abs, diagnostics, severities),
173
- diagnostics: filtered.length,
174
- errors,
175
- warnings,
176
- error: false,
177
- };
178
- } catch (error) {
179
- return {
180
- line: format_tool_error(
181
- to_lsp_tool_error(
182
- resolved.result.abs,
183
- resolved.result.state.language,
184
- resolved.result.state.workspace_root,
185
- resolved.result.state.command,
186
- resolved.result.state.install_hint,
187
- error
188
- )
189
- ),
190
- diagnostics: 0,
191
- error: true,
192
- };
193
- }
194
- }
195
- );
196
-
197
- let total_diag = 0;
198
- let total_err = 0;
199
- let total_warn = 0;
200
- let clean_count = 0;
201
- let fail_count = 0;
202
- const lines: string[] = [];
203
- for (const entry of lines_with_stats) {
204
- lines.push(entry.line);
205
- if (entry.error) {
206
- fail_count += 1;
207
- } else {
208
- total_diag += entry.diagnostics;
209
- total_err += (entry as any).errors ?? 0;
210
- total_warn += (entry as any).warnings ?? 0;
211
- if (entry.diagnostics === 0) clean_count += 1;
212
- }
213
- }
214
- const summary = total_err > 0 || total_warn > 0
215
- ? `Checked ${params.files.length} file(s): ${total_err} error(s), ${total_warn} warning(s), ${total_diag - total_err - total_warn} info/hint(s), ${clean_count} clean, ${fail_count} failed to check`
216
- : `Checked ${params.files.length} file(s): ${total_diag} diagnostic(s), ${clean_count} clean, ${fail_count} failed to check`;
217
- return make_tool_result(
218
- [summary, ...lines].join("\n\n"),
219
- {
220
- ok: fail_count === 0 && total_err === 0,
221
- checked: params.files.length,
222
- diagnostic_count: total_diag,
223
- error_count: total_err,
224
- warning_count: total_warn,
225
- clean_count,
226
- fail_count,
227
- }
228
- );
229
- },
230
- })
231
- );
232
-
233
- pi.registerTool(
234
- defineTool({
235
- name: "lsp_find_symbol",
236
- label: "LSP: find symbol",
237
- description:
238
- "Find symbols in a file by name or detail text using document symbols. Supports exact matching, kind filters, and top-level-only mode.",
239
- promptSnippet: "Find symbols in a file by name, kind, or match mode",
240
- promptGuidelines: [
241
- "Use lsp_find_symbol to locate named symbols in a file when symbol structure matters more than broad text search.",
242
- ],
243
- parameters: Type.Object({
244
- file: Type.String({
245
- description: "Path to the file whose symbols should be searched.",
246
- }),
247
- query: Type.String({
248
- description: "Substring to match against symbol names/details.",
249
- }),
250
- max_results: Type.Optional(
251
- Type.Number({
252
- description: "Max number of matches to return. Default 20.",
253
- })
254
- ),
255
- top_level_only: Type.Optional(
256
- Type.Boolean({
257
- description: "Only match top-level symbols. Default false.",
258
- })
259
- ),
260
- exact_match: Type.Optional(
261
- Type.Boolean({
262
- description:
263
- "Match whole symbol names/details exactly instead of substring matching. Default false.",
264
- })
265
- ),
266
- kinds: Type.Optional(
267
- Type.Array(SYMBOL_KIND_SCHEMA, {
268
- minItems: 1,
269
- maxItems: SYMBOL_KIND_NAMES.length,
270
- description: "Restrict matches to these symbol kinds.",
271
- })
272
- ),
273
- }),
274
- execute: async (
275
- _id,
276
- params,
277
- _signal,
278
- _on_update,
279
- ctx
280
- ) =>
281
- with_file_state(
282
- manager,
283
- params.file,
284
- ctx,
285
- async (result) => {
286
- const symbols =
287
- await result.state.client.document_symbols(result.uri);
288
- return format_symbol_matches(
289
- result.abs,
290
- params.query,
291
- find_symbol_matches(symbols, params.query, {
292
- max_results: params.max_results ?? 20,
293
- top_level_only: params.top_level_only ?? false,
294
- exact_match: params.exact_match ?? false,
295
- kinds: new Set(params.kinds ?? []),
296
- language: result.state.language,
297
- })
298
- );
299
- }
300
- ),
301
- })
302
- );
303
-
304
- pi.registerTool(
305
- defineTool({
306
- name: "lsp_hover",
307
- label: "LSP: hover",
308
- description:
309
- "Get hover info (types, docs) at a position in a file. Positions are zero-based.",
310
- promptSnippet: "Get types and documentation at a symbol position",
311
- promptGuidelines: [
312
- "Use lsp_hover to inspect the type, signature, or documentation of the symbol at a specific zero-based position.",
313
- ],
314
- parameters: Type.Object({
315
- file: Type.String({
316
- description: "Path to the file containing the symbol.",
317
- }),
318
- line: Type.Number({
319
- description: "Zero-based line number of the symbol.",
320
- }),
321
- character: Type.Number({
322
- description: "Zero-based character offset of the symbol.",
323
- }),
324
- }),
325
- execute: async (
326
- _id,
327
- params,
328
- _signal,
329
- _on_update,
330
- ctx
331
- ) =>
332
- with_file_state(
333
- manager,
334
- params.file,
335
- ctx,
336
- async (result) => {
337
- const hover = await result.state.client.hover(result.uri, {
338
- line: params.line,
339
- character: params.character,
340
- });
341
- return format_hover(hover);
342
- }
343
- ),
344
- })
345
- );
74
+ function withTimeout(promise: Promise<ToolResult>, ms: number, label: string): Promise<ToolResult> {
75
+ return Promise.race([
76
+ promise,
77
+ new Promise<ToolResult>((resolve) =>
78
+ setTimeout(() => resolve(err({ kind: "tool_timeout", message: `${label} timed out after ${ms}ms` })), ms)
79
+ ),
80
+ ]);
81
+ }
346
82
 
347
- pi.registerTool(
348
- defineTool({
349
- name: "lsp_definition",
350
- label: "LSP: go to definition",
351
- description:
352
- "Find definition locations for the symbol at a position. Positions are zero-based.",
353
- promptSnippet: "Find definition locations for a symbol at a position",
354
- promptGuidelines: [
355
- "Use lsp_definition to find the canonical definition location for the symbol at a specific zero-based position.",
356
- ],
357
- parameters: Type.Object({
358
- file: Type.String({
359
- description: "Path to the file containing the symbol.",
360
- }),
361
- line: Type.Number({
362
- description: "Zero-based line number of the symbol.",
363
- }),
364
- character: Type.Number({
365
- description: "Zero-based character offset of the symbol.",
366
- }),
367
- }),
368
- execute: async (
369
- _id,
370
- params,
371
- _signal,
372
- _on_update,
373
- ctx
374
- ) =>
375
- with_file_state(
376
- manager,
377
- params.file,
378
- ctx,
379
- async (result) => {
380
- const locations = await result.state.client.definition(
381
- result.uri,
382
- {
383
- line: params.line,
384
- character: params.character,
385
- }
386
- );
387
- return format_locations(locations, "No definition found.");
388
- }
389
- ),
390
- })
391
- );
83
+ function severityLabel(s: number): string {
84
+ return s === 1 ? "error" : s === 2 ? "warning" : s === 3 ? "info" : "hint";
85
+ }
392
86
 
393
- pi.registerTool(
394
- defineTool({
395
- name: "lsp_references",
396
- label: "LSP: find references",
397
- description:
398
- "Find references to the symbol at a position. Positions are zero-based.",
399
- promptSnippet: "Find references to a symbol at a position",
400
- promptGuidelines: [
401
- "Use lsp_references to find usages of a symbol more precisely than text search, optionally including the declaration site.",
402
- ],
403
- parameters: Type.Object({
404
- file: Type.String({
405
- description: "Path to the file containing the symbol.",
406
- }),
407
- line: Type.Number({
408
- description: "Zero-based line number of the symbol.",
409
- }),
410
- character: Type.Number({
411
- description: "Zero-based character offset of the symbol.",
412
- }),
413
- include_declaration: Type.Optional(
414
- Type.Boolean({
415
- description:
416
- "Whether to include the symbol declaration in reference results. Default true.",
417
- })
418
- ),
419
- }),
420
- execute: async (
421
- _id,
422
- params,
423
- _signal,
424
- _on_update,
425
- ctx
426
- ) =>
427
- with_file_state(
428
- manager,
429
- params.file,
430
- ctx,
431
- async (result) => {
432
- const locations = await result.state.client.references(
433
- result.uri,
434
- { line: params.line, character: params.character },
435
- params.include_declaration ?? true
436
- );
437
- return format_locations(locations, "No references found.");
438
- }
439
- ),
440
- })
441
- );
87
+ function symbolKindLabel(kind: number): string {
88
+ const labels: Record<number, string> = { 2:"module",3:"namespace",5:"class",6:"method",7:"property",8:"field",9:"constructor",11:"interface",12:"function",13:"variable",14:"constant",23:"struct",24:"event" };
89
+ return labels[kind] ?? "symbol";
90
+ }
442
91
 
443
- pi.registerTool(
444
- defineTool({
445
- name: "lsp_document_symbols",
446
- label: "LSP: document symbols",
447
- description:
448
- "List symbols in a file (functions, classes, variables) using the language server.",
449
- promptSnippet: "List functions, classes, and variables in a file",
450
- promptGuidelines: [
451
- "Use lsp_document_symbols to inspect a file's structural outline before making focused edits or searching for symbols.",
452
- ],
453
- parameters: Type.Object({
454
- file: Type.String({
455
- description: "Path to the file to inspect.",
456
- }),
457
- }),
458
- execute: async (
459
- _id,
460
- params,
461
- _signal,
462
- _on_update,
463
- ctx
464
- ) =>
465
- with_file_state(
466
- manager,
467
- params.file,
468
- ctx,
469
- async (result) => {
470
- const symbols =
471
- await result.state.client.document_symbols(result.uri);
472
- return format_document_symbols(result.abs, symbols);
473
- }
474
- ),
475
- })
476
- );
92
+ function formatDocumentSymbols(file: string, symbols: any[]): string {
93
+ if (symbols.length === 0) return `${file}: no symbols`;
94
+ const lines = [`${file}: ${symbols.length} top-level symbol(s)`];
95
+ const append = (syms: any[], depth: number) => {
96
+ for (const s of syms) {
97
+ const detail = s.detail ? ` ${s.detail}` : "";
98
+ lines.push(`${" ".repeat(depth)}${symbolKindLabel(s.kind)} ${s.name}${detail} @ ${s.range.start.line + 1}:${s.range.start.character + 1}`);
99
+ if (s.children?.length) append(s.children, depth + 1);
100
+ }
101
+ };
102
+ append(symbols, 1);
103
+ return lines.join("\n");
104
+ }
477
105
 
478
- pi.registerTool(
479
- defineTool({
480
- name: "lsp_rename",
481
- label: "LSP: rename symbol",
482
- description:
483
- "Rename a symbol at a position. Returns all locations that need to be updated with the new name. Use the edit tool to apply the changes.",
484
- promptSnippet: "Compute symbol rename updates across affected files",
485
- promptGuidelines: [
486
- "Use lsp_rename to compute coordinated symbol rename updates across affected files instead of manual search-and-replace.",
487
- ],
488
- parameters: Type.Object({
489
- file: Type.String({
490
- description: "Path to the file containing the symbol.",
491
- }),
492
- line: Type.Number({
493
- description: "Zero-based line number of the symbol.",
494
- }),
495
- character: Type.Number({
496
- description: "Zero-based character offset of the symbol.",
497
- }),
498
- newName: Type.String({
499
- description: "New name for the symbol.",
500
- }),
501
- }),
502
- execute: async (
503
- _id,
504
- params,
505
- _signal,
506
- _on_update,
507
- ctx
508
- ) =>
509
- with_file_state(
510
- manager,
511
- params.file,
512
- ctx,
513
- async (result) => {
514
- const edits = await result.state.client.rename(
515
- result.uri,
516
- { line: params.line, character: params.character },
517
- params.newName
518
- );
106
+ // ─── Register tools ───────────────────────────────────────────────────────
107
+
108
+ export function registerLspTools(pi: ExtensionAPI, manager: LspServerManager) {
109
+
110
+ // ── lsp_diagnostics ────────────────────────────────────────────────────
111
+ pi.registerTool({
112
+ name: "lsp_diagnostics",
113
+ label: "LSP: diagnostics",
114
+ description: "Get language server diagnostics for one or more files. Default filter: error. Supports optional severity filtering.",
115
+ promptSnippet: "Get language server diagnostics for one or more files",
116
+ promptGuidelines: [
117
+ "Use lsp_diagnostics to validate focused code changes after editing or writing before reporting completion.",
118
+ "Supported languages: typescript, c, cpp, python, rust, go, ruby, java, lua, svelte, json.",
119
+ ],
120
+ renderResult: renderLspResult,
121
+ parameters: Type.Object({
122
+ paths: Type.Array(Type.String(), { minItems: 1, maxItems: 100, description: "Paths to check. One or more file paths (relative to cwd or absolute)." }),
123
+ severity: Type.Optional(Type.Array(Type.Union([Type.Literal("error"), Type.Literal("warning"), Type.Literal("info"), Type.Literal("hint")]), { description: "Filter to specific severity levels. Default: error." })),
124
+ wait_ms: Type.Optional(Type.Number({ description: "Max ms to wait for diagnostics. Default 1500." })),
125
+ timeout_ms: Type.Optional(Type.Number({ description: "Overall max ms including server startup. Default 30000." })),
126
+ }),
127
+ execute: async (_id, params, _signal, _update, _ctx): Promise<ToolResult> => {
128
+ const waitMs = params.wait_ms ?? 1500;
129
+ const totalTimeout = params.timeout_ms ?? 30_000;
130
+ const severities: ("error"|"warning"|"info"|"hint")[] = params.severity ?? ["error"];
131
+ const minSeverity = Math.min(...severities.map(s => ({ error: 1, warning: 2, info: 3, hint: 4 }[s])));
132
+
133
+ return withTimeout((async () => {
134
+ const results = await Promise.all(params.paths.map(async (file) => {
135
+ const resolved = await manager.resolveFileState(file, { timeoutMs: totalTimeout });
136
+ if (!resolved.ok) return { line: formatToolError(resolved.error), diagnostics: 0, errors: 0, warnings: 0, isError: true };
137
+ try {
138
+ const diagnostics = await resolved.result.state.client.waitForDiagnostics(resolved.result.uri, waitMs);
139
+ const filtered = diagnostics.filter((d: any) => (d.severity ?? 1) <= minSeverity);
140
+ let errors = 0, warnings = 0;
141
+ for (const d of filtered) { if (d.severity === 1) errors++; else if (d.severity === 2) warnings++; }
142
+ return { line: formatDiagnostics(resolved.result.abs, filtered), diagnostics: filtered.length, errors, warnings, isError: false };
143
+ } catch { return { line: formatToolError({ kind: "tool_execution_failed", file, message: "diagnostics request failed" }), diagnostics: 0, errors: 0, warnings: 0, isError: true }; }
144
+ }));
145
+
146
+ let totalDiag = 0, totalErr = 0, totalWarn = 0, cleanCount = 0, failCount = 0;
147
+ const lines: string[] = [];
148
+ for (const r of results) {
149
+ lines.push(r.line);
150
+ if (r.isError) { failCount++; } else { totalDiag += r.diagnostics; totalErr += r.errors; totalWarn += r.warnings; if (r.diagnostics === 0) cleanCount++; }
151
+ }
152
+ const summary = totalErr > 0 || totalWarn > 0
153
+ ? `Checked ${params.paths.length} file(s): ${totalErr} error(s), ${totalWarn} warning(s), ${totalDiag - totalErr - totalWarn} info/hint(s), ${cleanCount} clean, ${failCount} failed`
154
+ : `Checked ${params.paths.length} file(s): ${totalDiag} diagnostic(s), ${cleanCount} clean, ${failCount} failed`;
155
+ return ok([summary, ...lines].join("\n\n"), { ok: failCount === 0 && totalErr === 0, checked: params.paths.length, diagnostic_count: totalDiag, error_count: totalErr, warning_count: totalWarn });
156
+ })(), totalTimeout, "LSP diagnostics");
157
+ },
158
+ });
519
159
 
520
- // Format rename output as a clear list of files to edit
521
- const locations = Object.keys(edits);
522
- if (locations.length === 0) {
523
- return `No rename locations found for "${params.newName}"`;
524
- }
160
+ // ── lsp_document_symbols ───────────────────────────────────────────────
161
+ pi.registerTool({
162
+ name: "lsp_document_symbols",
163
+ label: "LSP: document symbols",
164
+ description: "List symbols in a file (functions, classes, variables) using the language server.",
165
+ promptSnippet: "List functions, classes, and variables in a file",
166
+ promptGuidelines: [
167
+ "Prefer lsp_document_symbols to find functions, classes, or variables in code instead of grep.",
168
+ "Supported languages: typescript, c, cpp, python, rust, go, ruby, java, lua, svelte.",
169
+ ],
170
+ renderResult: renderLspResult,
171
+ parameters: Type.Object({
172
+ path: Type.String({ description: "Path to the file (relative or absolute)." }),
173
+ timeout_ms: Type.Optional(Type.Number({ description: "Overall max ms including server startup. Default 10000." })),
174
+ }),
175
+ execute: async (_id, params, _signal, _update, _ctx): Promise<ToolResult> => {
176
+ const timeoutMs = params.timeout_ms ?? 10_000;
177
+ return withTimeout(
178
+ withFile(manager, params.path, async (result) => {
179
+ const symbols = await result.state.client.documentSymbols(result.uri, timeoutMs);
180
+ return formatDocumentSymbols(result.abs, symbols);
181
+ }, { timeoutMs }),
182
+ timeoutMs,
183
+ "LSP document symbols",
184
+ );
185
+ },
186
+ });
187
+ }
525
188
 
526
- let output = `Rename to "${params.newName}": ${locations.length} file(s) need update\n\n`;
527
- for (const path of locations) {
528
- const info = edits[path]!;
529
- output += `${path}: change to "${info.newText}"\n`;
530
- }
531
- output += "\nUse the edit tool to apply these changes.";
189
+ // ─── Formatting ────────────────────────────────────────────────────────────
532
190
 
533
- return output;
534
- }
535
- ),
536
- })
537
- );
191
+ function formatDiagnostics(file: string, diagnostics: any[]): string {
192
+ if (diagnostics.length === 0) return `${file}: no diagnostics`;
193
+ const lines = [`${file}: ${diagnostics.length} diagnostic(s)`];
194
+ for (const d of diagnostics) {
195
+ const pos = `${d.range.start.line + 1}:${d.range.start.character + 1}`;
196
+ const source = d.source ? ` [${d.source}]` : "";
197
+ const code = d.code != null ? ` (${d.code})` : "";
198
+ lines.push(` ${pos} ${severityLabel(d.severity ?? 1)}${source}${code}: ${d.message}`);
199
+ }
200
+ return lines.join("\n");
538
201
  }
202
+
203
+ export const __lspToolsTest = { collapse_lsp_text: collapseText };