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