@oh-my-pi/pi-coding-agent 6.1.0 → 6.7.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 (93) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +34 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +25 -25
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +62 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +824 -639
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +49 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +228 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +237 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +1 -1
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +89 -41
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +24 -0
  83. package/src/modes/interactive/components/tool-execution.ts +34 -6
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -326
@@ -1,7 +1,7 @@
1
1
  import type { Dirent } from "node:fs";
2
2
  import { existsSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { BunFile } from "bun";
6
6
  import { type Theme, theme } from "../../../modes/interactive/theme/theme";
7
7
  import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" };
@@ -667,6 +667,7 @@ export type WritethroughCallback = (
667
667
  content: string,
668
668
  signal?: AbortSignal,
669
669
  file?: BunFile,
670
+ batch?: LspWritethroughBatchRequest,
670
671
  ) => Promise<FileDiagnosticsResult | undefined>;
671
672
 
672
673
  /** No-op writethrough callback */
@@ -684,772 +685,956 @@ export async function writethroughNoop(
684
685
  return undefined;
685
686
  }
686
687
 
687
- /** Create a writethrough callback for LSP aware write operations */
688
- export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
689
- const { enableFormat = false, enableDiagnostics = false } = options ?? {};
690
- if (!enableFormat && !enableDiagnostics) {
691
- return writethroughNoop;
688
+ interface PendingWritethrough {
689
+ dst: string;
690
+ content: string;
691
+ file?: BunFile;
692
+ }
693
+
694
+ interface LspWritethroughBatchRequest {
695
+ id: string;
696
+ flush: boolean;
697
+ }
698
+
699
+ interface LspWritethroughBatchState {
700
+ entries: Map<string, PendingWritethrough>;
701
+ options: Required<WritethroughOptions>;
702
+ }
703
+
704
+ const writethroughBatches = new Map<string, LspWritethroughBatchState>();
705
+
706
+ function getOrCreateWritethroughBatch(id: string, options: Required<WritethroughOptions>): LspWritethroughBatchState {
707
+ const existing = writethroughBatches.get(id);
708
+ if (existing) {
709
+ existing.options.enableFormat ||= options.enableFormat;
710
+ existing.options.enableDiagnostics ||= options.enableDiagnostics;
711
+ return existing;
692
712
  }
693
- return async (dst: string, content: string, signal?: AbortSignal, file?: BunFile) => {
694
- const config = await getConfig(cwd);
695
- const servers = getServersForFile(config, dst);
696
- if (servers.length === 0) {
697
- return writethroughNoop(dst, content, signal, file);
713
+ const batch: LspWritethroughBatchState = {
714
+ entries: new Map<string, PendingWritethrough>(),
715
+ options: { ...options },
716
+ };
717
+ writethroughBatches.set(id, batch);
718
+ return batch;
719
+ }
720
+
721
+ export async function flushLspWritethroughBatch(
722
+ id: string,
723
+ cwd: string,
724
+ signal?: AbortSignal,
725
+ ): Promise<FileDiagnosticsResult | undefined> {
726
+ const state = writethroughBatches.get(id);
727
+ if (!state) {
728
+ return undefined;
729
+ }
730
+ writethroughBatches.delete(id);
731
+ return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
732
+ }
733
+
734
+ function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
735
+ const counts = { error: 0, warning: 0, info: 0, hint: 0 };
736
+ for (const message of messages) {
737
+ const match = message.match(/\[(error|warning|info|hint)\]/i);
738
+ if (!match) continue;
739
+ const key = match[1].toLowerCase() as keyof typeof counts;
740
+ counts[key] += 1;
741
+ }
742
+
743
+ const parts: string[] = [];
744
+ if (counts.error > 0) parts.push(`${counts.error} error(s)`);
745
+ if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
746
+ if (counts.info > 0) parts.push(`${counts.info} info(s)`);
747
+ if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
748
+
749
+ return {
750
+ summary: parts.length > 0 ? parts.join(", ") : "no issues",
751
+ errored: counts.error > 0,
752
+ };
753
+ }
754
+
755
+ function mergeDiagnostics(
756
+ results: Array<FileDiagnosticsResult | undefined>,
757
+ options: Required<WritethroughOptions>,
758
+ ): FileDiagnosticsResult | undefined {
759
+ const messages: string[] = [];
760
+ const servers = new Set<string>();
761
+ let hasResults = false;
762
+ let hasFormatter = false;
763
+ let formatted = false;
764
+
765
+ for (const result of results) {
766
+ if (!result) continue;
767
+ hasResults = true;
768
+ if (result.server) {
769
+ for (const server of result.server.split(",")) {
770
+ const trimmed = server.trim();
771
+ if (trimmed) {
772
+ servers.add(trimmed);
773
+ }
774
+ }
698
775
  }
699
- const { lspServers, customLinterServers } = splitServers(servers);
776
+ if (result.messages.length > 0) {
777
+ messages.push(...result.messages);
778
+ }
779
+ if (result.formatter !== undefined) {
780
+ hasFormatter = true;
781
+ if (result.formatter === FileFormatResult.FORMATTED) {
782
+ formatted = true;
783
+ }
784
+ }
785
+ }
786
+
787
+ if (!hasResults && !hasFormatter) {
788
+ return undefined;
789
+ }
700
790
 
701
- let finalContent = content;
702
- const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
703
- const getWritePromise = once(() => writeContent(finalContent));
704
- const useCustomFormatter = enableFormat && customLinterServers.length > 0;
791
+ let summary = options.enableDiagnostics ? "no issues" : "OK";
792
+ let errored = false;
793
+ if (messages.length > 0) {
794
+ const summaryInfo = summarizeDiagnosticMessages(messages);
795
+ summary = summaryInfo.summary;
796
+ errored = summaryInfo.errored;
797
+ }
798
+ const formatter = hasFormatter ? (formatted ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED) : undefined;
705
799
 
706
- // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
707
- const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers) : undefined;
800
+ return {
801
+ server: servers.size > 0 ? Array.from(servers).join(", ") : undefined,
802
+ messages,
803
+ summary,
804
+ errored,
805
+ formatter,
806
+ };
807
+ }
708
808
 
709
- let formatter: FileFormatResult | undefined;
710
- let diagnostics: FileDiagnosticsResult | undefined;
711
- try {
712
- const timeoutSignal = AbortSignal.timeout(10_000);
713
- const operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
714
- await untilAborted(operationSignal, async () => {
715
- if (useCustomFormatter) {
716
- // Custom linters (e.g. Biome CLI) require on-disk input.
717
- await writeContent(content);
718
- finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal);
809
+ async function runLspWritethrough(
810
+ dst: string,
811
+ content: string,
812
+ cwd: string,
813
+ options: Required<WritethroughOptions>,
814
+ signal?: AbortSignal,
815
+ file?: BunFile,
816
+ ): Promise<FileDiagnosticsResult | undefined> {
817
+ const { enableFormat, enableDiagnostics } = options;
818
+ const config = await getConfig(cwd);
819
+ const servers = getServersForFile(config, dst);
820
+ if (servers.length === 0) {
821
+ return writethroughNoop(dst, content, signal, file);
822
+ }
823
+ const { lspServers, customLinterServers } = splitServers(servers);
824
+
825
+ let finalContent = content;
826
+ const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
827
+ const getWritePromise = once(() => writeContent(finalContent));
828
+ const useCustomFormatter = enableFormat && customLinterServers.length > 0;
829
+
830
+ // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
831
+ const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers) : undefined;
832
+
833
+ let formatter: FileFormatResult | undefined;
834
+ let diagnostics: FileDiagnosticsResult | undefined;
835
+ try {
836
+ const timeoutSignal = AbortSignal.timeout(10_000);
837
+ const operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
838
+ await untilAborted(operationSignal, async () => {
839
+ if (useCustomFormatter) {
840
+ // Custom linters (e.g. Biome CLI) require on-disk input.
841
+ await writeContent(content);
842
+ finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal);
843
+ formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
844
+ await writeContent(finalContent);
845
+ await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
846
+ } else {
847
+ // 1. Sync original content to LSP servers
848
+ await syncFileContent(dst, content, cwd, lspServers, operationSignal);
849
+
850
+ // 2. Format in-memory via LSP
851
+ if (enableFormat) {
852
+ finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal);
719
853
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
720
- await writeContent(finalContent);
854
+ }
855
+
856
+ // 3. If formatted, sync formatted content to LSP servers
857
+ if (finalContent !== content) {
721
858
  await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
722
- } else {
723
- // 1. Sync original content to LSP servers
724
- await syncFileContent(dst, content, cwd, lspServers, operationSignal);
859
+ }
725
860
 
726
- // 2. Format in-memory via LSP
727
- if (enableFormat) {
728
- finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal);
729
- formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
730
- }
861
+ // 4. Write to disk
862
+ await getWritePromise();
863
+ }
731
864
 
732
- // 3. If formatted, sync formatted content to LSP servers
733
- if (finalContent !== content) {
734
- await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
735
- }
865
+ // 5. Notify saved to LSP servers
866
+ await notifyFileSaved(dst, cwd, lspServers, operationSignal);
736
867
 
737
- // 4. Write to disk
738
- await getWritePromise();
739
- }
868
+ // 6. Get diagnostics from all servers (wait for fresh results)
869
+ if (enableDiagnostics) {
870
+ diagnostics = await getDiagnosticsForFile(dst, cwd, servers, operationSignal, minVersions);
871
+ }
872
+ });
873
+ } catch {
874
+ await getWritePromise();
875
+ }
740
876
 
741
- // 5. Notify saved to LSP servers
742
- await notifyFileSaved(dst, cwd, lspServers, operationSignal);
877
+ if (formatter !== undefined) {
878
+ diagnostics ??= {
879
+ server: servers.map(([name]) => name).join(", "),
880
+ messages: [],
881
+ summary: "OK",
882
+ errored: false,
883
+ };
884
+ diagnostics.formatter = formatter;
885
+ }
743
886
 
744
- // 6. Get diagnostics from all servers (wait for fresh results)
745
- if (enableDiagnostics) {
746
- diagnostics = await getDiagnosticsForFile(dst, cwd, servers, operationSignal, minVersions);
747
- }
748
- });
749
- } catch {
750
- await getWritePromise();
887
+ return diagnostics;
888
+ }
889
+
890
+ async function flushWritethroughBatch(
891
+ batch: PendingWritethrough[],
892
+ cwd: string,
893
+ options: Required<WritethroughOptions>,
894
+ signal?: AbortSignal,
895
+ ): Promise<FileDiagnosticsResult | undefined> {
896
+ if (batch.length === 0) {
897
+ return undefined;
898
+ }
899
+ const results: Array<FileDiagnosticsResult | undefined> = [];
900
+ for (const entry of batch) {
901
+ results.push(await runLspWritethrough(entry.dst, entry.content, cwd, options, signal, entry.file));
902
+ }
903
+ return mergeDiagnostics(results, options);
904
+ }
905
+
906
+ /** Create a writethrough callback for LSP aware write operations */
907
+ export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
908
+ const resolvedOptions: Required<WritethroughOptions> = {
909
+ enableFormat: options?.enableFormat ?? false,
910
+ enableDiagnostics: options?.enableDiagnostics ?? false,
911
+ };
912
+ if (!resolvedOptions.enableFormat && !resolvedOptions.enableDiagnostics) {
913
+ return writethroughNoop;
914
+ }
915
+ return async (
916
+ dst: string,
917
+ content: string,
918
+ signal?: AbortSignal,
919
+ file?: BunFile,
920
+ batch?: LspWritethroughBatchRequest,
921
+ ) => {
922
+ if (!batch) {
923
+ return runLspWritethrough(dst, content, cwd, resolvedOptions, signal, file);
751
924
  }
752
925
 
753
- if (formatter !== undefined) {
754
- diagnostics ??= {
755
- server: servers.map(([name]) => name).join(", "),
756
- messages: [],
757
- summary: "OK",
758
- errored: false,
759
- };
760
- diagnostics.formatter = formatter;
926
+ const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
927
+ state.entries.set(dst, { dst, content, file });
928
+
929
+ if (!batch.flush) {
930
+ await writethroughNoop(dst, content, signal, file);
931
+ return undefined;
761
932
  }
762
933
 
763
- return diagnostics;
934
+ writethroughBatches.delete(batch.id);
935
+ return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
764
936
  };
765
937
  }
