@mrclrchtr/supi-lsp 0.1.0 → 1.0.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.
Files changed (71) hide show
  1. package/README.md +112 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +18 -9
  19. package/{capabilities.ts → src/capabilities.ts} +8 -0
  20. package/src/client/client-refresh.ts +229 -0
  21. package/{client.ts → src/client/client.ts} +178 -30
  22. package/{transport.ts → src/client/transport.ts} +10 -6
  23. package/src/config.ts +143 -0
  24. package/src/defaults.json +82 -0
  25. package/src/diagnostics/diagnostic-augmentation.ts +82 -0
  26. package/src/diagnostics/diagnostic-display.ts +68 -0
  27. package/{diagnostic-summary.ts → src/diagnostics/diagnostic-summary.ts} +11 -7
  28. package/{diagnostics.ts → src/diagnostics/diagnostics.ts} +9 -4
  29. package/src/diagnostics/stale-diagnostics.ts +47 -0
  30. package/src/diagnostics/suppression-diagnostics.ts +58 -0
  31. package/src/format.ts +359 -0
  32. package/src/guidance.ts +163 -0
  33. package/src/index.ts +17 -0
  34. package/src/lsp-state.ts +82 -0
  35. package/src/lsp.ts +470 -0
  36. package/src/manager/manager-client-state.ts +34 -0
  37. package/src/manager/manager-diagnostics.ts +139 -0
  38. package/src/manager/manager-helpers.ts +39 -0
  39. package/src/manager/manager-project-info.ts +46 -0
  40. package/src/manager/manager-types.ts +39 -0
  41. package/src/manager/manager-workspace-recovery.ts +83 -0
  42. package/src/manager/manager-workspace-symbol.ts +18 -0
  43. package/src/manager/manager.ts +550 -0
  44. package/src/overrides.ts +173 -0
  45. package/src/pattern-matcher.ts +197 -0
  46. package/src/renderer.ts +120 -0
  47. package/src/scanner.ts +153 -0
  48. package/src/search-fallback.ts +98 -0
  49. package/src/service-registry.ts +153 -0
  50. package/src/settings-registration.ts +292 -0
  51. package/{summary.ts → src/summary.ts} +44 -9
  52. package/src/tool-actions.ts +430 -0
  53. package/src/tree-persist.ts +48 -0
  54. package/src/tsconfig-scope.ts +156 -0
  55. package/{types.ts → src/types.ts} +123 -0
  56. package/src/ui.ts +358 -0
  57. package/{utils.ts → src/utils.ts} +8 -25
  58. package/src/workspace-sentinels.ts +114 -0
  59. package/bash-guard.ts +0 -58
  60. package/config.ts +0 -99
  61. package/defaults.json +0 -40
  62. package/format.ts +0 -190
  63. package/guidance.ts +0 -140
  64. package/lsp.ts +0 -375
  65. package/manager.ts +0 -396
  66. package/overrides.ts +0 -95
  67. package/recent-paths.ts +0 -126
  68. package/runtime-state.ts +0 -113
  69. package/tool-actions.ts +0 -211
  70. package/tsconfig.json +0 -5
  71. package/ui.ts +0 -303
