diff --git a/LSP-copilot.sublime-commands b/LSP-copilot.sublime-commands index 469b13c..241c856 100644 --- a/LSP-copilot.sublime-commands +++ b/LSP-copilot.sublime-commands @@ -7,4 +7,12 @@ "default": "// Settings in here override those in \"LSP-copilot/LSP-copilot.sublime-settings\"\n\n{\n\t$0\n}\n", }, }, + { + "caption": "Copilot: Inline Completion with Prompt", + "command": "copilot_inline_completion_prompt", + }, + { + "caption": "Copilot: Inline Completion", + "command": "copilot_inline_completion", + }, ] diff --git a/LSP-copilot.sublime-settings b/LSP-copilot.sublime-settings index 20e0846..23f195c 100644 --- a/LSP-copilot.sublime-settings +++ b/LSP-copilot.sublime-settings @@ -9,6 +9,7 @@ "buffer", "res" ], + "initializationOptions": {}, "settings": { "auto_ask_completions": true, "commit_completion_on_tab": true, diff --git a/Main.sublime-commands b/Main.sublime-commands index e993190..d07446f 100644 --- a/Main.sublime-commands +++ b/Main.sublime-commands @@ -3,6 +3,22 @@ "caption": "Copilot: Chat", "command": "copilot_conversation_chat" }, + { + "caption": "CopilotSelectInlineCompletionCommand", + "command": "copilot_select_inline_completion" + }, + { + "caption": "Copilot: Code Review", + "command": "copilot_code_review" + }, + { + "caption": "Copilot: Generate Commit Message", + "command": "copilot_git_commit_generate" + }, + { + "caption": "Copilot: Edit Conversation", + "command": "copilot_edit_conversation_create" + }, { "caption": "Copilot: Explain", "command": "copilot_conversation_chat", @@ -28,6 +44,10 @@ "caption": "Copilot: Get Prompt", "command": "copilot_get_prompt", }, + { + "caption": "Copilot: Models", + "command": "copilot_models", + }, { "caption": "Copilot: Fix This", "command": "copilot_conversation_chat", @@ -70,6 +90,11 @@ "caption": "Copilot: Conversation Templates", "command": "copilot_conversation_templates", }, + { + // Debug Command + "caption": "Copilot: Register Conversation Tools", + "command": "copilot_register_conversation_tools", + }, { "caption": "Copilot: Check Status", "command": "copilot_check_status" diff --git a/README.md b/README.md index 4b2a468..e64b495 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@  -GitHub Copilot support for Sublime Text LSP plugin provided through [Copilot.vim][]. +GitHub Copilot support for Sublime Text LSP plugin provided through [@github/copilot-language-server][]. This plugin uses [Copilot][] distribution which uses OpenAI Codex to suggest codes and entire functions in real-time right from your editor. @@ -110,7 +110,7 @@ In LSP-copilot's plugin settings, add the following `env` key: } ``` +[@github/copilot-language-server]: https://www.npmjs.com/package/@github/copilot-language-server [Copilot]: https://github.com/features/copilot -[Copilot.vim]: https://github.com/github/copilot.vim -[LSP]: https://packagecontrol.io/packages/LSP [LSP-copilot]: https://packagecontrol.io/packages/LSP-copilot +[LSP]: https://packagecontrol.io/packages/LSP diff --git a/boot.py b/boot.py index 0e522d0..c01a8f4 100644 --- a/boot.py +++ b/boot.py @@ -10,6 +10,7 @@ def reload_plugin() -> None: del sys.modules[module_name] + reload_plugin() from .plugin import * # noqa: E402, F403 diff --git a/language-server/package-lock.json b/language-server/package-lock.json index 3f2e666..072cca3 100644 --- a/language-server/package-lock.json +++ b/language-server/package-lock.json @@ -1,27 +1,76 @@ { - "name": "language-server", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "dependencies": { - "copilot-node-server": "^1.41.0" - } - }, - "node_modules/copilot-node-server": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/copilot-node-server/-/copilot-node-server-1.41.0.tgz", - "integrity": "sha512-r2+uaWa05wvxNALv8rLegRCOlcopUDLYOd8kAHTAM8xpqBNK5TcMqFbGufxKF7YIWpBwcyfNaAIb724Un5e1eA==", - "bin": { - "copilot-node-server": "copilot/dist/language-server.js" - } - } + "name": "language-server", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "version": "0.0.0", + "dependencies": { + "@github/copilot-language-server": "^1.326.0" + } }, - "dependencies": { - "copilot-node-server": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/copilot-node-server/-/copilot-node-server-1.41.0.tgz", - "integrity": "sha512-r2+uaWa05wvxNALv8rLegRCOlcopUDLYOd8kAHTAM8xpqBNK5TcMqFbGufxKF7YIWpBwcyfNaAIb724Un5e1eA==" - } + "node_modules/@github/copilot-language-server": { + "version": "1.326.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.326.0.tgz", + "integrity": "sha512-ax+6pgboEjxgoZod2UcHxL5m995wUH3TRZU5MqUJBeOoHBtOsyDpO6R6dLknyFA6Q5ybE8pDSUlH5/+4M1PznQ==", + "dependencies": { + "vscode-languageserver-protocol": "^3.17.5" + }, + "bin": { + "copilot-language-server": "dist/language-server.js" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" + } + }, + "dependencies": { + "@github/copilot-language-server": { + "version": "1.326.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.326.0.tgz", + "integrity": "sha512-ax+6pgboEjxgoZod2UcHxL5m995wUH3TRZU5MqUJBeOoHBtOsyDpO6R6dLknyFA6Q5ybE8pDSUlH5/+4M1PznQ==", + "requires": { + "vscode-languageserver-protocol": "^3.17.5" + } + }, + "vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==" + }, + "vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "requires": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" } + } } diff --git a/language-server/package.json b/language-server/package.json index 75ecc76..854080b 100644 --- a/language-server/package.json +++ b/language-server/package.json @@ -1,6 +1,7 @@ { - "private": true, - "dependencies": { - "copilot-node-server": "^1.41.0" - } + "private": true, + "dependencies": { + "@github/copilot-language-server": "^1.326.0" + }, + "version": "0.0.0" } diff --git a/plugin/__init__.py b/plugin/__init__.py index e6c8657..4c8832f 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -9,6 +9,7 @@ CopilotCheckFileStatusCommand, CopilotCheckStatusCommand, CopilotClosePanelCompletionCommand, + CopilotCodeReviewCommand, CopilotConversationAgentsCommand, CopilotConversationChatCommand, CopilotConversationChatShimCommand, @@ -17,6 +18,10 @@ CopilotConversationDebugCommand, CopilotConversationDestroyCommand, CopilotConversationDestroyShimCommand, + CopilotGitCommitGenerateCommand, + CopilotInlineCompletionPromptCommand, + CopilotInlineCompletionCommand, + CopilotSelectInlineCompletionCommand, CopilotConversationInsertCodeCommand, CopilotConversationInsertCodeShimCommand, CopilotConversationRatingCommand, @@ -25,14 +30,19 @@ CopilotConversationToggleReferencesBlockCommand, CopilotConversationTurnDeleteCommand, CopilotConversationTurnDeleteShimCommand, + CopilotEditConversationCreateCommand, + CopilotEditConversationDestroyShimCommand, + CopilotEditConversationDestroyCommand, CopilotGetPanelCompletionsCommand, CopilotGetPromptCommand, CopilotGetVersionCommand, + CopilotModelsCommand, CopilotNextCompletionCommand, CopilotPrepareAndEditSettingsCommand, CopilotPreviousCompletionCommand, CopilotRejectCompletionCommand, CopilotSendAnyRequestCommand, + CopilotSetModelPolicyCommand, CopilotSignInCommand, CopilotSignInWithGithubTokenCommand, CopilotSignOutCommand, @@ -53,7 +63,14 @@ "CopilotAskCompletionsCommand", "CopilotCheckFileStatusCommand", "CopilotCheckStatusCommand", + "CopilotCodeReviewCommand", + "CopilotInlineCompletionPromptCommand", + "CopilotEditConversationCreateCommand", + "CopilotEditConversationDestroyShimCommand", + "CopilotEditConversationDestroyCommand", "CopilotClosePanelCompletionCommand", + "CopilotInlineCompletionCommand", + "CopilotSelectInlineCompletionCommand", "CopilotConversationAgentsCommand", "CopilotConversationChatCommand", "CopilotConversationChatShimCommand", @@ -73,10 +90,13 @@ "CopilotGetPanelCompletionsCommand", "CopilotGetPromptCommand", "CopilotGetVersionCommand", + "CopilotGitCommitGenerateCommand", + "CopilotModelsCommand", "CopilotNextCompletionCommand", "CopilotPreviousCompletionCommand", "CopilotRejectCompletionCommand", "CopilotSendAnyRequestCommand", + "CopilotSetModelPolicyCommand", "CopilotSignInCommand", "CopilotSignInWithGithubTokenCommand", "CopilotSignOutCommand", diff --git a/plugin/client.py b/plugin/client.py index c72826f..cd8e85c 100644 --- a/plugin/client.py +++ b/plugin/client.py @@ -9,11 +9,15 @@ from functools import wraps from typing import Any, cast from urllib.parse import urlparse +import uuid import jmespath import sublime from LSP.plugin import ClientConfig, DottedDict, Notification, Request, Session, WorkspaceFolder from lsp_utils import ApiWrapperInterface, NpmClientHandler, notification_handler, request_handler +from LSP.plugin.core.protocol import Point, Range +from LSP.plugin.core.sessions import SessionViewProtocol +from LSP.plugin.core.url import filename_to_uri from .constants import ( NTFY_FEATURE_FLAGS_NOTIFICATION, @@ -28,6 +32,19 @@ REQ_GET_COMPLETIONS_CYCLING, REQ_GET_VERSION, REQ_SET_EDITOR_INFO, + REQ_CONTEXT_REGISTER_PROVIDERS, + REQ_CONVERSATION_AGENTS, + REQ_CONVERSATION_PRECONDITIONS, + REQ_CONVERSATION_TEMPLATES, + REQ_GET_PANEL_COMPLETIONS, + REQ_NOTIFY_SHOWN, + REQ_COPILOT_MODELS, + EDIT_STATUS_BEGIN, + EDIT_STATUS_END, + EDIT_STATUS_PLAN_GENERATED, + EDIT_STATUS_OVERALL_DESCRIPTION, + EDIT_STATUS_CODE_GENERATED, + EDIT_STATUS_NO_CODE_BLOCKS, ) from .helpers import ( ActivityIndicator, @@ -37,12 +54,13 @@ preprocess_completions, preprocess_panel_completions, ) -from .log import log_warning +from .log import log_warning, log_info, log_debug from .template import load_string_template from .types import ( AccountStatus, CopilotPayloadCompletions, CopilotPayloadConversationContext, + CopilotPayloadConversationEntry, CopilotPayloadFeatureFlagsNotification, CopilotPayloadGetVersion, CopilotPayloadLogMessage, @@ -52,13 +70,14 @@ NetworkProxy, T_Callable, ) -from .ui import ViewCompletionManager, ViewPanelCompletionManager, WindowConversationManager +from .ui import ViewCompletionManager, ViewPanelCompletionManager, WindowConversationManager, WindowEditConversationManager from .utils import ( all_views, all_windows, debounce, get_session_setting, status_message, + find_window_by_id, ) WindowId = int @@ -102,14 +121,14 @@ class CopilotPlugin(NpmClientHandler): server_binary_path = os.path.join( server_directory, "node_modules", - "copilot-node-server", - "copilot", + "@github", + "copilot-language-server", "dist", "language-server.js", ) server_version = "" - """The version of the [copilot.vim](https://github.com/github/copilot.vim) package.""" + """The version of the "@github/copilot-language-server" package.""" server_version_gh = "" """The version of the Github Copilot language server.""" @@ -166,6 +185,18 @@ def can_start( cls.window_attrs.setdefault(window, WindowAttr()) return None + @classmethod + def on_pre_start( + cls, + window: sublime.Window, + initiating_view: sublime.View, + workspace_folders: list[WorkspaceFolder], + configuration: ClientConfig, + ) -> str | None: + super().on_pre_start(window, initiating_view, workspace_folders, configuration) + configuration.init_options.update(cls.editor_info()) + return None + def on_ready(self, api: ApiWrapperInterface) -> None: def _on_get_version(response: CopilotPayloadGetVersion, failed: bool) -> None: self.server_version_gh = response.get("version", "") @@ -177,13 +208,31 @@ def _on_check_status(result: CopilotPayloadSignInConfirm, failed: bool) -> None: authorized=result["status"] == "OK", user=user, ) - - def _on_set_editor_info(result: str, failed: bool) -> None: - pass + + def _on_register_providers(response: Any, failed: bool) -> None: + if failed: + log_warning("Failed to register context providers") + else: + log_info("Successfully registered context providers") api.send_request(REQ_GET_VERSION, {}, _on_get_version) api.send_request(REQ_CHECK_STATUS, {}, _on_check_status) - api.send_request(REQ_SET_EDITOR_INFO, self.editor_info(), _on_set_editor_info) + + # Register context providers + api.send_request( + REQ_CONTEXT_REGISTER_PROVIDERS, + { + "providers": [ + { + "id": "sublime-text-editor", + "name": "Sublime Text Editor", + "fullName": "Sublime Text Editor Context Provider", + "description": "Provides access to the Sublime Text editor context" + } + ] + }, + _on_register_providers + ) def on_settings_changed(self, settings: DottedDict) -> None: def parse_proxy(proxy: str) -> NetworkProxy | None: @@ -286,7 +335,13 @@ def from_view(cls, view: sublime.View) -> CopilotPlugin | None: @classmethod def parse_server_version(cls) -> str: lock_file_content = sublime.load_resource(f"Packages/{PACKAGE_NAME}/language-server/package-lock.json") - return jmespath.search('dependencies."copilot-node-server".version', json.loads(lock_file_content)) or "" + return ( + jmespath.search( + 'dependencies."@github/copilot-language-server".version', + json.loads(lock_file_content), + ) + or "" + ) @classmethod def plugin_session(cls, view: sublime.View) -> tuple[None, None] | tuple[CopilotPlugin, Session | None]: @@ -325,26 +380,162 @@ def update_status_bar_text(self, extra_variables: dict[str, Any] | None = None) def on_server_notification_async(self, notification: Notification) -> None: if notification.method == "$/progress": - if ( - (token := notification.params["token"]).startswith("copilot_chat://") - and (params := notification.params["value"]) - and (window := WindowConversationManager.find_window_by_token_id(token)) - ): - wcm = WindowConversationManager(window) - if params.get("kind", None) == "end": - wcm.is_waiting = False - - if suggest_title := params.get("suggestedTitle", None): - wcm.suggested_title = suggest_title - - if params.get("reply", None): - wcm.append_conversation_entry(params) - - if followup := params.get("followUp", None): - message = followup.get("message", "") - wcm.follow_up = message + token = notification.params.get("token", "") + params = notification.params.get("value") + + if not params: + return + + if token.startswith("copilot_chat://"): + self._handle_chat_progress(token, params) + elif token.startswith("copilot_pedit://"): + self._handle_edit_progress(token, params) + + def _handle_chat_progress(self, token: str, params: Any) -> None: + """Handle progress notifications for chat conversations.""" + window = WindowConversationManager.find_window_by_token_id(token) + if not window: + return + + wcm = WindowConversationManager(window) + needs_update = False + + if params.get("kind") == "end": + wcm.is_waiting = False + needs_update = True + + if suggest_title := params.get("suggestedTitle"): + wcm.suggested_title = suggest_title + needs_update = True + + if params.get("reply"): + wcm.append_conversation_entry(params) + needs_update = True + + if followup := params.get("followUp"): + wcm.follow_up = followup.get("message", "") + needs_update = True + + if needs_update: + wcm.update() + + def _handle_edit_progress(self, token: str, params: list[dict[str, Any]]) -> None: + """Handle progress notifications for edit conversations.""" + window = WindowConversationManager.find_window_by_token_id(token) + if not window: + return + + wecm = WindowEditConversationManager(window) + needs_update = False + + for update in params: + if "editConversationId" in update: + wecm.conversation_id = update["editConversationId"] + needs_update = True + + # Handle different file generation statuses + status = update.get("fileGenerationStatus") + if status == EDIT_STATUS_BEGIN: + wecm.is_waiting = True + wecm.open() + elif status == EDIT_STATUS_END: + wecm.is_waiting = False + elif status == EDIT_STATUS_NO_CODE_BLOCKS: + self._handle_no_code_blocks_response(wecm, update) + elif status in (EDIT_STATUS_PLAN_GENERATED, EDIT_STATUS_OVERALL_DESCRIPTION): + self._handle_edit_description_response(wecm, update) + elif status == EDIT_STATUS_CODE_GENERATED: + self._handle_code_generated_response(wecm, update) + + if needs_update: + wecm.update() + + def _create_conversation_entry( + self, + conversation_id: str, + reply: str, + turn_id: str | None = None, + kind: str = "report" + ) -> CopilotPayloadConversationEntry: + """Helper method to create a standardized conversation entry.""" + return { + "kind": kind, + "conversationId": conversation_id, + "reply": reply, + "turnId": turn_id or str(uuid.uuid4()), + "references": [], + "annotations": [], + "hideText": False, + "warnings": [], + } - wcm.update() + def _handle_no_code_blocks_response(self, wecm: WindowEditConversationManager, update: dict[str, Any]) -> None: + """Handle the no-code-blocks-found response.""" + entry = self._create_conversation_entry( + wecm.conversation_id, + update["rawResponse"], + update.get("editTurnId") + ) + wecm.append_conversation_entry(entry) + wecm.is_waiting = False + + def _handle_edit_description_response(self, wecm: WindowEditConversationManager, update: dict[str, Any]) -> None: + """Handle edit plan or description generated responses.""" + if "editDescription" in update: + entry = self._create_conversation_entry( + wecm.conversation_id, + update["editDescription"], + update.get("editTurnId") + ) + # Use annotations to store metadata for template processing + status = update.get("fileGenerationStatus") + if status == EDIT_STATUS_PLAN_GENERATED: + entry["annotations"] = ["edit-plan"] + elif status == EDIT_STATUS_OVERALL_DESCRIPTION: + entry["annotations"] = ["edit-description"] + + wecm.append_conversation_entry(entry) + + def _handle_code_generated_response(self, wecm: WindowEditConversationManager, update: dict[str, Any]) -> None: + """Handle updated code generated responses.""" + if "partialText" not in update: + return + + # Format the code with proper markdown code fence + language_id = update.get("languageId", "") + code_content = update["partialText"] + markdown_reply = f"```{language_id}\n{code_content}\n```" + + # Add conversation entry + entry = self._create_conversation_entry( + wecm.conversation_id, + markdown_reply, + update.get("editTurnId") + ) + wecm.append_conversation_entry(entry) + + # Add as a pending edit for the entire file + self._add_full_file_edit(wecm, code_content) + + def _add_full_file_edit(self, wecm: WindowEditConversationManager, code_content: str) -> None: + """Add a pending edit that replaces the entire file content.""" + source_view = wecm.get_source_view() + if not source_view: + return + + # Calculate the range for the entire file + file_content = source_view.substr(sublime.Region(0, source_view.size())) + lines = file_content.split('\n') + last_line = len(lines) - 1 + last_char = len(lines[-1]) if lines else 0 + + wecm.add_pending_edit({ + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": last_line, "character": last_char} + }, + "newText": code_content + }) @notification_handler(NTFY_FEATURE_FLAGS_NOTIFICATION) def _handle_feature_flags_notification(self, payload: CopilotPayloadFeatureFlagsNotification) -> None: @@ -444,3 +635,51 @@ def _on_get_completions( preprocess_completions(view, completions) vcm.show(completions, 0, get_session_setting(session, "completion_style")) + + @notification_handler(REQ_NOTIFY_SHOWN) + def _handle_notify_shown_notification(self, payload: Any) -> None: + pass + + @notification_handler(REQ_COPILOT_MODELS) + def _handle_copilot_models_notification(self, payload: Any) -> None: + pass + + @notification_handler(REQ_CONVERSATION_AGENTS) + def _handle_conversation_agents_notification(self, payload: Any) -> None: + pass + + @notification_handler(REQ_CONVERSATION_PRECONDITIONS) + def _handle_conversation_preconditions_notification(self, payload: Any) -> None: + pass + + @notification_handler(REQ_CONVERSATION_TEMPLATES) + def _handle_conversation_templates_notification(self, payload: Any) -> None: + pass + + @notification_handler(REQ_GET_PANEL_COMPLETIONS) + def _handle_get_panel_completions_notification(self, payload: Any) -> None: + pass + + @notification_handler(EDIT_STATUS_BEGIN) + def _handle_edit_status_begin_notification(self, payload: Any) -> None: + pass + + @notification_handler(EDIT_STATUS_END) + def _handle_edit_status_end_notification(self, payload: Any) -> None: + pass + + @notification_handler(EDIT_STATUS_PLAN_GENERATED) + def _handle_edit_status_plan_generated_notification(self, payload: Any) -> None: + pass + + @notification_handler(EDIT_STATUS_OVERALL_DESCRIPTION) + def _handle_edit_status_overall_description_notification(self, payload: Any) -> None: + pass + + @notification_handler(EDIT_STATUS_CODE_GENERATED) + def _handle_edit_status_code_generated_notification(self, payload: Any) -> None: + pass + + @notification_handler(EDIT_STATUS_NO_CODE_BLOCKS) + def _handle_edit_status_no_code_blocks_notification(self, payload: Any) -> None: + pass diff --git a/plugin/commands.py b/plugin/commands.py index b98e442..7df3a34 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -1,8 +1,22 @@ +# TODO +# e.set("context/registerProviders", RGe), +# e.set("context/unregisterProviders", Uje), +# e.set("conversation/registerTools", BWe), +# e.set("copilot/codeReview", LWe), +# e.set("git/commitGenerate", yGe), +# e.set("editConversation/create", MWe), +# e.set("editConversation/turn", OWe), +# e.set("editConversation/turnDelete", UWe), +# e.set("editConversation/destroy", QWe), +# e.set("mcp/getTools", qWe), +# e.set("mcp/updateToolsStatus", WWe), + from __future__ import annotations import json import os import uuid +import time from abc import ABC from collections.abc import Callable from functools import partial, wraps @@ -29,28 +43,43 @@ REQ_CONVERSATION_TEMPLATES, REQ_CONVERSATION_TURN, REQ_CONVERSATION_TURN_DELETE, + REQ_COPILOT_MODELS, + REQ_COPILOT_SET_MODEL_POLICY, REQ_FILE_CHECK_STATUS, REQ_GET_PANEL_COMPLETIONS, REQ_GET_PROMPT, REQ_GET_VERSION, + REQ_INLINE_COMPLETION_PROMPT, + REQ_INLINE_COMPLETION, REQ_NOTIFY_ACCEPTED, REQ_NOTIFY_REJECTED, REQ_SIGN_IN_CONFIRM, REQ_SIGN_IN_INITIATE, REQ_SIGN_IN_WITH_GITHUB_TOKEN, REQ_SIGN_OUT, + REQ_COPILOT_CODE_REVIEW, + REQ_GIT_COMMIT_GENERATE, + REQ_EDIT_CONVERSATION_CREATE, + REQ_EDIT_CONVERSATION_TURN, + REQ_EDIT_CONVERSATION_TURN_DELETE, + REQ_EDIT_CONVERSATION_DESTROY, + REQ_CONVERSATION_REGISTER_TOOLS, ) from .decorators import must_be_active_view from .helpers import ( GithubInfo, prepare_completion_request_doc, + prepare_code_review_request_doc, prepare_conversation_turn_request, preprocess_chat_message, preprocess_message_for_html, + GitHelper, + prepare_conversation_edit_request, ) from .log import log_info from .types import ( CopilotConversationDebugTemplates, + CopilotModel, CopilotPayloadConversationCreate, CopilotPayloadConversationPreconditions, CopilotPayloadConversationTemplate, @@ -65,8 +94,10 @@ CopilotRequestConversationAgent, CopilotUserDefinedPromptTemplates, T_Callable, + CopilotPayloadEditConversationCreate, + CopilotPayloadEditConversationTurn, ) -from .ui import ViewCompletionManager, ViewPanelCompletionManager, WindowConversationManager +from .ui import ViewCompletionManager, ViewPanelCompletionManager, WindowConversationManager, WindowEditConversationManager from .utils import ( find_index_by_key_value, find_view_by_id, @@ -177,6 +208,327 @@ def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: plugin.request_get_completions(self.view) +class CopilotInlineCompletionPromptCommand(CopilotTextCommand): + """Command to get inline completions with a custom prompt/message.""" + + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit, message: str = "") -> None: + # Prompt for message if not provided + if not message.strip(): + self.view.window().show_input_panel( + "Completion Prompt:", + "", + lambda msg: self._request_inline_completion_with_prompt(plugin, session, msg), + None, + None + ) + else: + self._request_inline_completion_with_prompt(plugin, session, message) + + def _request_inline_completion_with_prompt(self, plugin: CopilotPlugin, session: Session, message: str) -> None: + """Send the inline completion request with the custom prompt.""" + if not message.strip(): + status_message("Please provide a message for the completion prompt", icon="❌") + return + + # Prepare document information + if not (doc := prepare_completion_request_doc(self.view)): + status_message("Failed to prepare document for completion request", icon="❌") + return + + # Get cursor position + if not (sel := self.view.sel()) or len(sel) != 1: + status_message("Please place cursor at a single position", icon="❌") + return + + cursor_point = sel[0].begin() + row, col = self.view.rowcol(cursor_point) + + # Prepare the request payload based on the structure from constants + payload = { + "textDocument": { + "uri": doc["uri"] + }, + "position": { + "line": row, + "character": col + }, + "formattingOptions": { + "tabSize": doc.get("tabSize", 4), + "insertSpaces": doc.get("insertSpaces", True) + }, + "context": { + "triggerKind": 2 + }, + "data": { + "message": message + } + } + + # Send the request + session.send_request( + Request(REQ_INLINE_COMPLETION_PROMPT, payload), + lambda response: self._on_result_inline_completion_prompt(response, message) + ) + + status_message(f"Requesting completion with prompt: {message[:50]}...", icon="⏳") + + def _on_result_inline_completion_prompt(self, response: Any, original_message: str) -> None: + """Handle the response from the inline completion prompt request.""" + if not response: + status_message("No completion suggestions received", icon="❌") + return + + # Handle the new response format with items + if isinstance(response, dict) and "items" in response: + items = response["items"] + if not items: + status_message("No completion items received", icon="❌") + return + + # Show the completion selection dialog + self.view.window().run_command("copilot_select_inline_completion", { + "items": items, + "original_message": original_message + }) + else: + # Fallback for other response formats + status_message(f"Unexpected response format: {type(response)}", icon="❌") + + +class CopilotInlineCompletionCommand(CopilotTextCommand): + """Command to get inline completions (automatic trigger).""" + + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit, message: str = "") -> None: + # Prompt for message if not provided + if not message.strip(): + self.view.window().show_input_panel( + "Completion Message:", + "", + lambda msg: self._request_inline_completion(plugin, session, msg), + None, + None + ) + else: + self._request_inline_completion(plugin, session, message) + + def _request_inline_completion(self, plugin: CopilotPlugin, session: Session, message: str) -> None: + """Send the inline completion request.""" + if not message.strip(): + status_message("Please provide a message for the completion", icon="❌") + return + + # Prepare document information + if not (doc := prepare_completion_request_doc(self.view)): + status_message("Failed to prepare document for completion request", icon="❌") + return + + # Get cursor position + if not (sel := self.view.sel()) or len(sel) != 1: + status_message("Please place cursor at a single position", icon="❌") + return + + cursor_point = sel[0].begin() + row, col = self.view.rowcol(cursor_point) + + # Prepare the request payload - same as prompt but with triggerKind: 1 + payload = { + "textDocument": { + "uri": doc["uri"] + }, + "position": { + "line": row, + "character": col + }, + "formattingOptions": { + "tabSize": doc.get("tabSize", 4), + "insertSpaces": doc.get("insertSpaces", True) + }, + "context": { + "triggerKind": 1 # Different from prompt command which uses 2 + }, + "data": { + "message": message + } + } + + # Send the request + session.send_request( + Request(REQ_INLINE_COMPLETION, payload), + lambda response: self._on_result_inline_completion(response, message) + ) + + status_message(f"Requesting inline completion: {message[:50]}...", icon="⏳") + + def _on_result_inline_completion(self, response: Any, original_message: str) -> None: + """Handle the response from the inline completion request.""" + if not response: + status_message("No completion suggestions received", icon="❌") + return + + # Handle the new response format with items + if isinstance(response, dict) and "items" in response: + items = response["items"] + if not items: + status_message("No completion items received", icon="❌") + return + + # Store the completion items for the input handler + self._completion_items = items + self._original_message = original_message + + # Show the completion selection dialog + self.view.window().run_command("copilot_select_inline_completion", { + "items": items, + "original_message": original_message + }) + else: + # Fallback for other response formats + status_message(f"Unexpected response format: {type(response)}", icon="❌") + + +class CopilotSelectInlineCompletionCommand(CopilotTextCommand): + """Command to select and insert an inline completion from a list.""" + + def run(self, edit: sublime.Edit, selected: int, items: list[dict[str, Any]], original_message: str, selected_item: dict[str, Any] | None = None) -> None: + if selected_item: + # Insert the selected completion + insert_text = selected_item.get("insertText", "") + if insert_text: + # Get the range where to insert + if "range" in selected_item: + range_data = selected_item["range"] + start_line = range_data["start"]["line"] + start_char = range_data["start"]["character"] + end_line = range_data["end"]["line"] + end_char = range_data["end"]["character"] + + # Convert to Sublime Text points + start_point = self.view.text_point(start_line, start_char) + end_point = self.view.text_point(end_line, end_char) + + # Replace the range with the completion + self.view.replace(edit, sublime.Region(start_point, end_point), insert_text) + else: + # Insert at current cursor position + if sel := self.view.sel(): + self.view.insert(edit, sel[0].begin(), insert_text) + + status_message(f"Inserted completion for: {original_message[:30]}...", icon="✅") + + # Handle acceptance command if present + if "command" in selected_item and selected_item["command"]: + cmd = selected_item["command"] + if cmd.get("command") == "github.copilot.didAcceptCompletionItem": + # Send acceptance notification to Copilot + args = cmd.get("arguments", []) + if args: + # This would typically be handled by the LSP client + # For now, we'll just log it + print(f"Copilot completion accepted: {args[0]}") + else: + status_message("Selected completion has no text", icon="❌") + + def input(self, args: dict[str, Any]) -> sublime_plugin.CommandInputHandler | None: + items = args.get("items", []) + original_message = args.get("original_message", "") + + if not items: + return None + + return CopilotInlineCompletionInputHandler(items, original_message) + + +class CopilotInlineCompletionInputHandler(sublime_plugin.ListInputHandler): + """Input handler for selecting inline completions.""" + + def __init__(self, items: list[dict[str, Any]], original_message: str): + self.items = items + self.original_message = original_message + + def name(self) -> str: + return "selected_item" + + def placeholder(self) -> str: + return f"Select completion for: {self.original_message[:50]}..." + + def list_items(self) -> list[sublime.ListInputItem]: + import mdpopups + + list_items = [] + for i, item in enumerate(self.items): + insert_text = item.get("insertText", "") + + # Create preview using mdpopups to convert markdown to HTML + if insert_text: + # Detect language for syntax highlighting + # This is a simple heuristic - you might want to improve this + language = self._detect_language(insert_text) + markdown_text = f"```{language}\n{insert_text}\n```" + + # Convert markdown to HTML using mdpopups + try: + html_details = mdpopups.md2html(None, markdown_text) + except Exception: + # Fallback to plain text if markdown conversion fails + html_details = f"
{insert_text}" + + # Create a short preview for the main text + preview = insert_text.strip().split('\n')[0] + if len(preview) > 60: + preview = preview[:57] + "..." + + # Add line count annotation + line_count = len(insert_text.split('\n')) + annotation = f"{line_count} line{'s' if line_count != 1 else ''}" + + list_items.append(sublime.ListInputItem( + text=preview, + value=item, + details=html_details, + annotation=annotation, + kind=sublime.KIND_SNIPPET + )) + else: + list_items.append(sublime.ListInputItem( + text=f"Completion {i + 1}", + value=item, + details="No preview available", + annotation="", + kind=sublime.KIND_SNIPPET + )) + + return list_items + + def _detect_language(self, text: str) -> str: + """Simple language detection based on content.""" + text_lower = text.lower().strip() + + # Python + if any(keyword in text_lower for keyword in ['def ', 'import ', 'from ', 'class ', 'if __name__']): + return "python" + + # JavaScript/TypeScript + if any(keyword in text_lower for keyword in ['function ', 'const ', 'let ', 'var ', '=>', 'console.log']): + return "javascript" + + # HTML + if text_lower.startswith('<') and '>' in text_lower: + return "html" + + # CSS + if '{' in text and '}' in text and ':' in text: + return "css" + + # JSON + if text.strip().startswith('{') and text.strip().endswith('}'): + return "json" + + # Default to text + return "text" + + class CopilotAcceptPanelCompletionShimCommand(CopilotWindowCommand): def run(self, view_id: int, completion_index: int) -> None: if not (view := find_view_by_id(view_id)): @@ -580,12 +932,264 @@ def run(self, edit: sublime.Edit, characters: str) -> None: class CopilotConversationAgentsCommand(CopilotTextCommand): @_provide_plugin_session() def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: - session.send_request(Request(REQ_CONVERSATION_AGENTS, {"options": {}}), self._on_result_conversation_agents) + session.send_request(Request(REQ_CONVERSATION_AGENTS, {}), self._on_result_conversation_agents) def _on_result_conversation_agents(self, payload: list[CopilotRequestConversationAgent]) -> None: + if not payload: + status_message("No conversation agents available", icon="❌") + return + + window = self.view.window() or sublime.active_window() + window.show_quick_panel( + [ + sublime.QuickPanelItem( + trigger=agent["slug"], + details=agent["name"], + annotation=agent["description"], + ) + for agent in payload + ], + lambda index: self._on_agent_selected(index, payload), + ) + + def _on_agent_selected(self, index: int, agents: list[CopilotRequestConversationAgent]) -> None: + if index == -1: + return + + # For now, just show detailed info about the selected agent + agent = agents[index] + message_dialog( + f"Agent: {agent['name']}\n" + f"Slug: {agent['slug']}\n" + f"Description: {agent['description']}" + ) + + +class CopilotRegisterConversationToolsCommand(CopilotTextCommand): + """Command to register custom tools for Copilot Chat conversations.""" + + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: + # Define tools to register + tools = [ + { + "id": "sublime-file-search", + "name": "Search Files in Sublime", + "description": "Search for files in the current project or workspace", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query" + }, + "maxResults": { + "type": "integer", + "description": "Maximum number of results to return" + } + }, + "required": ["query"] + } + }, + { + "id": "sublime-text-search", + "name": "Search Text in Sublime", + "description": "Search for text content across files in the project", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The text to search for" + }, + "filePattern": { + "type": "string", + "description": "Optional file pattern to limit search" + } + }, + "required": ["query"] + } + } + ] + + # Send request to register tools + session.send_request( + Request( + REQ_CONVERSATION_REGISTER_TOOLS, + { + "tools": tools + } + ), + self._on_result_register_tools + ) + + status_message("Registering conversation tools...", icon="⏳") + + def _on_result_register_tools(self, payload: Any) -> None: + if isinstance(payload, dict) and payload.get("status") == "OK": + status_message("Successfully registered conversation tools", icon="✅") + else: + status_message("Failed to register conversation tools", icon="❌") + + +class CopilotModelsCommand(CopilotTextCommand): + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: + session.send_request(Request(REQ_COPILOT_MODELS, {}), self._on_result_copilot_models) + + def _on_result_copilot_models(self, payload: list[CopilotModel]) -> None: + window = self.view.window() or sublime.active_window() + window.show_quick_panel( + [ + sublime.QuickPanelItem( + trigger=item["modelFamily"], + details=item["modelName"], + annotation=", ".join(item["scopes"]), + ) + for item in payload + ], + lambda index: self._set_model_policy(index, payload), + ) + + def _set_model_policy(self, index: int, models: list[CopilotModel]) -> None: + if index == -1: + return + model_name = models[index]["modelFamily"] + self.view.run_command("copilot_set_model_policy", {"model": model_name, "status": "enabled"}) + + +class CopilotCodeReviewCommand(CopilotTextCommand): + """Command to perform code review using GitHub Copilot.""" + + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: if not (window := self.view.window()): return - window.show_quick_panel([[item["slug"], item["description"]] for item in payload], lambda _: None) + + # Get the file URI for the current view + file_uri = filename_to_uri(self.view.file_name() or "") + if not file_uri: + status_message("Cannot perform code review on an unsaved file", icon="❌") + return + doc = prepare_code_review_request_doc(self.view) + + # Send request to perform code review + session.send_request( + Request( + REQ_COPILOT_CODE_REVIEW, + { + "uri": file_uri, + "document": doc, + "selection": doc["selection"], + "source": "command_palette", + } + ), + lambda response: self._on_result_code_review(response, window) + ) + + status_message("Analyzing code...", icon="⏳") + + def _on_result_code_review(self, payload: dict, window: sublime.Window) -> None: + """Handle the code review response from Copilot.""" + if not payload: + status_message("Code review failed or returned no results", icon="❌") + return + + # Create an output panel to show the review results + panel_name = f"{COPILOT_OUTPUT_PANEL_PREFIX}_code_review" + panel = window.create_output_panel(panel_name) + panel.set_read_only(False) + panel.run_command("append", {"characters": "# GitHub Copilot Code Review\n\n"}) + + # Process review comments if available + if comments := payload.get("comments", []): + for i, comment in enumerate(comments, 1): + # Extract comment details + message = comment.get("message", "No message provided") + kind = comment.get("kind", "unknown") + severity = comment.get("severity", "unknown") + + # Format the comment header + panel.run_command("append", {"characters": f"## Comment {i} ({kind.title()} - {severity.title()})\n\n"}) + panel.run_command("append", {"characters": f"{message}\n\n"}) + + # Add line range information if available + if file_range := comment.get("range"): + line_start = file_range.get("start", {}).get("line", 0) + 1 + line_end = file_range.get("end", {}).get("line", 0) + 1 + char_start = file_range.get("start", {}).get("character", 0) + char_end = file_range.get("end", {}).get("character", 0) + + if line_start == line_end: + panel.run_command("append", {"characters": f"**Location:** Line {line_start}, characters {char_start}-{char_end}\n\n"}) + else: + panel.run_command("append", {"characters": f"**Location:** Lines {line_start}-{line_end}\n\n"}) + + # Add file information if different from current file + if uri := comment.get("uri"): + import urllib.parse + file_path = urllib.parse.unquote(uri.replace("file://", "")) + file_name = file_path.split("/")[-1] if "/" in file_path else file_path + panel.run_command("append", {"characters": f"**File:** {file_name}\n\n"}) + + panel.run_command("append", {"characters": "---\n\n"}) + else: + panel.run_command("append", {"characters": "No issues found in your code. Great job! ✅\n\n"}) + + panel.set_read_only(True) + window.run_command("show_panel", {"panel": f"output.{panel_name}"}) + status_message("Code review completed", icon="✅") + + +class CopilotGitCommitGenerateCommand(CopilotTextCommand): + """Command to generate Git commit messages using GitHub Copilot.""" + + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: + if not (window := self.view.window()): + return + + # Gather all Git data using GitHelper + git_data = GitHelper.gather_git_commit_data(self.view) + if not git_data: + status_message("Not in a Git repository or no workspace folder found", icon="❌") + return + status_message("Generating commit message...", icon="⏳") + + # Send request to generate commit message + session.send_request( + Request(REQ_GIT_COMMIT_GENERATE, git_data), + lambda response: self._on_result_git_commit_generate(response, window) + ) + + def _on_result_git_commit_generate(self, payload: dict, window: sublime.Window) -> None: + """Handle the git commit message generation response from Copilot.""" + if not payload or not (commit_message := payload.get("commitMessage")): + status_message("Failed to generate commit message", icon="❌") + return + + # Create a new view for the commit message + view = window.new_file() + view.set_name("Git Commit Message") + view.set_scratch(True) + + # Insert the generated commit message + with mutable_view(view) as v: + v.run_command("append", {"characters": commit_message}) + + # Set commit message syntax + view.assign_syntax("Packages/Git/Git Commit.sublime-syntax") + + status_message("Commit message generated", icon="✅") + + +class CopilotSetModelPolicyCommand(CopilotTextCommand): + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit, model: str, status: str) -> None: + session.send_request( + Request(REQ_COPILOT_SET_MODEL_POLICY, {"model": model, "status": status}), + lambda _: None, + ) class CopilotGetPromptCommand(CopilotTextCommand): @@ -926,3 +1530,373 @@ def placeholder(self) -> str: def name(self) -> str: return "payload" + +# THIS IS NOT IMPLEMENTED +# { +# partialResultToken: I.Union([I.String(), I.Number()]), +# turns: I.Array({ request: I.String(), response: I.Optional(I.String()) }), { minItems: 1 }), +# workingSet: I.Optional(I.Array({ +# t-ype: I.Literal("file"), +# uri: I.String(), +# visibleRange: I.Optional(Ea), +# selection: I.Optional(Ea), +# status: I.Optional(xNt), +# range: I.Optional(Ea), +# })), +# source: I.Optional(yLt), +# workspaceFolder: I.Optional(I.String()), +# userLanguage: I.Optional(I.String()), +# model: I.Optional(I.String()), +# }) + +class CopilotEditConversationCreateCommand(CopilotTextCommand): + """Command to create a new edit conversation with GitHub Copilot.""" + + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit, message: str = "") -> None: + if not (window := self.view.window()): + return + + file_uri = filename_to_uri(self.view.file_name() or "") + if not file_uri: + status_message("Cannot start edit conversation on an unsaved file", icon="❌") + return + + wecm = WindowEditConversationManager(window) + if wecm.conversation_id: + ui_entry = wecm.get_ui_entry() + ui_entry.open() + ui_entry.prompt_for_message(callback=lambda msg: self._on_edit_prompt(plugin, session, msg), initial_text=message) + return + + # If no message provided, prompt for it first + if not message.strip(): + wecm = WindowEditConversationManager(window) + wecm.source_view_id = self.view.id() + ui_entry = wecm.get_ui_entry() + ui_entry.open() + ui_entry.prompt_for_message( + callback=lambda msg: self._create_edit_conversation_with_message(plugin, session, msg), + initial_text="" + ) + return + + # Create conversation with the provided message + self._create_edit_conversation_with_message(plugin, session, message) + + def _create_edit_conversation_with_message(self, plugin: CopilotPlugin, session: Session, message: str) -> None: + """Create a new edit conversation with the specified message.""" + if not (window := self.view.window()): + return + + if not message.strip(): + status_message("Please provide a message for the edit conversation", icon="❌") + return + + file_uri = filename_to_uri(self.view.file_name() or "") + if not file_uri: + status_message("Cannot start edit conversation on an unsaved file", icon="❌") + return + + # Prepare the document for the request + doc = prepare_conversation_edit_request(self.view) + if not doc: + status_message("Failed to prepare document for edit conversation", icon="❌") + return + + # Add user message to conversation before sending request + wecm = WindowEditConversationManager(window) + wecm.source_view_id = self.view.id() + is_template, msg = preprocess_chat_message(self.view, message, []) + # Add the user's message to the conversation + wecm.append_conversation_entry({ + "kind": plugin.get_account_status().user or "user", + "conversationId": "", # Will be set when conversation is created + "reply": preprocess_message_for_html(msg), + "turnId": str(uuid.uuid4()), + "references": [], + "annotations": [], + "hideText": False, + "warnings": [], + }) + + # Update UI to show the user message + ui_entry = wecm.get_ui_entry() + ui_entry.update() + + # Send request to create a new edit conversation + session.send_request( + Request( + REQ_EDIT_CONVERSATION_CREATE, + { + "partialResultToken": f"copilot_pedit://{window.id()}", + "turns": [ + { + "request": msg, + "doc": doc + } + ], + "workingSet": [ + { + "type": "file", + "uri": file_uri, + "selection": doc["selection"], + "range": doc["range"] + } + ], + "source": "panel", + "workspaceFolder": "", + "userLanguage": "en-US", # Default to English + "model": None # Let the server choose the model + } + ), + lambda response: self._on_result_edit_conversation_create(plugin, session, response) + ) + + status_message("Creating edit conversation...", icon="⏳") + + def _on_result_edit_conversation_create( + self, + plugin: CopilotPlugin, + session: Session, + response: list + ) -> None: + if not (window := self.view.window()): + return + if len(response) != 0: + status_message(f"{response} Failed to create edit conversation", icon="❌") + return + + # The conversation ID will come from progress updates + # Set up the manager and UI for continuous conversation + wecm = WindowEditConversationManager(window) + wecm.source_view_id = self.view.id() + ui_entry = wecm.get_ui_entry() + ui_entry.open() + ui_entry.prompt_for_message(callback=lambda msg: self._on_edit_prompt(plugin, session, msg)) + + + def _on_edit_prompt(self, plugin: CopilotPlugin, session: Session, msg: str): + if not (window := self.view.window()): + return + + wecm = WindowEditConversationManager(window) + ui_entry = wecm.get_ui_entry() + if wecm.is_waiting: + ui_entry.prompt_for_message(callback=lambda x: self._on_edit_prompt(plugin, session, x), initial_text=msg) + return + + if not (source_view := find_view_by_id(wecm.source_view_id)): + status_message("Source view no longer available", icon="❌") + return + + is_template, msg = preprocess_chat_message(source_view, msg, []) + # Get workspace folder if available + workspace_folder = None + if window.folders(): + workspace_folder = window.folders()[0] + + # Prepare the document for the request + doc = prepare_conversation_edit_request(source_view) + if not doc: + status_message("Failed to prepare document for edit conversation", icon="❌") + return + + file_uri = filename_to_uri(source_view.file_name() or "") + + # Add user message to conversation + wecm.append_conversation_entry({ + "kind": plugin.get_account_status().user or "user", + "conversationId": wecm.conversation_id, + "reply": preprocess_message_for_html(msg), + "turnId": str(uuid.uuid4()), + "references": [], + "annotations": [], + "hideText": False, + "warnings": [], + }) + + # Update UI to show the user message + ui_entry.update() + + # Send the turn request + session.send_request( + Request( + REQ_EDIT_CONVERSATION_TURN, + { + "partialResultToken": f"copilot_edit://{window.id()}_{wecm.conversation_id}", + "editConversationId": wecm.conversation_id, + "message": msg, + "workingSet": [ + { + "type": "file", + "uri": file_uri, + "selection": doc["selection"], + "range": doc["range"] + } + ], + "workspaceFolder": "", + "userLanguage": "en-US", + "model": None + } + ), + lambda _: ui_entry.prompt_for_message(callback=lambda x: self._on_edit_prompt(plugin, session, x)) + ) + ui_entry.show_waiting_state(True) + + +class CopilotApplyEditConversationEditsCommand(CopilotTextCommand): + """Command to apply edits from an edit conversation.""" + + @_provide_plugin_session() + def run(self, plugin: CopilotPlugin, session: Session, edit: sublime.Edit) -> None: + if not (window := self.view.window()): + return + + wecm = WindowEditConversationManager(window) + # Get pending edits and source view from the edit conversation manager + pending_edits = wecm.pending_edits + source_view = wecm.get_source_view() + + if not pending_edits: + status_message("No pending edits to apply", icon="❌") + return + + if not source_view: + status_message("Source view no longer available", icon="❌") + return + + # Apply edits to the source view + window.focus_view(source_view) + with mutable_view(source_view) as edit_obj: + for edit_item in pending_edits: + if range_data := edit_item.get("range"): + # Convert LSP range to Sublime Text region + start_point = source_view.text_point( + range_data["start"]["line"], + range_data["start"]["character"] + ) + end_point = source_view.text_point( + range_data["end"]["line"], + range_data["end"]["character"] + ) + region = sublime.Region(start_point, end_point) + + # Replace the text in the region + edit_obj.replace(region, edit_item.get("newText", "")) + + # Clear pending edits + wecm.clear_pending_edits() + status_message("Applied edits to the document", icon="✅") + + +class CopilotEditConversationTurnDeleteCommand(CopilotTextCommand): + """Command to delete a turn from an edit conversation.""" + + @_provide_plugin_session() + def run( + self, + plugin: CopilotPlugin, + session: Session, + _: sublime.Edit, + conversation_id: str, + turn_id: str + ) -> None: + session.send_request( + Request( + REQ_EDIT_CONVERSATION_TURN_DELETE, + { + "conversationId": conversation_id, + "turnId": turn_id + } + ), + lambda response: self._on_result_edit_conversation_turn_delete(conversation_id, turn_id, response) + ) + + def _on_result_edit_conversation_turn_delete( + self, + conversation_id: str, + turn_id: str, + payload: Any + ) -> None: + status_message(f"Deleted turn from edit conversation", icon="✅") + + # Update the panel if it exists + if not (window := self.view.window()): + return + + panel_name = f"{COPILOT_OUTPUT_PANEL_PREFIX}_edit_{conversation_id[:8]}" + for view in window.views(): + if view.settings().get('output.name') == panel_name: + view.run_command("copilot_refresh_edit_conversation_panel", {"conversation_id": conversation_id}) + break + +class CopilotEditConversationDestroyShimCommand(CopilotWindowCommand): + def run(self, conversation_id: str) -> None: + wecm = WindowEditConversationManager(self.window) + if not (view := find_view_by_id(wecm.source_view_id)): + status_message("Failed to find source view.") + return + # Focus the view so that the command runs + self.window.focus_view(view) + view.run_command("copilot_edit_conversation_destroy", {"conversation_id": conversation_id}) + +class CopilotEditConversationDestroyCommand(CopilotTextCommand): + """Command to destroy an edit conversation.""" + + @_provide_plugin_session() + def run( + self, + plugin: CopilotPlugin, + session: Session, + _: sublime.Edit, + conversation_id: str + ) -> None: + if not ( + (window := self.view.window()) + and (wecm := WindowEditConversationManager(window)) + and wecm.conversation_id == conversation_id + ): + status_message("Failed to find window or edit conversation.") + return + + session.send_request( + Request( + REQ_EDIT_CONVERSATION_DESTROY, + { + "editConversationId": conversation_id, + "options": {}, + } + ), + self._on_result_edit_conversation_destroy, + ) + + def _on_result_edit_conversation_destroy(self, payload: str) -> None: + if not (window := self.view.window()): + status_message("Failed to find window") + return + if payload != "OK": + status_message("Failed to destroy edit conversation.") + return + + status_message("Destroyed edit conversation.") + wecm = WindowEditConversationManager(window) + wecm.close() + wecm.reset() + + def is_enabled(self, event: dict[Any, Any] | None = None, point: int | None = None) -> bool: # type: ignore + if not (window := self.view.window()): + return False + return bool(WindowEditConversationManager(window).conversation_id) + + +class CopilotRefreshEditConversationPanelCommand(sublime_plugin.TextCommand): + """Command to refresh the edit conversation panel.""" + + def run(self, edit: sublime.Edit, conversation_id: str) -> None: + # This is a utility command to update the panel after deleting a turn + # In a real implementation, this would fetch the updated conversation + # and redraw the panel contents + self.view.set_read_only(False) + self.view.run_command("append", {"characters": "\n[Turn deleted]\n\n"}) + self.view.set_read_only(True) diff --git a/plugin/constants.py b/plugin/constants.py index 071b922..c861d88 100644 --- a/plugin/constants.py +++ b/plugin/constants.py @@ -18,12 +18,19 @@ # ---------------- # REQ_CHECK_STATUS = "checkStatus" +REQ_CHECK_QUOTA = "checkQuota" +REQ_COPILOT_MODELS = "copilot/models" +REQ_COPILOT_SET_MODEL_POLICY = "copilot/setModelPolicy" +REQ_COPILOT_CODE_REVIEW = "copilot/codeReview" REQ_FILE_CHECK_STATUS = "checkFileStatus" REQ_GET_COMPLETIONS = "getCompletions" REQ_GET_COMPLETIONS_CYCLING = "getCompletionsCycling" -REQ_GET_PROMPT = "getPrompt" REQ_GET_PANEL_COMPLETIONS = "getPanelCompletions" +REQ_GET_PROMPT = "getPrompt" REQ_GET_VERSION = "getVersion" +REQ_INLINE_COMPLETION_PROMPT = "textDocument/inlineCompletionPrompt" +REQ_INLINE_COMPLETION = "textDocument/inlineCompletion" +REQ_GIT_COMMIT_GENERATE = "git/commitGenerate" REQ_NOTIFY_ACCEPTED = "notifyAccepted" REQ_NOTIFY_REJECTED = "notifyRejected" REQ_NOTIFY_SHOWN = "notifyShown" @@ -33,6 +40,43 @@ REQ_SIGN_IN_INITIATE = "signInInitiate" REQ_SIGN_IN_WITH_GITHUB_TOKEN = "signInWithGithubToken" REQ_SIGN_OUT = "signOut" +REQ_TEXT_DOCUMENT_DID_FOCUS = "textDocument/didFocus" +# { +# "textDocument": { +# "uri": "file:///path/to/file" +# } +# } + +# textDocument/inlineCompletionPrompt +# { +# textDocument: { +# uri: string; + # }, +# position: { + # line: I.Integer({ minimum: 0 }), + # character: I.Integer({ minimum: 0 }), + # }, +# formattingOptions: I.Optional( +# I.Object({ +# tabSize: I.Optional(I.Union([I.Integer({ minimum: 1 }), I.String()])), +# insertSpaces: I.Optional(I.Union([I.Boolean(), I.String()])), +# }), +# ), +# context: { + # triggerKind: "Invoked" or "Automatic", + # selectedCompletionInfo: I.Optional( + # I.Object({ + # text: I.String(), + # range: { start: {line: character}, end: wl }, + # tooltipSignature: I.Optional(I.String()), + # }), + # ), + # }, +# data: { +# "message": "string", +# }, +# } +# textDocument/inlineCompletion # --------------------- # # Copilot chat requests # @@ -50,6 +94,23 @@ REQ_CONVERSATION_TEMPLATES = "conversation/templates" REQ_CONVERSATION_TURN = "conversation/turn" REQ_CONVERSATION_TURN_DELETE = "conversation/turnDelete" +REQ_CONVERSATION_REGISTER_TOOLS = "conversation/registerTools" + +# ---------------------------- # +# Copilot edit chat requests # +# ---------------------------- # + +REQ_EDIT_CONVERSATION_CREATE = "editConversation/create" +REQ_EDIT_CONVERSATION_TURN = "editConversation/turn" +REQ_EDIT_CONVERSATION_TURN_DELETE = "editConversation/turnDelete" +REQ_EDIT_CONVERSATION_DESTROY = "editConversation/destroy" + +# -------------------------- # +# Copilot context requests # +# -------------------------- # + +REQ_CONTEXT_REGISTER_PROVIDERS = "context/registerProviders" +REQ_CONTEXT_UNREGISTER_PROVIDERS = "context/unregisterProviders" # --------------------- # # Copilot notifications # @@ -61,3 +122,12 @@ NTFY_PANEL_SOLUTION_DONE = "PanelSolutionsDone" NTFY_PROGRESS = "$/progress" NTFY_STATUS_NOTIFICATION = "statusNotification" + +# Edit conversation file generation statuses +EDIT_STATUS_BEGIN = "edit-conversation-begin" +EDIT_STATUS_END = "edit-conversation-end" +EDIT_STATUS_PLAN_GENERATED = "edit-plan-generated" +EDIT_STATUS_OVERALL_DESCRIPTION = "overall-description-generated" +EDIT_STATUS_CODE_GENERATED = "updated-code-generated" +EDIT_STATUS_NO_CODE_BLOCKS = "no-code-blocks-found" +# "updated-code-generating" diff --git a/plugin/helpers.py b/plugin/helpers.py index 9d5d4ef..d7bc9d1 100644 --- a/plugin/helpers.py +++ b/plugin/helpers.py @@ -3,6 +3,7 @@ import itertools import os import re +import subprocess import threading import time from operator import itemgetter @@ -195,6 +196,17 @@ def lsp_range_to_st_region(range_: LspRange, view: sublime.View) -> sublime.Regi ) +def prepare_code_review_request_doc(view: sublime.View): + selection = view.sel()[0] + file_path = view.file_name() or f"buffer:{view.buffer().id()}" + return { + "text": view.substr(sublime.Region(0, view.size())), + "uri": file_path if file_path.startswith("buffer:") else filename_to_uri(file_path), + "languageId": get_view_language_id(view), + "selection": st_region_to_lsp_range(selection, view), + "version": view.change_count(), + } + def prepare_completion_request_doc(view: sublime.View) -> CopilotDocType | None: selection = view.sel()[0] file_path = view.file_name() or f"buffer:{view.buffer().id()}" @@ -334,3 +346,212 @@ def preprocess_panel_completions(view: sublime.View, completions: Sequence[Copil def is_debug_mode() -> bool: return bool(get_plugin_setting_dotted("settings.debug", False)) + + +class GitHelper: + """Helper class for Git operations used by Copilot commands.""" + + @staticmethod + def run_git_command(cmd: list[str], cwd: str | None = None) -> str | None: + """Run a git command and return the output, or None if it fails.""" + try: + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + timeout=10, + check=True + ) + return result.stdout.strip() + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return None + + @staticmethod + def get_git_repo_root(start_path: str) -> str | None: + """Find the Git repository root starting from the given path.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=start_path, + capture_output=True, + text=True, + timeout=5, + check=True + ) + return result.stdout.strip() + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return None + + @staticmethod + def get_git_changes(repo_root: str) -> list[str]: + """Get actual diff content for staged and unstaged changes.""" + changes = [] + + # Get staged changes (actual diff content) + staged_output = GitHelper.run_git_command(["git", "diff", "--cached"], repo_root) + if staged_output: + changes.append(f"=== STAGED CHANGES ===\n{staged_output}") + + # Get unstaged changes (actual diff content) + unstaged_output = GitHelper.run_git_command(["git", "diff"], repo_root) + if unstaged_output: + changes.append(f"=== UNSTAGED CHANGES ===\n{unstaged_output}") + + # Get untracked files content (for small files) + untracked_output = GitHelper.run_git_command(["git", "ls-files", "--others", "--exclude-standard"], repo_root) + if untracked_output: + untracked_files = [f.strip() for f in untracked_output.split('\n') if f.strip()] + untracked_content = [] + + for file_path in untracked_files[:5]: # Limit to first 5 untracked files + full_path = Path(repo_root) / file_path + try: + if full_path.is_file() and full_path.stat().st_size < 10000: # Only read files < 10KB + content = full_path.read_text(encoding='utf-8', errors='ignore') + untracked_content.append(f"=== NEW FILE: {file_path} ===\n{content}") + except (OSError, UnicodeDecodeError): + untracked_content.append(f"=== NEW FILE: {file_path} ===\n[Binary or unreadable file]") + + if untracked_content: + changes.extend(untracked_content) + + return changes + + @staticmethod + def get_current_user_email(repo_root: str) -> str | None: + """Get the current Git user email.""" + return GitHelper.run_git_command(["git", "config", "user.email"], repo_root) + + @staticmethod + def get_user_commits(repo_root: str, user_email: str | None, limit: int = 10) -> list[str]: + """Get recent commits by the current user.""" + if not user_email: + return [] + + cmd = [ + "git", "log", + f"--author={user_email}", + f"--max-count={limit}", + "--oneline", + "--no-merges" + ] + + output = GitHelper.run_git_command(cmd, repo_root) + if output: + return [line.strip() for line in output.split('\n') if line.strip()] + return [] + + @staticmethod + def get_recent_commits(repo_root: str, limit: int = 20) -> list[str]: + """Get recent commits from all contributors.""" + cmd = [ + "git", "log", + f"--max-count={limit}", + "--oneline", + "--no-merges" + ] + + output = GitHelper.run_git_command(cmd, repo_root) + if output: + return [line.strip() for line in output.split('\n') if line.strip()] + return [] + + @staticmethod + def get_workspace_folder(view: sublime.View) -> str | None: + """Get the workspace folder path.""" + if not (window := view.window()): + return None + + # Try to get the project path + if folders := window.folders(): + return folders[0] + + # Fallback to file directory + if file_name := view.file_name(): + return str(Path(file_name).parent) + + return None + + @staticmethod + def get_user_language() -> str | None: + """Get user's language preference from Sublime Text settings.""" + # Try to get language from various Sublime Text settings + settings = sublime.load_settings("Preferences.sublime-settings") + + # Check for explicit language setting + if lang := settings.get("language"): + return lang + + # Fallback to system locale + try: + import locale + return locale.getdefaultlocale()[0] + except: + return "en-US" + + @classmethod + def gather_git_commit_data(cls, view: sublime.View) -> dict[str, Any] | None: + """ + Gather all Git data needed for commit message generation. + Returns None if not in a Git repository or if workspace folder not found. + """ + # Get workspace folder + workspace_folder = cls.get_workspace_folder(view) + if not workspace_folder: + return None + + # Find Git repository root + repo_root = cls.get_git_repo_root(workspace_folder) + if not repo_root: + return None + + # Gather Git information + changes = cls.get_git_changes(repo_root) + user_email = cls.get_current_user_email(repo_root) + user_commits = cls.get_user_commits(repo_root, user_email) + recent_commits = cls.get_recent_commits(repo_root) + user_language = cls.get_user_language() + + return { + "changes": changes, + "userCommits": user_commits, + "recentCommits": recent_commits, + "workspaceFolder": workspace_folder, + "userLanguage": user_language, + } + +def prepare_conversation_edit_request(view: sublime.View) -> dict: + """Prepare document information for edit conversation request.""" + if not (doc := prepare_completion_request_doc(view)): + return {} + + # Get the current selection + selection = view.sel()[0] + selection_start = selection.begin() + selection_end = selection.end() + + # Get the line range for the selection + selection_line_start = view.line(selection_start).begin() + selection_line_end = view.line(selection_end).end() + + # Convert positions to LSP format (line, character) + def point_to_lsp_position(point: int) -> dict: + row, col = view.rowcol(point) + return { + "line": row, + "character": col + } + + return { + "text": view.substr(sublime.Region(0, view.size())), + "languageId": get_view_language_id(view), + "selection": { + "start": point_to_lsp_position(selection_start), + "end": point_to_lsp_position(selection_end) + }, + "range": { + "start": point_to_lsp_position(selection_line_start), + "end": point_to_lsp_position(selection_line_end) + } + } \ No newline at end of file diff --git a/plugin/templates/edit_conversation.md.jinja b/plugin/templates/edit_conversation.md.jinja new file mode 100644 index 0000000..c252528 --- /dev/null +++ b/plugin/templates/edit_conversation.md.jinja @@ -0,0 +1,153 @@ + + + + +--- + +{% if source_file %} +
{{ edit.newText }}
+