766
938
 
767
- /** Create an LSP tool */
768
- export function createLspTool(session: ToolSession): AgentTool<typeof lspSchema, LspToolDetails, Theme> | null {
769
- if (session.enableLsp === false) {
770
- return null;
939
+ /**
940
+ * LSP tool for language server protocol operations.
941
+ */
942
+ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Theme> {
943
+ public readonly name = "lsp";
944
+ public readonly label = "LSP";
945
+ public readonly description: string;
946
+ public readonly parameters = lspSchema;
947
+ public readonly renderCall = renderCall;
948
+ public readonly renderResult = renderResult;
949
+
950
+ private readonly session: ToolSession;
951
+
952
+ constructor(session: ToolSession) {
953
+ this.session = session;
954
+ this.description = renderPromptTemplate(lspDescription);
771
955
  }
772
- return {
773
- name: "lsp",
774
- label: "LSP",
775
- description: renderPromptTemplate(lspDescription),
776
- parameters: lspSchema,
777
- renderCall,
778
- renderResult,
779
- execute: async (_toolCallId, params: LspParams, _signal) => {
780
- const {
781
- action,
782
- file,
783
- files,
784
- line,
785
- column,
786
- end_line,
787
- end_character,
788
- query,
789
- new_name,
790
- replacement,
791
- kind,
792
- apply,
793
- action_index,
794
- include_declaration,
795
- } = params;
796
-
797
- const config = await getConfig(session.cwd);
798
-
799
- // Status action doesn't need a file
800
- if (action === "status") {
801
- const servers = Object.keys(config.servers);
802
- const lspmuxState = await detectLspmux();
803
- const lspmuxStatus = lspmuxState.available
804
- ? lspmuxState.running
805
- ? "lspmux: active (multiplexing enabled)"
806
- : "lspmux: installed but server not running"
807
- : "";
808
-
809
- const serverStatus =
810
- servers.length > 0
811
- ? `Active language servers: ${servers.join(", ")}`
812
- : "No language servers configured for this project";
813
-
814
- const output = lspmuxStatus ? `${serverStatus}\n${lspmuxStatus}` : serverStatus;
815
- return {
816
- content: [{ type: "text", text: output }],
817
- details: { action, success: true },
818
- };
819
- }
820
956
 
821
- // Workspace diagnostics - check entire project
822
- if (action === "workspace_diagnostics") {
823
- const result = await runWorkspaceDiagnostics(session.cwd, config);
957
+ static createIf(session: ToolSession): LspTool | null {
958
+ return session.enableLsp === false ? null : new LspTool(session);
959
+ }
960
+
961
+ public async execute(
962
+ _toolCallId: string,
963
+ params: LspParams,
964
+ _signal?: AbortSignal,
965
+ _onUpdate?: AgentToolUpdateCallback<LspToolDetails>,
966
+ _context?: AgentToolContext,
967
+ ): Promise<AgentToolResult<LspToolDetails>> {
968
+ const {
969
+ action,
970
+ file,
971
+ files,
972
+ line,
973
+ column,
974
+ end_line,
975
+ end_character,
976
+ query,
977
+ new_name,
978
+ replacement,
979
+ kind,
980
+ apply,
981
+ action_index,
982
+ include_declaration,
983
+ } = params;
984
+
985
+ const config = await getConfig(this.session.cwd);
986
+
987
+ // Status action doesn't need a file
988
+ if (action === "status") {
989
+ const servers = Object.keys(config.servers);
990
+ const lspmuxState = await detectLspmux();
991
+ const lspmuxStatus = lspmuxState.available
992
+ ? lspmuxState.running
993
+ ? "lspmux: active (multiplexing enabled)"
994
+ : "lspmux: installed but server not running"
995
+ : "";
996
+
997
+ const serverStatus =
998
+ servers.length > 0
999
+ ? `Active language servers: ${servers.join(", ")}`
1000
+ : "No language servers configured for this project";
1001
+
1002
+ const output = lspmuxStatus ? `${serverStatus}\n${lspmuxStatus}` : serverStatus;
1003
+ return {
1004
+ content: [{ type: "text", text: output }],
1005
+ details: { action, success: true },
1006
+ };
1007
+ }
1008
+
1009
+ // Workspace diagnostics - check entire project
1010
+ if (action === "workspace_diagnostics") {
1011
+ const result = await runWorkspaceDiagnostics(this.session.cwd, config);
1012
+ return {
1013
+ content: [
1014
+ {
1015
+ type: "text",
1016
+ text: `Workspace diagnostics (${result.projectType.description}):\n${result.output}`,
1017
+ },
1018
+ ],
1019
+ details: { action, success: true },
1020
+ };
1021
+ }
1022
+
1023
+ // Diagnostics can be batch or single-file - queries all applicable servers
1024
+ if (action === "diagnostics") {
1025
+ const targets = files?.length ? files : file ? [file] : null;
1026
+ if (!targets) {
824
1027
  return {
825
- content: [
826
- {
827
- type: "text",
828
- text: `Workspace diagnostics (${result.projectType.description}):\n${result.output}`,
829
- },
830
- ],
831
- details: { action, success: true },
1028
+ content: [{ type: "text", text: "Error: file or files parameter required for diagnostics" }],
1029
+ details: { action, success: false },
832
1030
  };
833
1031
  }
834
1032
 
835
- // Diagnostics can be batch or single-file - queries all applicable servers
836
- if (action === "diagnostics") {
837
- const targets = files?.length ? files : file ? [file] : null;
838
- if (!targets) {
839
- return {
840
- content: [{ type: "text", text: "Error: file or files parameter required for diagnostics" }],
841
- details: { action, success: false },
842
- };
843
- }
844
-
845
- const detailed = Boolean(files?.length);
846
- const results: string[] = [];
847
- const allServerNames = new Set<string>();
1033
+ const detailed = Boolean(files?.length);
1034
+ const results: string[] = [];
1035
+ const allServerNames = new Set<string>();
848
1036
 
849
- for (const target of targets) {
850
- const resolved = resolveToCwd(target, session.cwd);
851
- const servers = getServersForFile(config, resolved);
852
- if (servers.length === 0) {
853
- results.push(`${theme.status.error} ${target}: No language server found`);
854
- continue;
855
- }
1037
+ for (const target of targets) {
1038
+ const resolved = resolveToCwd(target, this.session.cwd);
1039
+ const servers = getServersForFile(config, resolved);
1040
+ if (servers.length === 0) {
1041
+ results.push(`${theme.status.error} ${target}: No language server found`);
1042
+ continue;
1043
+ }
856
1044
 
857
- const uri = fileToUri(resolved);
858
- const relPath = path.relative(session.cwd, resolved);
859
- const allDiagnostics: Diagnostic[] = [];
860
-
861
- // Query all applicable servers for this file
862
- for (const [serverName, serverConfig] of servers) {
863
- allServerNames.add(serverName);
864
- try {
865
- if (serverConfig.createClient) {
866
- const linterClient = getLinterClient(serverName, serverConfig, session.cwd);
867
- const diagnostics = await linterClient.lint(resolved);
868
- allDiagnostics.push(...diagnostics);
869
- continue;
870
- }
871
- const client = await getOrCreateClient(serverConfig, session.cwd);
872
- const minVersion = client.diagnosticsVersion;
873
- await refreshFile(client, resolved);
874
- const diagnostics = await waitForDiagnostics(client, uri, 3000, undefined, minVersion);
1045
+ const uri = fileToUri(resolved);
1046
+ const relPath = path.relative(this.session.cwd, resolved);
1047
+ const allDiagnostics: Diagnostic[] = [];
1048
+
1049
+ // Query all applicable servers for this file
1050
+ for (const [serverName, serverConfig] of servers) {
1051
+ allServerNames.add(serverName);
1052
+ try {
1053
+ if (serverConfig.createClient) {
1054
+ const linterClient = getLinterClient(serverName, serverConfig, this.session.cwd);
1055
+ const diagnostics = await linterClient.lint(resolved);
875
1056
  allDiagnostics.push(...diagnostics);
876
- } catch {
877
- // Server failed, continue with others
1057
+ continue;
878
1058
  }
1059
+ const client = await getOrCreateClient(serverConfig, this.session.cwd);
1060
+ const minVersion = client.diagnosticsVersion;
1061
+ await refreshFile(client, resolved);
1062
+ const diagnostics = await waitForDiagnostics(client, uri, 3000, undefined, minVersion);
1063
+ allDiagnostics.push(...diagnostics);
1064
+ } catch {
1065
+ // Server failed, continue with others
879
1066
  }
1067
+ }
880
1068
 
881
- // Deduplicate diagnostics
882
- const seen = new Set<string>();
883
- const uniqueDiagnostics: Diagnostic[] = [];
884
- for (const d of allDiagnostics) {
885
- const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
886
- if (!seen.has(key)) {
887
- seen.add(key);
888
- uniqueDiagnostics.push(d);
889
- }
1069
+ // Deduplicate diagnostics
1070
+ const seen = new Set<string>();
1071
+ const uniqueDiagnostics: Diagnostic[] = [];
1072
+ for (const d of allDiagnostics) {
1073
+ const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
1074
+ if (!seen.has(key)) {
1075
+ seen.add(key);
1076
+ uniqueDiagnostics.push(d);
890
1077
  }
1078
+ }
891
1079
 
892
- if (!detailed && targets.length === 1) {
893
- if (uniqueDiagnostics.length === 0) {
894
- return {
895
- content: [{ type: "text", text: "No diagnostics" }],
896
- details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
897
- };
898
- }
899
-
900
- const summary = formatDiagnosticsSummary(uniqueDiagnostics);
901
- const formatted = uniqueDiagnostics.map((d) => formatDiagnostic(d, relPath));
902
- const output = `${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}`;
1080
+ if (!detailed && targets.length === 1) {
1081
+ if (uniqueDiagnostics.length === 0) {
903
1082
  return {
904
- content: [{ type: "text", text: output }],
1083
+ content: [{ type: "text", text: "No diagnostics" }],
905
1084
  details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
906
1085
  };
907
1086
  }
908
1087
 
909
- if (uniqueDiagnostics.length === 0) {
910
- results.push(`${theme.status.success} ${relPath}: no issues`);
911
- } else {
912
- const summary = formatDiagnosticsSummary(uniqueDiagnostics);
913
- results.push(`${theme.status.error} ${relPath}: ${summary}`);
914
- for (const diag of uniqueDiagnostics) {
915
- results.push(` ${formatDiagnostic(diag, relPath)}`);
916
- }
917
- }
1088
+ const summary = formatDiagnosticsSummary(uniqueDiagnostics);
1089
+ const formatted = uniqueDiagnostics.map((d) => formatDiagnostic(d, relPath));
1090
+ const output = `${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}`;
1091
+ return {
1092
+ content: [{ type: "text", text: output }],
1093
+ details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
1094
+ };
918
1095
  }
919
1096
 
920
- return {
921
- content: [{ type: "text", text: results.join("\n") }],
922
- details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
923
- };
1097
+ if (uniqueDiagnostics.length === 0) {
1098
+ results.push(`${theme.status.success} ${relPath}: no issues`);
1099
+ } else {
1100
+ const summary = formatDiagnosticsSummary(uniqueDiagnostics);
1101
+ results.push(`${theme.status.error} ${relPath}: ${summary}`);
1102
+ for (const diag of uniqueDiagnostics) {
1103
+ results.push(` ${formatDiagnostic(diag, relPath)}`);
1104
+ }
1105
+ }
924
1106
  }
925
1107
 
926
- const requiresFile =
927
- !file &&
928
- action !== "workspace_symbols" &&
929
- action !== "flycheck" &&
930
- action !== "ssr" &&
931
- action !== "runnables" &&
932
- action !== "reload_workspace";
1108
+ return {
1109
+ content: [{ type: "text", text: results.join("\n") }],
1110
+ details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
1111
+ };
1112
+ }
933
1113
 
934
- if (requiresFile) {
935
- return {
936
- content: [{ type: "text", text: "Error: file parameter required for this action" }],
937
- details: { action, success: false },
938
- };
939
- }
1114
+ const requiresFile =
1115
+ !file &&
1116
+ action !== "workspace_symbols" &&
1117
+ action !== "flycheck" &&
1118
+ action !== "ssr" &&
1119
+ action !== "runnables" &&
1120
+ action !== "reload_workspace";
1121
+
1122
+ if (requiresFile) {
1123
+ return {
1124
+ content: [{ type: "text", text: "Error: file parameter required for this action" }],
1125
+ details: { action, success: false },
1126
+ };
1127
+ }
940
1128
 
941
- const resolvedFile = file ? resolveToCwd(file, session.cwd) : null;
942
- const serverInfo = resolvedFile
943
- ? getLspServerForFile(config, resolvedFile)
944
- : getServerForWorkspaceAction(config, action);
1129
+ const resolvedFile = file ? resolveToCwd(file, this.session.cwd) : null;
1130
+ const serverInfo = resolvedFile
1131
+ ? getLspServerForFile(config, resolvedFile)
1132
+ : getServerForWorkspaceAction(config, action);
945
1133
 
946
- if (!serverInfo) {
947
- return {
948
- content: [{ type: "text", text: "No language server found for this action" }],
949
- details: { action, success: false },
950
- };
951
- }
1134
+ if (!serverInfo) {
1135
+ return {
1136
+ content: [{ type: "text", text: "No language server found for this action" }],
1137
+ details: { action, success: false },
1138
+ };
1139
+ }
952
1140
 
953
- const [serverName, serverConfig] = serverInfo;
1141
+ const [serverName, serverConfig] = serverInfo;
954
1142
 
955
- try {
956
- const client = await getOrCreateClient(serverConfig, session.cwd);
957
- let targetFile = resolvedFile;
958
- if (action === "runnables" && !targetFile) {
959
- targetFile = findFileForServer(session.cwd, serverConfig);
960
- if (!targetFile) {
961
- return {
962
- content: [{ type: "text", text: "Error: no matching files found for runnables" }],
963
- details: { action, serverName, success: false },
964
- };
965
- }
1143
+ try {
1144
+ const client = await getOrCreateClient(serverConfig, this.session.cwd);
1145
+ let targetFile = resolvedFile;
1146
+ if (action === "runnables" && !targetFile) {
1147
+ targetFile = findFileForServer(this.session.cwd, serverConfig);
1148
+ if (!targetFile) {
1149
+ return {
1150
+ content: [{ type: "text", text: "Error: no matching files found for runnables" }],
1151
+ details: { action, serverName, success: false },
1152
+ };
966
1153
  }
1154
+ }
967
1155
 
968
- if (targetFile) {
969
- await ensureFileOpen(client, targetFile);
970
- }
1156
+ if (targetFile) {
1157
+ await ensureFileOpen(client, targetFile);
1158
+ }
1159
+
1160
+ const uri = targetFile ? fileToUri(targetFile) : "";
1161
+ const position = { line: (line || 1) - 1, character: (column || 1) - 1 };
971
1162
 
972
- const uri = targetFile ? fileToUri(targetFile) : "";
973
- const position = { line: (line || 1) - 1, character: (column || 1) - 1 };
1163
+ let output: string;
974
1164
 
975
- let output: string;
1165
+ switch (action) {
1166
+ // =====================================================================
1167
+ // Standard LSP Operations
1168
+ // =====================================================================
976
1169
 
977
- switch (action) {
978
- // =====================================================================
979
- // Standard LSP Operations
980
- // =====================================================================
1170
+ case "definition": {
1171
+ const result = (await sendRequest(client, "textDocument/definition", {
1172
+ textDocument: { uri },
1173
+ position,
1174
+ })) as Location | Location[] | LocationLink | LocationLink[] | null;
981
1175
 
982
- case "definition": {
983
- const result = (await sendRequest(client, "textDocument/definition", {
984
- textDocument: { uri },
985
- position,
986
- })) as Location | Location[] | LocationLink | LocationLink[] | null;
1176
+ if (!result) {
1177
+ output = "No definition found";
1178
+ } else {
1179
+ const raw = Array.isArray(result) ? result : [result];
1180
+ const locations = raw.flatMap((loc) => {
1181
+ if ("uri" in loc) {
1182
+ return [loc as Location];
1183
+ }
1184
+ if ("targetUri" in loc) {
1185
+ // Use targetSelectionRange (the precise identifier range) with fallback to targetRange
1186
+ const link = loc as LocationLink;
1187
+ return [{ uri: link.targetUri, range: link.targetSelectionRange ?? link.targetRange }];
1188
+ }
1189
+ return [];
1190
+ });
987
1191
 
988
- if (!result) {
1192
+ if (locations.length === 0) {
989
1193
  output = "No definition found";
990
1194
  } else {
991
- const raw = Array.isArray(result) ? result : [result];
992
- const locations = raw.flatMap((loc) => {
993
- if ("uri" in loc) {
994
- return [loc as Location];
995
- }
996
- if ("targetUri" in loc) {
997
- // Use targetSelectionRange (the precise identifier range) with fallback to targetRange
998
- const link = loc as LocationLink;
999
- return [{ uri: link.targetUri, range: link.targetSelectionRange ?? link.targetRange }];
1000
- }
1001
- return [];
1002
- });
1003
-
1004
- if (locations.length === 0) {
1005
- output = "No definition found";
1006
- } else {
1007
- output = `Found ${locations.length} definition(s):\n${locations
1008
- .map((loc) => ` ${formatLocation(loc, session.cwd)}`)
1009
- .join("\n")}`;
1010
- }
1195
+ output = `Found ${locations.length} definition(s):\n${locations
1196
+ .map((loc) => ` ${formatLocation(loc, this.session.cwd)}`)
1197
+ .join("\n")}`;
1011
1198
  }
1012
- break;
1013
1199
  }
1200
+ break;
1201
+ }
1014
1202
 
1015
- case "references": {
1016
- const result = (await sendRequest(client, "textDocument/references", {
1017
- textDocument: { uri },
1018
- position,
1019
- context: { includeDeclaration: include_declaration ?? true },
1020
- })) as Location[] | null;
1203
+ case "references": {
1204
+ const result = (await sendRequest(client, "textDocument/references", {
1205
+ textDocument: { uri },
1206
+ position,
1207
+ context: { includeDeclaration: include_declaration ?? true },
1208
+ })) as Location[] | null;
1021
1209
 
1022
- if (!result || result.length === 0) {
1023
- output = "No references found";
1024
- } else {
1025
- const lines = result.map((loc) => ` ${formatLocation(loc, session.cwd)}`);
1026
- output = `Found ${result.length} reference(s):\n${lines.join("\n")}`;
1027
- }
1028
- break;
1210
+ if (!result || result.length === 0) {
1211
+ output = "No references found";
1212
+ } else {
1213
+ const lines = result.map((loc) => ` ${formatLocation(loc, this.session.cwd)}`);
1214
+ output = `Found ${result.length} reference(s):\n${lines.join("\n")}`;
1029
1215
  }
1216
+ break;
1217
+ }
1030
1218
 
1031
- case "hover": {
1032
- const result = (await sendRequest(client, "textDocument/hover", {
1033
- textDocument: { uri },
1034
- position,
1035
- })) as Hover | null;
1219
+ case "hover": {
1220
+ const result = (await sendRequest(client, "textDocument/hover", {
1221
+ textDocument: { uri },
1222
+ position,
1223
+ })) as Hover | null;
1036
1224
 
1037
- if (!result || !result.contents) {
1038
- output = "No hover information";
1039
- } else {
1040
- output = extractHoverText(result.contents);
1041
- }
1042
- break;
1225
+ if (!result || !result.contents) {
1226
+ output = "No hover information";
1227
+ } else {
1228
+ output = extractHoverText(result.contents);
1043
1229
  }
1230
+ break;
1231
+ }
1044
1232
 
1045
- case "symbols": {
1046
- const result = (await sendRequest(client, "textDocument/documentSymbol", {
1047
- textDocument: { uri },
1048
- })) as (DocumentSymbol | SymbolInformation)[] | null;
1233
+ case "symbols": {
1234
+ const result = (await sendRequest(client, "textDocument/documentSymbol", {
1235
+ textDocument: { uri },
1236
+ })) as (DocumentSymbol | SymbolInformation)[] | null;
1049
1237
 
1050
- if (!result || result.length === 0) {
1051
- output = "No symbols found";
1052
- } else if (!targetFile) {
1053
- return {
1054
- content: [{ type: "text", text: "Error: file parameter required for symbols" }],
1055
- details: { action, serverName, success: false },
1056
- };
1238
+ if (!result || result.length === 0) {
1239
+ output = "No symbols found";
1240
+ } else if (!targetFile) {
1241
+ return {
1242
+ content: [{ type: "text", text: "Error: file parameter required for symbols" }],
1243
+ details: { action, serverName, success: false },
1244
+ };
1245
+ } else {
1246
+ const relPath = path.relative(this.session.cwd, targetFile);
1247
+ // Check if hierarchical (DocumentSymbol) or flat (SymbolInformation)
1248
+ if ("selectionRange" in result[0]) {
1249
+ // Hierarchical
1250
+ const lines = (result as DocumentSymbol[]).flatMap((s) => formatDocumentSymbol(s));
1251
+ output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
1057
1252
  } else {
1058
- const relPath = path.relative(session.cwd, targetFile);
1059
- // Check if hierarchical (DocumentSymbol) or flat (SymbolInformation)
1060
- if ("selectionRange" in result[0]) {
1061
- // Hierarchical
1062
- const lines = (result as DocumentSymbol[]).flatMap((s) => formatDocumentSymbol(s));
1063
- output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
1064
- } else {
1065
- // Flat
1066
- const lines = (result as SymbolInformation[]).map((s) => {
1067
- const line = s.location.range.start.line + 1;
1068
- const icon = symbolKindToIcon(s.kind);
1069
- return `${icon} ${s.name} @ line ${line}`;
1070
- });
1071
- output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
1072
- }
1253
+ // Flat
1254
+ const lines = (result as SymbolInformation[]).map((s) => {
1255
+ const line = s.location.range.start.line + 1;
1256
+ const icon = symbolKindToIcon(s.kind);
1257
+ return `${icon} ${s.name} @ line ${line}`;
1258
+ });
1259
+ output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
1073
1260
  }
1074
- break;
1075
1261
  }
1262
+ break;
1263
+ }
1076
1264
 
1077
- case "workspace_symbols": {
1078
- if (!query) {
1079
- return {
1080
- content: [{ type: "text", text: "Error: query parameter required for workspace_symbols" }],
1081
- details: { action, serverName, success: false },
1082
- };
1083
- }
1265
+ case "workspace_symbols": {
1266
+ if (!query) {
1267
+ return {
1268
+ content: [{ type: "text", text: "Error: query parameter required for workspace_symbols" }],
1269
+ details: { action, serverName, success: false },
1270
+ };
1271
+ }
1084
1272
 
1085
- const result = (await sendRequest(client, "workspace/symbol", { query })) as
1086
- | SymbolInformation[]
1087
- | null;
1273
+ const result = (await sendRequest(client, "workspace/symbol", { query })) as SymbolInformation[] | null;
1088
1274
 
1089
- if (!result || result.length === 0) {
1090
- output = `No symbols matching "${query}"`;
1091
- } else {
1092
- const lines = result.map((s) => formatSymbolInformation(s, session.cwd));
1093
- output = `Found ${result.length} symbol(s) matching "${query}":\n${lines.map((l) => ` ${l}`).join("\n")}`;
1094
- }
1095
- break;
1275
+ if (!result || result.length === 0) {
1276
+ output = `No symbols matching "${query}"`;
1277
+ } else {
1278
+ const lines = result.map((s) => formatSymbolInformation(s, this.session.cwd));
1279
+ output = `Found ${result.length} symbol(s) matching "${query}":\n${lines.map((l) => ` ${l}`).join("\n")}`;
1096
1280
  }
1281
+ break;
1282
+ }
1097
1283
 
1098
- case "rename": {
1099
- if (!new_name) {
1100
- return {
1101
- content: [{ type: "text", text: "Error: new_name parameter required for rename" }],
1102
- details: { action, serverName, success: false },
1103
- };
1104
- }
1284
+ case "rename": {
1285
+ if (!new_name) {
1286
+ return {
1287
+ content: [{ type: "text", text: "Error: new_name parameter required for rename" }],
1288
+ details: { action, serverName, success: false },
1289
+ };
1290
+ }
1105
1291
 
1106
- const result = (await sendRequest(client, "textDocument/rename", {
1107
- textDocument: { uri },
1108
- position,
1109
- newName: new_name,
1110
- })) as WorkspaceEdit | null;
1292
+ const result = (await sendRequest(client, "textDocument/rename", {
1293
+ textDocument: { uri },
1294
+ position,
1295
+ newName: new_name,
1296
+ })) as WorkspaceEdit | null;
1111
1297
 
1112
- if (!result) {
1113
- output = "Rename returned no edits";
1298
+ if (!result) {
1299
+ output = "Rename returned no edits";
1300
+ } else {
1301
+ const shouldApply = apply !== false;
1302
+ if (shouldApply) {
1303
+ const applied = await applyWorkspaceEdit(result, this.session.cwd);
1304
+ output = `Applied rename:\n${applied.map((a) => ` ${a}`).join("\n")}`;
1114
1305
  } else {
1115
- const shouldApply = apply !== false;
1116
- if (shouldApply) {
1117
- const applied = await applyWorkspaceEdit(result, session.cwd);
1118
- output = `Applied rename:\n${applied.map((a) => ` ${a}`).join("\n")}`;
1119
- } else {
1120
- const preview = formatWorkspaceEdit(result, session.cwd);
1121
- output = `Rename preview:\n${preview.map((p) => ` ${p}`).join("\n")}`;
1122
- }
1306
+ const preview = formatWorkspaceEdit(result, this.session.cwd);
1307
+ output = `Rename preview:\n${preview.map((p) => ` ${p}`).join("\n")}`;
1123
1308
  }
1124
- break;
1309
+ }
1310
+ break;
1311
+ }
1312
+
1313
+ case "actions": {
1314
+ if (!targetFile) {
1315
+ return {
1316
+ content: [{ type: "text", text: "Error: file parameter required for actions" }],
1317
+ details: { action, serverName, success: false },
1318
+ };
1319
+ }
1320
+
1321
+ const actionsMinVersion = client.diagnosticsVersion;
1322
+ await refreshFile(client, targetFile);
1323
+ const diagnostics = await waitForDiagnostics(client, uri, 3000, undefined, actionsMinVersion);
1324
+ const endLine = (end_line ?? line ?? 1) - 1;
1325
+ const endCharacter = (end_character ?? column ?? 1) - 1;
1326
+ const range = { start: position, end: { line: endLine, character: endCharacter } };
1327
+ const relevantDiagnostics = diagnostics.filter(
1328
+ (d) => d.range.start.line <= range.end.line && d.range.end.line >= range.start.line,
1329
+ );
1330
+
1331
+ const codeActionContext: { diagnostics: Diagnostic[]; only?: string[] } = {
1332
+ diagnostics: relevantDiagnostics,
1333
+ };
1334
+ if (kind) {
1335
+ codeActionContext.only = [kind];
1125
1336
  }
1126
1337
 
1127
- case "actions": {
1128
- if (!targetFile) {
1338
+ const result = (await sendRequest(client, "textDocument/codeAction", {
1339
+ textDocument: { uri },
1340
+ range,
1341
+ context: codeActionContext,
1342
+ })) as Array<CodeAction | Command> | null;
1343
+
1344
+ if (!result || result.length === 0) {
1345
+ output = "No code actions available";
1346
+ } else if (action_index !== undefined) {
1347
+ // Apply specific action
1348
+ if (action_index < 0 || action_index >= result.length) {
1129
1349
  return {
1130
- content: [{ type: "text", text: "Error: file parameter required for actions" }],
1350
+ content: [
1351
+ {
1352
+ type: "text",
1353
+ text: `Error: action_index ${action_index} out of range (0-${result.length - 1})`,
1354
+ },
1355
+ ],
1131
1356
  details: { action, serverName, success: false },
1132
1357
  };
1133
1358
  }
1134
1359
 
1135
- const actionsMinVersion = client.diagnosticsVersion;
1136
- await refreshFile(client, targetFile);
1137
- const diagnostics = await waitForDiagnostics(client, uri, 3000, undefined, actionsMinVersion);
1138
- const endLine = (end_line ?? line ?? 1) - 1;
1139
- const endCharacter = (end_character ?? column ?? 1) - 1;
1140
- const range = { start: position, end: { line: endLine, character: endCharacter } };
1141
- const relevantDiagnostics = diagnostics.filter(
1142
- (d) => d.range.start.line <= range.end.line && d.range.end.line >= range.start.line,
1143
- );
1144
-
1145
- const codeActionContext: { diagnostics: Diagnostic[]; only?: string[] } = {
1146
- diagnostics: relevantDiagnostics,
1147
- };
1148
- if (kind) {
1149
- codeActionContext.only = [kind];
1150
- }
1151
-
1152
- const result = (await sendRequest(client, "textDocument/codeAction", {
1153
- textDocument: { uri },
1154
- range,
1155
- context: codeActionContext,
1156
- })) as Array<CodeAction | Command> | null;
1157
-
1158
- if (!result || result.length === 0) {
1159
- output = "No code actions available";
1160
- } else if (action_index !== undefined) {
1161
- // Apply specific action
1162
- if (action_index < 0 || action_index >= result.length) {
1163
- return {
1164
- content: [
1165
- {
1166
- type: "text",
1167
- text: `Error: action_index ${action_index} out of range (0-${result.length - 1})`,
1168
- },
1169
- ],
1170
- details: { action, serverName, success: false },
1171
- };
1360
+ const isCommand = (candidate: CodeAction | Command): candidate is Command =>
1361
+ typeof (candidate as Command).command === "string";
1362
+ const isCodeAction = (candidate: CodeAction | Command): candidate is CodeAction =>
1363
+ !isCommand(candidate);
1364
+ const getCommandPayload = (
1365
+ candidate: CodeAction | Command,
1366
+ ): { command: string; arguments?: unknown[] } | null => {
1367
+ if (isCommand(candidate)) {
1368
+ return { command: candidate.command, arguments: candidate.arguments };
1172
1369
  }
1173
-
1174
- const isCommand = (candidate: CodeAction | Command): candidate is Command =>
1175
- typeof (candidate as Command).command === "string";
1176
- const isCodeAction = (candidate: CodeAction | Command): candidate is CodeAction =>
1177
- !isCommand(candidate);
1178
- const getCommandPayload = (
1179
- candidate: CodeAction | Command,
1180
- ): { command: string; arguments?: unknown[] } | null => {
1181
- if (isCommand(candidate)) {
1182
- return { command: candidate.command, arguments: candidate.arguments };
1183
- }
1184
- if (candidate.command) {
1185
- return { command: candidate.command.command, arguments: candidate.command.arguments };
1186
- }
1187
- return null;
1188
- };
1189
-
1190
- const codeAction = result[action_index];
1191
-
1192
- // Resolve if needed
1193
- let resolvedAction = codeAction;
1194
- if (
1195
- isCodeAction(codeAction) &&
1196
- !codeAction.edit &&
1197
- codeAction.data &&
1198
- client.serverCapabilities?.codeActionProvider
1199
- ) {
1200
- const provider = client.serverCapabilities.codeActionProvider;
1201
- if (typeof provider === "object" && provider.resolveProvider) {
1202
- resolvedAction = (await sendRequest(client, "codeAction/resolve", codeAction)) as CodeAction;
1203
- }
1370
+ if (candidate.command) {
1371
+ return { command: candidate.command.command, arguments: candidate.command.arguments };
1204
1372
  }
1373
+ return null;
1374
+ };
1205
1375
 
1206
- if (isCodeAction(resolvedAction) && resolvedAction.edit) {
1207
- const applied = await applyWorkspaceEdit(resolvedAction.edit, session.cwd);
1208
- output = `Applied "${codeAction.title}":\n${applied.map((a) => ` ${a}`).join("\n")}`;
1209
- } else {
1210
- const commandPayload = getCommandPayload(resolvedAction);
1211
- if (commandPayload) {
1212
- await sendRequest(client, "workspace/executeCommand", commandPayload);
1213
- output = `Executed "${codeAction.title}"`;
1214
- } else {
1215
- output = `Code action "${codeAction.title}" has no edits or command to apply`;
1216
- }
1376
+ const codeAction = result[action_index];
1377
+
1378
+ // Resolve if needed
1379
+ let resolvedAction = codeAction;
1380
+ if (
1381
+ isCodeAction(codeAction) &&
1382
+ !codeAction.edit &&
1383
+ codeAction.data &&
1384
+ client.serverCapabilities?.codeActionProvider
1385
+ ) {
1386
+ const provider = client.serverCapabilities.codeActionProvider;
1387
+ if (typeof provider === "object" && provider.resolveProvider) {
1388
+ resolvedAction = (await sendRequest(client, "codeAction/resolve", codeAction)) as CodeAction;
1217
1389
  }
1218
- } else {
1219
- // List available actions
1220
- const lines = result.map((actionItem, i) => {
1221
- if ("kind" in actionItem || "isPreferred" in actionItem || "edit" in actionItem) {
1222
- const actionDetails = actionItem as CodeAction;
1223
- const preferred = actionDetails.isPreferred ? " (preferred)" : "";
1224
- const kindInfo = actionDetails.kind ? ` [${actionDetails.kind}]` : "";
1225
- return ` [${i}] ${actionDetails.title}${kindInfo}${preferred}`;
1226
- }
1227
- return ` [${i}] ${actionItem.title}`;
1228
- });
1229
- output = `Available code actions:\n${lines.join("\n")}\n\nUse action_index parameter to apply a specific action.`;
1230
1390
  }
1231
- break;
1232
- }
1233
1391
 
1234
- case "incoming_calls":
1235
- case "outgoing_calls": {
1236
- // First, prepare the call hierarchy item at the cursor position
1237
- const prepareResult = (await sendRequest(client, "textDocument/prepareCallHierarchy", {
1238
- textDocument: { uri },
1239
- position,
1240
- })) as CallHierarchyItem[] | null;
1241
-
1242
- if (!prepareResult || prepareResult.length === 0) {
1243
- output = "No callable symbol found at this position";
1244
- break;
1245
- }
1246
-
1247
- const item = prepareResult[0];
1248
-
1249
- if (action === "incoming_calls") {
1250
- const calls = (await sendRequest(client, "callHierarchy/incomingCalls", { item })) as
1251
- | CallHierarchyIncomingCall[]
1252
- | null;
1253
-
1254
- if (!calls || calls.length === 0) {
1255
- output = `No callers found for "${item.name}"`;
1256
- } else {
1257
- const lines = calls.map((call) => {
1258
- const loc = { uri: call.from.uri, range: call.from.selectionRange };
1259
- const detail = call.from.detail ? ` (${call.from.detail})` : "";
1260
- return ` ${call.from.name}${detail} @ ${formatLocation(loc, session.cwd)}`;
1261
- });
1262
- output = `Found ${calls.length} caller(s) of "${item.name}":\n${lines.join("\n")}`;
1263
- }
1392
+ if (isCodeAction(resolvedAction) && resolvedAction.edit) {
1393
+ const applied = await applyWorkspaceEdit(resolvedAction.edit, this.session.cwd);
1394
+ output = `Applied "${codeAction.title}":\n${applied.map((a) => ` ${a}`).join("\n")}`;
1264
1395
  } else {
1265
- const calls = (await sendRequest(client, "callHierarchy/outgoingCalls", { item })) as
1266
- | CallHierarchyOutgoingCall[]
1267
- | null;
1268
-
1269
- if (!calls || calls.length === 0) {
1270
- output = `"${item.name}" doesn't call any functions`;
1396
+ const commandPayload = getCommandPayload(resolvedAction);
1397
+ if (commandPayload) {
1398
+ await sendRequest(client, "workspace/executeCommand", commandPayload);
1399
+ output = `Executed "${codeAction.title}"`;
1271
1400
  } else {
1272
- const lines = calls.map((call) => {
1273
- const loc = { uri: call.to.uri, range: call.to.selectionRange };
1274
- const detail = call.to.detail ? ` (${call.to.detail})` : "";
1275
- return ` ${call.to.name}${detail} @ ${formatLocation(loc, session.cwd)}`;
1276
- });
1277
- output = `"${item.name}" calls ${calls.length} function(s):\n${lines.join("\n")}`;
1401
+ output = `Code action "${codeAction.title}" has no edits or command to apply`;
1278
1402
  }
1279
1403
  }
1404
+ } else {
1405
+ // List available actions
1406
+ const lines = result.map((actionItem, i) => {
1407
+ if ("kind" in actionItem || "isPreferred" in actionItem || "edit" in actionItem) {
1408
+ const actionDetails = actionItem as CodeAction;
1409
+ const preferred = actionDetails.isPreferred ? " (preferred)" : "";
1410
+ const kindInfo = actionDetails.kind ? ` [${actionDetails.kind}]` : "";
1411
+ return ` [${i}] ${actionDetails.title}${kindInfo}${preferred}`;
1412
+ }
1413
+ return ` [${i}] ${actionItem.title}`;
1414
+ });
1415
+ output = `Available code actions:\n${lines.join("\n")}\n\nUse action_index parameter to apply a specific action.`;
1416
+ }
1417
+ break;
1418
+ }
1419
+
1420
+ case "incoming_calls":
1421
+ case "outgoing_calls": {
1422
+ // First, prepare the call hierarchy item at the cursor position
1423
+ const prepareResult = (await sendRequest(client, "textDocument/prepareCallHierarchy", {
1424
+ textDocument: { uri },
1425
+ position,
1426
+ })) as CallHierarchyItem[] | null;
1427
+
1428
+ if (!prepareResult || prepareResult.length === 0) {
1429
+ output = "No callable symbol found at this position";
1280
1430
  break;
1281
1431
  }
1282
1432
 
1283
- // =====================================================================
1284
- // Rust-Analyzer Specific Operations
1285
- // =====================================================================
1433
+ const item = prepareResult[0];
1286
1434
 
1287
- case "flycheck": {
1288
- if (!hasCapability(serverConfig, "flycheck")) {
1289
- return {
1290
- content: [{ type: "text", text: "Error: flycheck requires rust-analyzer" }],
1291
- details: { action, serverName, success: false },
1292
- };
1293
- }
1435
+ if (action === "incoming_calls") {
1436
+ const calls = (await sendRequest(client, "callHierarchy/incomingCalls", { item })) as
1437
+ | CallHierarchyIncomingCall[]
1438
+ | null;
1294
1439
 
1295
- await rustAnalyzer.flycheck(client, resolvedFile ?? undefined);
1296
- const collected: Array<{ filePath: string; diagnostic: Diagnostic }> = [];
1297
- for (const [diagUri, diags] of client.diagnostics.entries()) {
1298
- const relPath = path.relative(session.cwd, uriToFile(diagUri));
1299
- for (const diag of diags) {
1300
- collected.push({ filePath: relPath, diagnostic: diag });
1301
- }
1440
+ if (!calls || calls.length === 0) {
1441
+ output = `No callers found for "${item.name}"`;
1442
+ } else {
1443
+ const lines = calls.map((call) => {
1444
+ const loc = { uri: call.from.uri, range: call.from.selectionRange };
1445
+ const detail = call.from.detail ? ` (${call.from.detail})` : "";
1446
+ return ` ${call.from.name}${detail} @ ${formatLocation(loc, this.session.cwd)}`;
1447
+ });
1448
+ output = `Found ${calls.length} caller(s) of "${item.name}":\n${lines.join("\n")}`;
1302
1449
  }
1450
+ } else {
1451
+ const calls = (await sendRequest(client, "callHierarchy/outgoingCalls", { item })) as
1452
+ | CallHierarchyOutgoingCall[]
1453
+ | null;
1303
1454
 
1304
- if (collected.length === 0) {
1305
- output = "Flycheck: no issues found";
1455
+ if (!calls || calls.length === 0) {
1456
+ output = `"${item.name}" doesn't call any functions`;
1306
1457
  } else {
1307
- const summary = formatDiagnosticsSummary(collected.map((d) => d.diagnostic));
1308
- const formatted = collected.slice(0, 20).map((d) => formatDiagnostic(d.diagnostic, d.filePath));
1309
- const more = collected.length > 20 ? `\n ... and ${collected.length - 20} more` : "";
1310
- output = `Flycheck ${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}${more}`;
1458
+ const lines = calls.map((call) => {
1459
+ const loc = { uri: call.to.uri, range: call.to.selectionRange };
1460
+ const detail = call.to.detail ? ` (${call.to.detail})` : "";
1461
+ return ` ${call.to.name}${detail} @ ${formatLocation(loc, this.session.cwd)}`;
1462
+ });
1463
+ output = `"${item.name}" calls ${calls.length} function(s):\n${lines.join("\n")}`;
1311
1464
  }
1312
- break;
1313
1465
  }
1466
+ break;
1467
+ }
1314
1468
 
1315
- case "expand_macro": {
1316
- if (!hasCapability(serverConfig, "expandMacro")) {
1317
- return {
1318
- content: [{ type: "text", text: "Error: expand_macro requires rust-analyzer" }],
1319
- details: { action, serverName, success: false },
1320
- };
1321
- }
1469
+ // =====================================================================
1470
+ // Rust-Analyzer Specific Operations
1471
+ // =====================================================================
1322
1472
 
1323
- if (!targetFile) {
1324
- return {
1325
- content: [{ type: "text", text: "Error: file parameter required for expand_macro" }],
1326
- details: { action, serverName, success: false },
1327
- };
1328
- }
1473
+ case "flycheck": {
1474
+ if (!hasCapability(serverConfig, "flycheck")) {
1475
+ return {
1476
+ content: [{ type: "text", text: "Error: flycheck requires rust-analyzer" }],
1477
+ details: { action, serverName, success: false },
1478
+ };
1479
+ }
1329
1480
 
1330
- const result = await rustAnalyzer.expandMacro(client, targetFile, line || 1, column || 1);
1331
- if (!result) {
1332
- output = "No macro expansion at this position";
1333
- } else {
1334
- output = `Macro: ${result.name}\n\nExpansion:\n${result.expansion}`;
1481
+ await rustAnalyzer.flycheck(client, resolvedFile ?? undefined);
1482
+ const collected: Array<{ filePath: string; diagnostic: Diagnostic }> = [];
1483
+ for (const [diagUri, diags] of client.diagnostics.entries()) {
1484
+ const relPath = path.relative(this.session.cwd, uriToFile(diagUri));
1485
+ for (const diag of diags) {
1486
+ collected.push({ filePath: relPath, diagnostic: diag });
1335
1487
  }
1336
- break;
1337
1488
  }
1338
1489
 
1339
- case "ssr": {
1340
- if (!hasCapability(serverConfig, "ssr")) {
1341
- return {
1342
- content: [{ type: "text", text: "Error: ssr requires rust-analyzer" }],
1343
- details: { action, serverName, success: false },
1344
- };
1345
- }
1490
+ if (collected.length === 0) {
1491
+ output = "Flycheck: no issues found";
1492
+ } else {
1493
+ const summary = formatDiagnosticsSummary(collected.map((d) => d.diagnostic));
1494
+ const formatted = collected.slice(0, 20).map((d) => formatDiagnostic(d.diagnostic, d.filePath));
1495
+ const more = collected.length > 20 ? `\n ... and ${collected.length - 20} more` : "";
1496
+ output = `Flycheck ${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}${more}`;
1497
+ }
1498
+ break;
1499
+ }
1346
1500
 
1347
- if (!query) {
1348
- return {
1349
- content: [{ type: "text", text: "Error: query parameter (pattern) required for ssr" }],
1350
- details: { action, serverName, success: false },
1351
- };
1352
- }
1501
+ case "expand_macro": {
1502
+ if (!hasCapability(serverConfig, "expandMacro")) {
1503
+ return {
1504
+ content: [{ type: "text", text: "Error: expand_macro requires rust-analyzer" }],
1505
+ details: { action, serverName, success: false },
1506
+ };
1507
+ }
1353
1508
 
1354
- if (!replacement) {
1355
- return {
1356
- content: [{ type: "text", text: "Error: replacement parameter required for ssr" }],
1357
- details: { action, serverName, success: false },
1358
- };
1359
- }
1509
+ if (!targetFile) {
1510
+ return {
1511
+ content: [{ type: "text", text: "Error: file parameter required for expand_macro" }],
1512
+ details: { action, serverName, success: false },
1513
+ };
1514
+ }
1360
1515
 
1361
- const shouldApply = apply === true;
1362
- const result = await rustAnalyzer.ssr(client, query, replacement, !shouldApply);
1516
+ const result = await rustAnalyzer.expandMacro(client, targetFile, line || 1, column || 1);
1517
+ if (!result) {
1518
+ output = "No macro expansion at this position";
1519
+ } else {
1520
+ output = `Macro: ${result.name}\n\nExpansion:\n${result.expansion}`;
1521
+ }
1522
+ break;
1523
+ }
1363
1524
 
1364
- if (shouldApply) {
1365
- const applied = await applyWorkspaceEdit(result, session.cwd);
1366
- output =
1367
- applied.length > 0
1368
- ? `Applied SSR:\n${applied.map((a) => ` ${a}`).join("\n")}`
1369
- : "SSR: no matches found";
1370
- } else {
1371
- const preview = formatWorkspaceEdit(result, session.cwd);
1372
- output =
1373
- preview.length > 0
1374
- ? `SSR preview:\n${preview.map((p) => ` ${p}`).join("\n")}`
1375
- : "SSR: no matches found";
1376
- }
1377
- break;
1525
+ case "ssr": {
1526
+ if (!hasCapability(serverConfig, "ssr")) {
1527
+ return {
1528
+ content: [{ type: "text", text: "Error: ssr requires rust-analyzer" }],
1529
+ details: { action, serverName, success: false },
1530
+ };
1378
1531
  }
1379
1532
 
1380
- case "runnables": {
1381
- if (!hasCapability(serverConfig, "runnables")) {
1382
- return {
1383
- content: [{ type: "text", text: "Error: runnables requires rust-analyzer" }],
1384
- details: { action, serverName, success: false },
1385
- };
1386
- }
1533
+ if (!query) {
1534
+ return {
1535
+ content: [{ type: "text", text: "Error: query parameter (pattern) required for ssr" }],
1536
+ details: { action, serverName, success: false },
1537
+ };
1538
+ }
1387
1539
 
1388
- if (!targetFile) {
1389
- return {
1390
- content: [{ type: "text", text: "Error: file parameter required for runnables" }],
1391
- details: { action, serverName, success: false },
1392
- };
1393
- }
1540
+ if (!replacement) {
1541
+ return {
1542
+ content: [{ type: "text", text: "Error: replacement parameter required for ssr" }],
1543
+ details: { action, serverName, success: false },
1544
+ };
1545
+ }
1394
1546
 
1395
- const result = await rustAnalyzer.runnables(client, targetFile, line);
1396
- if (result.length === 0) {
1397
- output = "No runnables found";
1398
- } else {
1399
- const lines = result.map((r) => {
1400
- const args = r.args?.cargoArgs?.join(" ") || "";
1401
- return ` [${r.kind}] ${r.label}${args ? ` (cargo ${args})` : ""}`;
1402
- });
1403
- output = `Found ${result.length} runnable(s):\n${lines.join("\n")}`;
1404
- }
1405
- break;
1547
+ const shouldApply = apply === true;
1548
+ const result = await rustAnalyzer.ssr(client, query, replacement, !shouldApply);
1549
+
1550
+ if (shouldApply) {
1551
+ const applied = await applyWorkspaceEdit(result, this.session.cwd);
1552
+ output =
1553
+ applied.length > 0
1554
+ ? `Applied SSR:\n${applied.map((a) => ` ${a}`).join("\n")}`
1555
+ : "SSR: no matches found";
1556
+ } else {
1557
+ const preview = formatWorkspaceEdit(result, this.session.cwd);
1558
+ output =
1559
+ preview.length > 0
1560
+ ? `SSR preview:\n${preview.map((p) => ` ${p}`).join("\n")}`
1561
+ : "SSR: no matches found";
1406
1562
  }
1563
+ break;
1564
+ }
1407
1565
 
1408
- case "related_tests": {
1409
- if (!hasCapability(serverConfig, "relatedTests")) {
1410
- return {
1411
- content: [{ type: "text", text: "Error: related_tests requires rust-analyzer" }],
1412
- details: { action, serverName, success: false },
1413
- };
1414
- }
1566
+ case "runnables": {
1567
+ if (!hasCapability(serverConfig, "runnables")) {
1568
+ return {
1569
+ content: [{ type: "text", text: "Error: runnables requires rust-analyzer" }],
1570
+ details: { action, serverName, success: false },
1571
+ };
1572
+ }
1415
1573
 
1416
- if (!targetFile) {
1417
- return {
1418
- content: [{ type: "text", text: "Error: file parameter required for related_tests" }],
1419
- details: { action, serverName, success: false },
1420
- };
1421
- }
1574
+ if (!targetFile) {
1575
+ return {
1576
+ content: [{ type: "text", text: "Error: file parameter required for runnables" }],
1577
+ details: { action, serverName, success: false },
1578
+ };
1579
+ }
1422
1580
 
1423
- const result = await rustAnalyzer.relatedTests(client, targetFile, line || 1, column || 1);
1424
- if (result.length === 0) {
1425
- output = "No related tests found";
1426
- } else {
1427
- output = `Found ${result.length} related test(s):\n${result.map((t) => ` ${t}`).join("\n")}`;
1428
- }
1429
- break;
1581
+ const result = await rustAnalyzer.runnables(client, targetFile, line);
1582
+ if (result.length === 0) {
1583
+ output = "No runnables found";
1584
+ } else {
1585
+ const lines = result.map((r) => {
1586
+ const args = r.args?.cargoArgs?.join(" ") || "";
1587
+ return ` [${r.kind}] ${r.label}${args ? ` (cargo ${args})` : ""}`;
1588
+ });
1589
+ output = `Found ${result.length} runnable(s):\n${lines.join("\n")}`;
1430
1590
  }
1591
+ break;
1592
+ }
1431
1593
 
1432
- case "reload_workspace": {
1433
- await rustAnalyzer.reloadWorkspace(client);
1434
- output = "Workspace reloaded successfully";
1435
- break;
1594
+ case "related_tests": {
1595
+ if (!hasCapability(serverConfig, "relatedTests")) {
1596
+ return {
1597
+ content: [{ type: "text", text: "Error: related_tests requires rust-analyzer" }],
1598
+ details: { action, serverName, success: false },
1599
+ };
1600
+ }
1601
+
1602
+ if (!targetFile) {
1603
+ return {
1604
+ content: [{ type: "text", text: "Error: file parameter required for related_tests" }],
1605
+ details: { action, serverName, success: false },
1606
+ };
1436
1607
  }
1437
1608
 
1438
- default:
1439
- output = `Unknown action: ${action}`;
1609
+ const result = await rustAnalyzer.relatedTests(client, targetFile, line || 1, column || 1);
1610
+ if (result.length === 0) {
1611
+ output = "No related tests found";
1612
+ } else {
1613
+ output = `Found ${result.length} related test(s):\n${result.map((t) => ` ${t}`).join("\n")}`;
1614
+ }
1615
+ break;
1440
1616
  }
1441
1617
 
1442
- return {
1443
- content: [{ type: "text", text: output }],
1444
- details: { serverName, action, success: true },
1445
- };
1446
- } catch (err) {
1447
- const errorMessage = err instanceof Error ? err.message : String(err);
1448
- return {
1449
- content: [{ type: "text", text: `LSP error: ${errorMessage}` }],
1450
- details: { serverName, action, success: false },
1451
- };
1618
+ case "reload_workspace": {
1619
+ await rustAnalyzer.reloadWorkspace(client);
1620
+ output = "Workspace reloaded successfully";
1621
+ break;
1622
+ }
1623
+
1624
+ default:
1625
+ output = `Unknown action: ${action}`;
1452
1626
  }
1453
- },
1454
- };
1627
+
1628
+ return {
1629
+ content: [{ type: "text", text: output }],
1630
+ details: { serverName, action, success: true },
1631
+ };
1632
+ } catch (err) {
1633
+ const errorMessage = err instanceof Error ? err.message : String(err);
1634
+ return {
1635
+ content: [{ type: "text", text: `LSP error: ${errorMessage}` }],
1636
+ details: { serverName, action, success: false },
1637
+ };
1638
+ }
1639
+ }
1455
1640
  }