@@ -0,0 +1,68 @@
1
+ import type { OutstandingDiagnosticSummaryEntry } from "../manager/manager-types.ts";
2
+ import type { Diagnostic } from "../types.ts";
3
+
4
+ export function formatDiagnosticsDisplayContent(
5
+ diagnostics: OutstandingDiagnosticSummaryEntry[],
6
+ detailed?: Array<{ file: string; diagnostics: Diagnostic[] }>,
7
+ ): string {
8
+ const totals = collectDisplayTotals(diagnostics);
9
+ const summary = buildDisplaySummary(totals);
10
+
11
+ if (!detailed || detailed.length === 0) return summary;
12
+
13
+ const detailLines = buildDisplayDetailLines(detailed);
14
+ return detailLines.length > 0 ? `${summary}\n${detailLines.join("\n")}` : summary;
15
+ }
16
+
17
+ function collectDisplayTotals(
18
+ diagnostics: Array<{ errors: number; warnings: number; information: number; hints: number }>,
19
+ ) {
20
+ return diagnostics.reduce(
21
+ (acc, d) => ({
22
+ errors: acc.errors + d.errors,
23
+ warnings: acc.warnings + d.warnings,
24
+ information: acc.information + d.information,
25
+ hints: acc.hints + d.hints,
26
+ }),
27
+ { errors: 0, warnings: 0, information: 0, hints: 0 },
28
+ );
29
+ }
30
+
31
+ function buildDisplaySummary(totals: {
32
+ errors: number;
33
+ warnings: number;
34
+ information: number;
35
+ hints: number;
36
+ }): string {
37
+ const parts: string[] = [];
38
+ if (totals.errors > 0) parts.push(`${totals.errors} error${totals.errors === 1 ? "" : "s"}`);
39
+ if (totals.warnings > 0)
40
+ parts.push(`${totals.warnings} warning${totals.warnings === 1 ? "" : "s"}`);
41
+ if (totals.information > 0)
42
+ parts.push(`${totals.information} info${totals.information === 1 ? "" : "s"}`);
43
+ if (totals.hints > 0) parts.push(`${totals.hints} hint${totals.hints === 1 ? "" : "s"}`);
44
+
45
+ return parts.length > 0
46
+ ? `LSP diagnostics injected (${parts.join(", ")})`
47
+ : "LSP diagnostics injected";
48
+ }
49
+
50
+ function buildDisplayDetailLines(
51
+ detailed: Array<{
52
+ file: string;
53
+ diagnostics: Diagnostic[];
54
+ }>,
55
+ ): string[] {
56
+ const lines: string[] = [];
57
+ for (const entry of detailed) {
58
+ for (const d of entry.diagnostics.slice(0, 3)) {
59
+ const line = d.range.start.line + 1;
60
+ const source = d.source ? ` ${d.source}` : "";
61
+ lines.push(` ${entry.file} L${line}${source}: ${d.message}`);
62
+ }
63
+ if (entry.diagnostics.length > 3) {
64
+ lines.push(` +${entry.diagnostics.length - 3} more`);
65
+ }
66
+ }
67
+ return lines;
68
+ }
@@ -1,13 +1,17 @@
1
- import type { OutstandingDiagnosticSummaryEntry } from "./manager.ts";
2
- import { displayRelativeFilePath, shouldIgnoreLspPath } from "./summary.ts";
3
- import { type Diagnostic, DiagnosticSeverity } from "./types.ts";
1
+ import type { OutstandingDiagnosticSummaryEntry } from "../manager/manager-types.ts";
2
+ import { isGlobMatch } from "../pattern-matcher.ts";
3
+ import { displayRelativeFilePath, shouldIgnoreLspPath } from "../summary.ts";
4
+ import { type Diagnostic, DiagnosticSeverity } from "../types.ts";
4
5
 
