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 %}
+
Editing: {{ source_file }}
+
+ {% endif %}
+
Github Copilot
+ {%- else -%}
+ {% if avatar_img_src %}
Pending Edits ({{ pending_edits|length }})
+ {{ edit.newText }}
+