5
6
  export function collectDiagnosticSummaryCounts(
6
7
  fileDiags: Map<string, { errors: number; warnings: number }>,
7
8
  entry: { uri: string; diagnostics: Diagnostic[] },
9
+ cwd: string,
10
+ excludePatterns?: string[],
8
11
  ): void {
9
- const file = relativeFilePathFromUri(entry.uri);
10
- if (shouldIgnoreLspPath(file)) return;
12
+ const file = relativeFilePathFromUri(entry.uri, cwd);
13
+ if (shouldIgnoreLspPath(file, cwd)) return;
14
+ if (excludePatterns?.some((p) => isGlobMatch(file, p))) return;
11
15
 
12
16
  const current = fileDiags.get(file) ?? { errors: 0, warnings: 0 };
13
17
  for (const diagnostic of entry.diagnostics) {
@@ -47,8 +51,8 @@ export function accumulateOutstandingDiagnostics(
47
51
  return next;
48
52
  }
49
53
 
50
- export function relativeFilePathFromUri(uri: string): string {
51
- return displayRelativeFilePath(uri.replace("file://", ""));
54
+ export function relativeFilePathFromUri(uri: string, cwd: string): string {
55
+ return displayRelativeFilePath(uri.replace("file://", ""), cwd);
52
56
  }
53
57
 
54
58
  function isDiagnosticWithinThreshold(
@@ -1,7 +1,7 @@
1
1
  // Diagnostic formatting and severity utilities.
2
2
 
3
3
  import * as path from "node:path";
4
- import type { Diagnostic } from "./types.ts";
4
+ import type { Diagnostic } from "../types.ts";
5
5
 
6
6
  /** Map severity number to label. */
7
7
  export function severityLabel(severity: number | undefined): string {
@@ -52,10 +52,14 @@ export function formatDiagnostic(diag: Diagnostic): string {
52
52
  /**
53
53
  * Format a list of diagnostics for a file.
54
54
  */
55
- export function formatDiagnostics(filePath: string, diagnostics: Diagnostic[]): string {
55
+ export function formatDiagnostics(
56
+ filePath: string,
57
+ diagnostics: Diagnostic[],
58
+ cwd: string,
59
+ ): string {
56
60
  if (diagnostics.length === 0) return "No diagnostics.";
57
61
 
58
- const relPath = path.relative(process.cwd(), filePath);
62
+ const relPath = path.relative(cwd, filePath);
59
63
  const lines = [`**${relPath}**:`];
60
64
 
61
65
  for (const diag of diagnostics) {
@@ -70,13 +74,14 @@ export function formatDiagnostics(filePath: string, diagnostics: Diagnostic[]):
70
74
  */
71
75
  export function formatGroupedDiagnostics(
72
76
  entries: Array<{ file: string; diagnostics: Diagnostic[] }>,
77
+ cwd: string,
73
78
  ): string {
74
79
  if (entries.length === 0) return "No diagnostics across any files.";
75
80
 
76
81
  const sections: string[] = [];
77
82
  for (const entry of entries) {
78
83
  if (entry.diagnostics.length > 0) {
79
- sections.push(formatDiagnostics(entry.file, entry.diagnostics));
84
+ sections.push(formatDiagnostics(entry.file, entry.diagnostics, cwd));
80
85
  }
81
86
  }
82
87
 
@@ -0,0 +1,47 @@
1
+ import type { Diagnostic } from "../types.ts";
2
+
3
+ export interface StaleDiagnosticAssessment {
4
+ suspected: boolean;
5
+ matchedFiles: Array<{ file: string; diagnostics: Diagnostic[] }>;
6
+ warning: string | null;
7
+ }
8
+
9
+ const MODULE_RESOLUTION_MESSAGE =
10
+ /cannot find module|cannot find package|cannot resolve module|module not found/i;
11
+ const MODULE_RESOLUTION_CODES = new Set([2307, 2792]);
12
+
13
+ /** Assess whether a diagnostic cluster looks stale after a workspace change. */
14
+ export function assessStaleDiagnostics(
15
+ entries: Array<{ file: string; diagnostics: Diagnostic[] }>,
16
+ ): StaleDiagnosticAssessment {
17
+ const matchedFiles = entries
18
+ .map((entry) => ({
19
+ file: entry.file,
20
+ diagnostics: entry.diagnostics.filter(isLikelyStaleDiagnostic),
21
+ }))
22
+ .filter((entry) => entry.diagnostics.length > 0);
23
+
24
+ const suspected = matchedFiles.length >= 3;
25
+ return {
26
+ suspected,
27
+ matchedFiles,
28
+ warning: suspected
29
+ ? `⚠️ LSP diagnostics may be stale — ${matchedFiles.length} files report missing-module errors after a workspace change.`
30
+ : null,
31
+ };
32
+ }
33
+
34
+ function isLikelyStaleDiagnostic(diagnostic: Diagnostic): boolean {
35
+ if (diagnostic.severity === undefined) return false;
36
+ if (diagnostic.code !== undefined) {
37
+ if (typeof diagnostic.code === "number" && MODULE_RESOLUTION_CODES.has(diagnostic.code)) {
38
+ return true;
39
+ }
40
+ if (typeof diagnostic.code === "string") {
41
+ const parsed = Number.parseInt(diagnostic.code, 10);
42
+ if (MODULE_RESOLUTION_CODES.has(parsed)) return true;
43
+ }
44
+ }
45
+
46
+ return MODULE_RESOLUTION_MESSAGE.test(diagnostic.message);
47
+ }
@@ -0,0 +1,58 @@
1
+ import type { Diagnostic } from "../types.ts";
2
+
3
+ const SUPPRESSION_WARNING_SEVERITY = 2;
4
+
5
+ /** Detect diagnostics that represent stale suppression comments. */
6
+ export function isStaleSuppressionDiagnostic(diagnostic: Diagnostic): boolean {
7
+ const message = diagnostic.message.toLowerCase();
8
+
9
+ if (message.includes("unused '@ts-expect-error' directive")) {
10
+ return true;
11
+ }
12
+
13
+ if (diagnostic.source !== "biome") {
14
+ return false;
15
+ }
16
+
17
+ return (
18
+ message.includes("suppression comment has no effect") || message.includes("unused suppression")
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Split diagnostics into regular inline diagnostics and stale suppression cleanup.
24
+ *
25
+ * Stale suppression warnings stay visible even when regular inline diagnostics are
26
+ * configured to show errors only.
27
+ */
28
+ export function splitSuppressionDiagnostics(
29
+ diagnostics: Diagnostic[],
30
+ maxSeverity: number,
31
+ ): {
32
+ regular: Diagnostic[];
33
+ suppressions: Diagnostic[];
34
+ } {
35
+ const suppressionMaxSeverity = Math.max(maxSeverity, SUPPRESSION_WARNING_SEVERITY);
36
+ const regular: Diagnostic[] = [];
37
+ const suppressions: Diagnostic[] = [];
38
+
39
+ for (const diagnostic of diagnostics) {
40
+ const severity = diagnostic.severity;
41
+ if (severity === undefined) {
42
+ continue;
43
+ }
44
+
45
+ if (isStaleSuppressionDiagnostic(diagnostic)) {
46
+ if (severity <= suppressionMaxSeverity) {
47
+ suppressions.push(diagnostic);
48
+ }
49
+ continue;
50
+ }
51
+
52
+ if (severity <= maxSeverity) {
53
+ regular.push(diagnostic);
54
+ }
55
+ }
56
+
57
+ return { regular, suppressions };
58
+ }
package/src/format.ts ADDED
@@ -0,0 +1,359 @@
1
+ // LSP result formatting — converts LSP response types into readable text.
2
+
3
+ import * as path from "node:path";
4
+ import type { GrepMatch } from "./search-fallback.ts";
5
+ import { isProjectSource } from "./summary.ts";
6
+ import type {
7
+ CodeAction,
8
+ DocumentSymbol,
9
+ Hover,
10
+ Location,
11
+ LocationLink,
12
+ MarkedString,
13
+ MarkupContent,
14
+ SymbolInformation,
15
+ WorkspaceEdit,
16
+ } from "./types.ts";
17
+ import { uriToFile } from "./utils.ts";
18
+
19
+ // ── Hover ─────────────────────────────────────────────────────────────
20
+
21
+ export function formatHover(hover: Hover): string {
22
+ const contents = hover.contents;
23
+
24
+ if (typeof contents === "string") return contents;
25
+ if ("value" in contents) {
26
+ const mc = contents as MarkupContent | { language: string; value: string };
27
+ if ("kind" in mc) return mc.value;
28
+ return `\`\`\`${mc.language}\n${mc.value}\n\`\`\``;
29
+ }
30
+ if (Array.isArray(contents)) {
31
+ return (contents as MarkedString[])
32
+ .map((c) => (typeof c === "string" ? c : `\`\`\`${c.language}\n${c.value}\n\`\`\``))
33
+ .join("\n\n");
34
+ }
35
+
36
+ return String(contents);
37
+ }
38
+
39
+ // ── Locations ─────────────────────────────────────────────────────────
40
+
41
+ function formatLocationLine(loc: Location, cwd: string): string {
42
+ const file = relPath(uriToFile(loc.uri), cwd);
43
+ const line = loc.range.start.line + 1;
44
+ const col = loc.range.start.character + 1;
45
+ return `${file}:${line}:${col}`;
46
+ }
47
+
48
+ function formatLocationList(label: string, locs: Location[], cwd: string): string {
49
+ const lines = [`${label} (${locs.length} locations):\n`];
50
+ for (const loc of locs) {
51
+ lines.push(`- ${formatLocationLine(loc, cwd)}`);
52
+ }
53
+ return lines.join("\n");
54
+ }
55
+
56
+ function formatExternalFallback(label: string, locs: Location[], cwd: string): string {
57
+ const maxShown = 3;
58
+ const shown = locs.slice(0, maxShown);
59
+ const hidden = locs.length - maxShown;
60
+
61
+ let result: string;
62
+ if (shown.length === 1) {
63
+ result = `${label}: ${formatLocationLine(shown[0], cwd)}`;
64
+ } else {
65
+ const lines = [`${label} (${locs.length} locations):\n`];
66
+ for (const loc of shown) {
67
+ lines.push(`- ${formatLocationLine(loc, cwd)}`);
68
+ }
69
+ result = lines.join("\n");
70
+ }
71
+
72
+ if (hidden > 0) {
73
+ result += `\n+${hidden} more external ${hidden === 1 ? "location" : "locations"}`;
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ function formatExternalSuffix(count: number): string {
80
+ return count === 1
81
+ ? "+1 external location (node_modules, .pnpm, or out-of-tree)"
82
+ : `+${count} external locations (node_modules, .pnpm, or out-of-tree)`;
83
+ }
84
+
85
+ export function formatLocations(label: string, locations: Location[], cwd: string): string {
86
+ const projectLocs: Location[] = [];
87
+ const externalLocs: Location[] = [];
88
+ for (const loc of locations) {
89
+ if (isProjectSource(uriToFile(loc.uri), cwd)) {
90
+ projectLocs.push(loc);
91
+ } else {
92
+ externalLocs.push(loc);
93
+ }
94
+ }
95
+
96
+ let result: string;
97
+
98
+ if (projectLocs.length === 1) {
99
+ result = `${label}: ${formatLocationLine(projectLocs[0], cwd)}`;
100
+ } else if (projectLocs.length > 1) {
101
+ result = formatLocationList(label, projectLocs, cwd);
102
+ } else if (externalLocs.length > 0) {
103
+ return formatExternalFallback(label, externalLocs, cwd);
104
+ } else {
105
+ return `${label}: No locations found.`;
106
+ }
107
+
108
+ if (externalLocs.length > 0) {
109
+ result += `\n${formatExternalSuffix(externalLocs.length)}`;
110
+ }
111
+
112
+ return result;
113
+ }
114
+
115
+ export function normalizeLocations(result: Location | Location[] | LocationLink[]): Location[] {
116
+ if (Array.isArray(result)) {
117
+ return result.map((item) => {
118
+ if ("targetUri" in item) {
119
+ const link = item as LocationLink;
120
+ return { uri: link.targetUri, range: link.targetSelectionRange };
121
+ }
122
+ return item as Location;
123
+ });
124
+ }
125
+ return [result as Location];
126
+ }
127
+
128
+ // ── Symbols ───────────────────────────────────────────────────────────
129
+
130
+ export function formatDocumentSymbols(symbols: DocumentSymbol[], indent: number): string {
131
+ const lines: string[] = [];
132
+ const prefix = " ".repeat(indent);
133
+
134
+ for (const sym of symbols) {
135
+ const kind = symbolKindName(sym.kind);
136
+ const line = sym.selectionRange.start.line + 1;
137
+ const detail = sym.detail ? ` — ${sym.detail}` : "";
138
+ lines.push(`${prefix}- ${kind} **${sym.name}**${detail} (line ${line})`);
139
+
140
+ if (sym.children && sym.children.length > 0) {
141
+ lines.push(formatDocumentSymbols(sym.children, indent + 1));
142
+ }
143
+ }
144
+
145
+ return lines.join("\n");
146
+ }
147
+
148
+ export function formatSymbolInformation(symbols: SymbolInformation[], cwd: string): string {
149
+ const projectSyms: SymbolInformation[] = [];
150
+ const externalSyms: SymbolInformation[] = [];
151
+ for (const sym of symbols) {
152
+ if (isProjectSource(uriToFile(sym.location.uri), cwd)) {
153
+ projectSyms.push(sym);
154
+ } else {
155
+ externalSyms.push(sym);
156
+ }
157
+ }
158
+
159
+ const lines: string[] = [];
160
+ const symbolsToShow = projectSyms.length > 0 ? projectSyms : externalSyms;
161
+ for (const sym of symbolsToShow) {
162
+ const kind = symbolKindName(sym.kind);
163
+ const file = relPath(uriToFile(sym.location.uri), cwd);
164
+ const line = sym.location.range.start.line + 1;
165
+ const container = sym.containerName ? ` (in ${sym.containerName})` : "";
166
+ lines.push(`- ${kind} **${sym.name}**${container} — ${file}:${line}`);
167
+ }
168
+
169
+ if (projectSyms.length > 0 && externalSyms.length > 0) {
170
+ const suffix =
171
+ externalSyms.length === 1
172
+ ? "+1 external symbol (node_modules, .pnpm, or out-of-tree)"
173
+ : `+${externalSyms.length} external symbols (node_modules, .pnpm, or out-of-tree)`;
174
+ lines.push(`- _${suffix}_`);
175
+ }
176
+
177
+ return lines.join("\n");
178
+ }
179
+
180
+ // ── Workspace Edits ───────────────────────────────────────────────────
181
+
182
+ interface EditEntry {
183
+ file: string;
184
+ edits: Array<{ range: { start: { line: number } }; newText: string }>;
185
+ }
186
+
187
+ function partitionWorkspaceEdit(
188
+ edit: WorkspaceEdit,
189
+ cwd: string,
190
+ ): { projectChanges: EditEntry[]; externalCount: number } {
191
+ const projectChanges: EditEntry[] = [];
192
+ let externalCount = 0;
193
+
194
+ if (edit.changes) {
195
+ for (const [uri, edits] of Object.entries(edit.changes)) {
196
+ const filePath = uriToFile(uri);
197
+ if (isProjectSource(filePath, cwd)) {
198
+ projectChanges.push({ file: relPath(filePath, cwd), edits });
199
+ } else {
200
+ externalCount++;
201
+ }
202
+ }
203
+ }
204
+
205
+ if (edit.documentChanges) {
206
+ for (const dc of edit.documentChanges) {
207
+ const filePath = uriToFile(dc.textDocument.uri);
208
+ if (isProjectSource(filePath, cwd)) {
209
+ projectChanges.push({ file: relPath(filePath, cwd), edits: dc.edits });
210
+ } else {
211
+ externalCount++;
212
+ }
213
+ }
214
+ }
215
+
216
+ return { projectChanges, externalCount };
217
+ }
218
+
219
+ export function formatWorkspaceEdit(edit: WorkspaceEdit, cwd: string): string {
220
+ const { projectChanges, externalCount } = partitionWorkspaceEdit(edit, cwd);
221
+
222
+ const lines: string[] = ["Rename workspace edit:\n"];
223
+ for (const { file, edits } of projectChanges) {
224
+ lines.push(`**${file}** (${edits.length} change(s))`);
225
+ for (const e of edits) {
226
+ const line = e.range.start.line + 1;
227
+ lines.push(` Line ${line}: → "${e.newText}"`);
228
+ }
229
+ }
230
+
231
+ if (externalCount > 0) {
232
+ const suffix =
233
+ externalCount === 1
234
+ ? "+1 external file (node_modules, .pnpm, or out-of-tree)"
235
+ : `+${externalCount} external files (node_modules, .pnpm, or out-of-tree)`;
236
+ lines.push(`\n_${suffix}_`);
237
+ }
238
+
239
+ return lines.join("\n");
240
+ }
241
+
242
+ // ── Code Actions ──────────────────────────────────────────────────────
243
+
244
+ export function formatCodeActions(actions: CodeAction[]): string {
245
+ const lines = [`Available code actions (${actions.length}):\n`];
246
+ for (const action of actions) {
247
+ const kind = action.kind ? ` [${action.kind}]` : "";
248
+ const preferred = action.isPreferred ? " ⭐" : "";
249
+ lines.push(`- **${action.title}**${kind}${preferred}`);
250
+ if (action.edit) {
251
+ const fileCount = action.edit.changes
252
+ ? Object.keys(action.edit.changes).length
253
+ : (action.edit.documentChanges?.length ?? 0);
254
+ lines.push(` Edits ${fileCount} file(s)`);
255
+ }
256
+ }
257
+ return lines.join("\n");
258
+ }
259
+
260
+ // ── Workspace Symbols ─────────────────────────────────────────────────
261
+
262
+ export function formatWorkspaceSymbols(symbols: SymbolInformation[], cwd: string): string {
263
+ if (symbols.length === 0) return "No symbols found.";
264
+
265
+ const projectSyms: SymbolInformation[] = [];
266
+ const externalSyms: SymbolInformation[] = [];
267
+ for (const sym of symbols) {
268
+ if (isProjectSource(uriToFile(sym.location.uri), cwd)) {
269
+ projectSyms.push(sym);
270
+ } else {
271
+ externalSyms.push(sym);
272
+ }
273
+ }
274
+
275
+ if (projectSyms.length === 0 && externalSyms.length > 0) {
276
+ return `Workspace symbols: No in-project symbols found.\n+${externalSyms.length} external symbol${externalSyms.length === 1 ? "" : "s"} (node_modules, .pnpm, or out-of-tree).`;
277
+ }
278
+
279
+ const lines = [`Workspace symbols (${projectSyms.length}):\n`];
280
+ for (const sym of projectSyms) {
281
+ const kind = symbolKindName(sym.kind);
282
+ const file = relPath(uriToFile(sym.location.uri), cwd);
283
+ const line = sym.location.range.start.line + 1;
284
+ const col = sym.location.range.start.character + 1;
285
+ const container = sym.containerName ? ` — ${sym.containerName}` : "";
286
+ lines.push(`- **${sym.name}** (${kind})${container} — ${file}:${line}:${col}`);
287
+ }
288
+
289
+ if (externalSyms.length > 0) {
290
+ const suffix =
291
+ externalSyms.length === 1
292
+ ? "+1 external symbol (node_modules, .pnpm, or out-of-tree)"
293
+ : `+${externalSyms.length} external symbols (node_modules, .pnpm, or out-of-tree)`;
294
+ lines.push(`\n_${suffix}_`);
295
+ }
296
+
297
+ return lines.join("\n");
298
+ }
299
+
300
+ // ── Search Results ────────────────────────────────────────────────────
301
+
302
+ export function formatSearchResults(
303
+ lspSymbols: SymbolInformation[] | null,
304
+ grepMatches: GrepMatch[] | null,
305
+ cwd: string,
306
+ ): string {
307
+ if (lspSymbols && lspSymbols.length > 0) {
308
+ return formatWorkspaceSymbols(lspSymbols, cwd);
309
+ }
310
+ if (grepMatches && grepMatches.length > 0) {
311
+ const lines = [`Text search results (${grepMatches.length}):\n`];
312
+ for (const match of grepMatches) {
313
+ lines.push(`- ${match.file}:${match.line}: ${match.text}`);
314
+ }
315
+ return lines.join("\n");
316
+ }
317
+ return "No symbols or text matches found.";
318
+ }
319
+
320
+ // ── Symbol Kind Names ─────────────────────────────────────────────────
321
+
322
+ const SYMBOL_KIND_NAMES: Record<number, string> = {
323
+ 1: "File",
324
+ 2: "Module",
325
+ 3: "Namespace",
326
+ 4: "Package",
327
+ 5: "Class",
328
+ 6: "Method",
329
+ 7: "Property",
330
+ 8: "Field",
331
+ 9: "Constructor",
332
+ 10: "Enum",
333
+ 11: "Interface",
334
+ 12: "Function",
335
+ 13: "Variable",
336
+ 14: "Constant",
337
+ 15: "String",
338
+ 16: "Number",
339
+ 17: "Boolean",
340
+ 18: "Array",
341
+ 19: "Object",
342
+ 20: "Key",
343
+ 21: "Null",
344
+ 22: "EnumMember",
345
+ 23: "Struct",
346
+ 24: "Event",
347
+ 25: "Operator",
348
+ 26: "TypeParameter",
349
+ };
350
+
351
+ function symbolKindName(kind: number): string {
352
+ return SYMBOL_KIND_NAMES[kind] ?? `Kind(${kind})`;
353
+ }
354
+
355
+ // ── Helpers ───────────────────────────────────────────────────────────
356
+
357
+ function relPath(filePath: string, cwd: string): string {
358
+ return path.relative(cwd, filePath);
359
+ }