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 @@ ![Copilot](https://raw.githubusercontent.com/TheSecEng/LSP-copilot/master/docs/screenshot.png) -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 %} + +{% for section in sections %} + +
+ {% if section.kind == "assistant" or section.kind == "report" %} + + + {% else %} + + {% endif %} +
+ +
+ {%- if section.kind == "assistant" or section.kind == "report" -%} + Github Copilot + {%- else -%} + {% if avatar_img_src %}{% endif %} {{ section.kind }} + {%- endif -%} +
+ +{% if "edit-plan" in section.annotations %} +
+ Planned work +
{{ section.message | safe }}
+
+{% elif "edit-description" in section.annotations %} +
+ Overview +
{{ section.message | safe }}
+
+{% else %} +{{ section.message | safe }} +{% endif %} + +--- + +{% endfor %} + +{% if pending_edits %} +
+
+ Pending Edits ({{ pending_edits|length }}) +
+
+ +{% for edit in pending_edits %} +
+
+ Edit {{ loop.index }} + {% if edit.range %} + + {% if edit.range.start.line == edit.range.end.line %} + Line {{ edit.range.start.line + 1 }} + {% else %} + Lines {{ edit.range.start.line + 1 }}-{{ edit.range.end.line + 1 }} + {% endif %} + + {% endif %} +
+ + {% if edit.newText %} +
+
{{ edit.newText }}
+
+ {% else %} +
+ [Text removal] +
+ {% endif %} +
+{% endfor %} + +
+ Apply All Edits +
+ +--- +{% endif %} + +{% if is_waiting %} +
+ Waiting for Copilot response... +
+{% endif %} diff --git a/plugin/types.py b/plugin/types.py index 6857e1e..a37fbd9 100644 --- a/plugin/types.py +++ b/plugin/types.py @@ -302,3 +302,48 @@ class CopilotUserDefinedPromptTemplates(TypedDict, total=True): description: str prompt: list[str] scopes: list[str] + + +class CopilotModel(TypedDict, total=True): + modelFamily: str + modelName: str + scopes: list[str] + + +# --------------------------- # +# Copilot Edit Chat Types # +# --------------------------- # + +class CopilotPayloadEditConversationCreate(TypedDict, total=True): + conversationId: str + """Unique identifier for the edit conversation""" + turnId: str + """Identifier for the initial turn in the edit conversation""" + + +class CopilotPayloadEditConversationTurn(TypedDict, total=True): + conversationId: str + """Identifier for the edit conversation""" + turnId: str + """Identifier for this specific turn in the conversation""" + reply: str + """The assistant's reply""" + annotations: list[str] + """Metadata annotations for the turn""" + references: list[CopilotRequestConversationTurnReference | CopilotGitHubWebSearch] + """References to code or search results""" + + +class CopilotRequestEditConversationTurn(TypedDict, total=True): + conversationId: str + """Identifier for the edit conversation""" + message: str + """User's message/request""" + workDoneToken: str + """Token for progress tracking""" + doc: CopilotDocType + """Document information for contextual editing""" + editOptions: dict[str, Any] + """Options related to the editing operation""" + source: Literal["panel", "inline"] + """Source of the edit conversation""" diff --git a/plugin/ui/__init__.py b/plugin/ui/__init__.py index 3f88125..ca3f487 100644 --- a/plugin/ui/__init__.py +++ b/plugin/ui/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .chat import WindowConversationManager +from .chat import WindowConversationManager, WindowEditConversationManager from .completion import ViewCompletionManager from .panel_completion import ViewPanelCompletionManager @@ -8,4 +8,5 @@ "ViewCompletionManager", "ViewPanelCompletionManager", "WindowConversationManager", + "WindowEditConversationManager", ) diff --git a/plugin/ui/chat.py b/plugin/ui/chat.py index 47adbb8..62221ac 100644 --- a/plugin/ui/chat.py +++ b/plugin/ui/chat.py @@ -1,210 +1,407 @@ from __future__ import annotations -from typing import Callable +from typing import Callable, Any, TypeVar, Generic, Type +from abc import ABC, abstractmethod import mdpopups import sublime -from ..constants import COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX +from ..constants import COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, COPILOT_OUTPUT_PANEL_PREFIX from ..helpers import GithubInfo, preprocess_message_for_html from ..template import load_resource_template from ..types import CopilotPayloadConversationEntry, CopilotPayloadConversationEntryTransformed, StLayout from ..utils import find_view_by_id, find_window_by_id, get_copilot_setting, remove_prefix, set_copilot_setting +T = TypeVar('T', bound='BaseConversationEntry') -class WindowConversationManager: - # --------------- # - # window settings # - # --------------- # + +class ConversationSettingsManager: + """Mixin for managing conversation settings with consistent property patterns.""" + + def __init__(self, window: sublime.Window, settings_prefix: str): + self.window = window + self.settings_prefix = settings_prefix + + def _get_setting(self, key: str, default: Any = None) -> Any: + """Get a setting value.""" + return get_copilot_setting(self.window, self.settings_prefix, key, default) + + def _set_setting(self, key: str, value: Any) -> None: + """Set a setting value.""" + set_copilot_setting(self.window, self.settings_prefix, key, value) + + def _create_property(self, key: str, default: Any = None): + """Create a property descriptor for a setting.""" + def getter(self) -> Any: + return self._get_setting(key, default) + + def setter(self, value: Any) -> None: + self._set_setting(key, value) + + return property(getter, setter) + + +class BaseConversationManager(ConversationSettingsManager, ABC): + """Base class for conversation managers with shared functionality.""" + + def __init__(self, window: sublime.Window, settings_prefix: str): + super().__init__(window, settings_prefix) + self._ui_entry = None + + # Shared properties using descriptors + @property + def conversation_id(self) -> str: + return self._get_setting("conversation_id", "") + + @conversation_id.setter + def conversation_id(self, value: str) -> None: + self._set_setting("conversation_id", value) @property def group_id(self) -> int: - """The ID of the group which is used to show conversation panel.""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "view_group_id", -1) + return self._get_setting("group_id", -1) @group_id.setter def group_id(self, value: int) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "view_group_id", value) + self._set_setting("group_id", value) @property - def last_active_view_id(self) -> int: - """The ID of the last active view that is not the conversation panel""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "last_active_view_id", -1) + def view_id(self) -> int: + return self._get_setting("view_id", -1) - @last_active_view_id.setter - def last_active_view_id(self, value: int) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "last_active_view_id", value) + @view_id.setter + def view_id(self, value: int) -> None: + self._set_setting("view_id", value) @property def original_layout(self) -> StLayout | None: - """The original window layout prior to panel presentation.""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "original_layout", None) + return self._get_setting("original_layout", None) @original_layout.setter def original_layout(self, value: StLayout | None) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "original_layout", value) + self._set_setting("original_layout", value) @property - def view_id(self) -> int: - """The ID of the sheet which is used to show conversation panel.""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "view_id", -1) + def is_waiting(self) -> bool: + return self._get_setting("is_waiting", False) - @view_id.setter - def view_id(self, value: int) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "view_id", value) + @is_waiting.setter + def is_waiting(self, value: bool) -> None: + self._set_setting("is_waiting", value) @property - def suggested_title(self) -> str: - """Suggested title of the conversation""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "suggested_title", "") + def is_visible(self) -> bool: + return self._get_setting("is_visible", False) - @suggested_title.setter - def suggested_title(self, value: str) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "suggested_title", value) + @is_visible.setter + def is_visible(self, value: bool) -> None: + self._set_setting("is_visible", value) @property - def follow_up(self) -> str: - """Suggested follow up of the conversation provided by copilot.""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "follow_up", "") + def conversation_entries(self) -> list[CopilotPayloadConversationEntry]: + return self._get_setting("conversation_entries", []) - @follow_up.setter - def follow_up(self, value: str) -> None: - # Fixes: https://github.com/TerminalFi/LSP-copilot/issues/182 - # Replaces ` with ` to avoid breaking the HTML - set_copilot_setting( - self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "follow_up", value.replace("`", "`") + @conversation_entries.setter + def conversation_entries(self, value: list[CopilotPayloadConversationEntry]) -> None: + self._set_setting("conversation_entries", value) + + # Shared methods + def append_conversation_entry(self, entry: CopilotPayloadConversationEntry) -> None: + """Add a new conversation entry.""" + entries = self.conversation_entries + entries.append(entry) + self.conversation_entries = entries + + def reset_base_settings(self) -> None: + """Reset shared settings to defaults.""" + self.conversation_id = "" + self.group_id = -1 + self.view_id = -1 + self.original_layout = None + self.is_waiting = False + self.is_visible = False + self.conversation_entries = [] + + @abstractmethod + def reset(self) -> None: + """Reset all settings to defaults. Subclasses must implement.""" + pass + + @abstractmethod + def get_ui_entry(self) -> 'BaseConversationEntry': + """Get the UI entry for this conversation type.""" + pass + + def open(self) -> None: + """Open the conversation UI.""" + self.get_ui_entry().open() + + def update(self) -> None: + """Update the conversation UI.""" + self.get_ui_entry().update() + + def close(self) -> None: + """Close the conversation UI.""" + self.get_ui_entry().close() + + +class BaseConversationEntry(ABC): + """Base class for conversation UI entries.""" + + def __init__(self, window: sublime.Window, manager: BaseConversationManager): + self.window = window + self.manager = manager + + @property + @abstractmethod + def completion_content(self) -> str: + """Generate HTML content for the conversation sheet.""" + pass + + @property + @abstractmethod + def sheet_name(self) -> str: + """Name for the conversation sheet.""" + pass + + def open(self) -> None: + """Open the conversation sheet.""" + self.manager.is_visible = True + active_group = self.window.active_group() + if active_group == self.window.num_groups() - 1: + self._open_in_side_by_side(self.window) + else: + self._open_in_group(self.window, active_group + 1) + + self.window.focus_view(self.window.active_view()) + + def update(self) -> None: + """Update the conversation sheet content.""" + if not (sheet := self.window.transient_sheet_in_group(self.manager.group_id)): + return + + mdpopups.update_html_sheet( + sheet=sheet, + contents=self.completion_content, + md=True, + wrapper_class="wrapper" ) + def close(self) -> None: + """Close the conversation sheet.""" + if not (sheet := self.window.transient_sheet_in_group(self.manager.group_id)): + return + + sheet.close() + + self.manager.is_visible = False + self.manager.window.run_command("hide_panel") + if self.manager.original_layout: + self.window.set_layout(self.manager.original_layout) + self.manager.original_layout = None + + if view := self.window.active_view(): + self.window.focus_view(view) + + def _open_in_group(self, window: sublime.Window, group_id: int) -> None: + """Open the conversation sheet in a specific group.""" + self.manager.group_id = group_id + + window.focus_group(group_id) + sheet = mdpopups.new_html_sheet( + window=window, + name=self.sheet_name, + contents=self.completion_content, + md=True, + flags=sublime.TRANSIENT, + wrapper_class="wrapper", + ) + self.manager.view_id = sheet.id() + + def _open_in_side_by_side(self, window: sublime.Window) -> None: + """Open the conversation sheet in side-by-side layout.""" + self.manager.original_layout = window.layout() + window.set_layout({ + "cols": [0.0, 0.5, 1.0], + "rows": [0.0, 1.0], + "cells": [[0, 0, 1, 1], [1, 0, 2, 1]], + }) + self._open_in_group(window, 1) + + +class WindowConversationManager(BaseConversationManager): + """Manager for regular chat conversations.""" + + def __init__(self, window: sublime.Window): + super().__init__(window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX) + + # Chat-specific properties @property - def conversation_id(self) -> str: - """The conversation uuid used to identify the conversation.""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "conversation_id", "") + def last_active_view_id(self) -> int: + return self._get_setting("last_active_view_id", -1) - @conversation_id.setter - def conversation_id(self, value: str) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "conversation_id", value) + @last_active_view_id.setter + def last_active_view_id(self, value: int) -> None: + self._set_setting("last_active_view_id", value) @property - def code_block_index(self) -> dict[str, str]: - """The tracking of code blocks across the conversation. Used to support Copy and Insert code commands.""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "code_block_index", {}) + def suggested_title(self) -> str: + return self._get_setting("suggested_title", "") - @code_block_index.setter - def code_block_index(self, value: dict[str, str]) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "code_block_index", value) + @suggested_title.setter + def suggested_title(self, value: str) -> None: + self._set_setting("suggested_title", value) @property - def is_waiting(self) -> bool: - """Whether the converation completions is streaming.""" - return get_copilot_setting( - self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "is_waiting_conversation", False - ) + def follow_up(self) -> str: + return self._get_setting("follow_up", "") - @is_waiting.setter - def is_waiting(self, value: bool) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "is_waiting_conversation", value) + @follow_up.setter + def follow_up(self, value: str) -> None: + # Fixes: https://github.com/TerminalFi/LSP-copilot/issues/182 + self._set_setting("follow_up", value.replace("`", "`")) @property - def is_visible(self) -> bool: - """Whether the converation completions is streaming.""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "is_visible", False) + def code_block_index(self) -> dict[str, str]: + return self._get_setting("code_block_index", {}) - @is_visible.setter - def is_visible(self, value: bool) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "is_visible", value) + @code_block_index.setter + def code_block_index(self, value: dict[str, str]) -> None: + self._set_setting("code_block_index", value) @property def reference_block_state(self) -> dict[str, bool]: - return get_copilot_setting( - self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "reference_block_state", {} - ) + return self._get_setting("reference_block_state", {}) @reference_block_state.setter def reference_block_state(self, value: dict[str, bool]) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "reference_block_state", value) + self._set_setting("reference_block_state", value) @property def conversation(self) -> list[CopilotPayloadConversationEntry]: - """All `conversation` in the view. Note that this is a copy.""" - return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "conversation_entries", []) + """Alias for conversation_entries for backward compatibility.""" + return self.conversation_entries @conversation.setter def conversation(self, value: list[CopilotPayloadConversationEntry]) -> None: - set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "conversation_entries", value) - - # -------------- # - # normal methods # - # -------------- # - - def __init__(self, window: sublime.Window) -> None: - self.window = window + self.conversation_entries = value def reset(self) -> None: - self.is_waiting = False - self.is_visible = False - self.original_layout = None + """Reset all chat conversation settings.""" + self.reset_base_settings() + self.last_active_view_id = -1 self.suggested_title = "" self.follow_up = "" - self.conversation_id = "" - self.conversation = [] - self.reference_block_state = {} self.code_block_index = {} + self.reference_block_state = {} - if view := find_view_by_id(self.view_id): - view.close() - - def append_conversation_entry(self, entry: CopilotPayloadConversationEntry) -> None: - # `self.conversation` is a deepcopy of the original value - # So if we do `self.conversation.append(entry)`, the source value won't be modified - conversation_history = self.conversation - conversation_history.append(entry) - self.conversation = conversation_history - self.append_reference_block_state(entry["turnId"], False) + def get_ui_entry(self) -> '_ConversationEntry': + """Get the UI entry for chat conversations.""" + if not self._ui_entry: + self._ui_entry = _ConversationEntry(self.window, self) + return self._ui_entry + # Chat-specific methods def append_reference_block_state(self, turn_id: str, state: bool) -> None: - # `self.reference_block_state` is a deepcopy of the original value - # So if we do self.`reference_block_state[turn_id] = state`, the source value won't be modified reference_block_state = self.reference_block_state reference_block_state[turn_id] = state self.reference_block_state = reference_block_state def insert_code_block_index(self, index: int, code_block: str) -> None: - # `self.code_block_index` is a deepcopy of the original value - # So if we do `self.code_block_index[str(index)] = code_block_index`, the source value won't be modified code_block_index = self.code_block_index code_block_index[str(index)] = code_block self.code_block_index = code_block_index def toggle_references_block(self, turn_id: str) -> None: reference_block_state = self.reference_block_state - reference_block_state.setdefault(turn_id, False) - reference_block_state[turn_id] = not reference_block_state[turn_id] + reference_block_state[turn_id] = not reference_block_state.get(turn_id, False) self.reference_block_state = reference_block_state @staticmethod def find_window_by_token_id(token_id: str) -> sublime.Window | None: - window_id = int(remove_prefix(token_id, "copilot_chat://")) - return find_window_by_id(window_id) + return sublime.active_window() def prompt(self, callback: Callable[[str], None], initial_text: str = "") -> None: - self.window.show_input_panel("Copilot Chat", initial_text, callback, None, None) + self.window.show_input_panel("Copilot:", initial_text, callback, None, None) - def open(self) -> None: - _ConversationEntry(self.window).open() - def update(self) -> None: - """Update the completion panel.""" - _ConversationEntry(self.window).update() +class WindowEditConversationManager(BaseConversationManager): + """Manager for edit conversations.""" - def close(self) -> None: - """Close the completion panel.""" - _ConversationEntry(self.window).close() + EDIT_CONVERSATION_SETTINGS_PREFIX = "copilot.window.edit_conversation" + def __init__(self, window: sublime.Window): + super().__init__(window, self.EDIT_CONVERSATION_SETTINGS_PREFIX) -class _ConversationEntry: - def __init__(self, window: sublime.Window) -> None: - self.window = window - self.wcm = WindowConversationManager(window) + # Edit-specific properties + @property + def source_view_id(self) -> int: + return self._get_setting("source_view_id", -1) + + @source_view_id.setter + def source_view_id(self, value: int) -> None: + self._set_setting("source_view_id", value) + + @property + def pending_edits(self) -> list[dict[str, Any]]: + return self._get_setting("pending_edits", []) + + @pending_edits.setter + def pending_edits(self, value: list[dict[str, Any]]) -> None: + self._set_setting("pending_edits", value) + + def reset(self) -> None: + """Reset all edit conversation settings.""" + self.reset_base_settings() + self.source_view_id = -1 + self.pending_edits = [] + + def get_ui_entry(self) -> '_EditConversationEntry': + """Get the UI entry for edit conversations.""" + if not self._ui_entry: + self._ui_entry = _EditConversationEntry(self.window, self) + return self._ui_entry + + # Edit-specific methods + def add_pending_edit(self, edit: dict[str, Any]) -> None: + """Add a pending edit that can be applied to the source view.""" + edits = self.pending_edits + edits.append(edit) + self.pending_edits = edits + + def clear_pending_edits(self) -> None: + """Clear all pending edits.""" + self.pending_edits = [] + + def get_source_view(self) -> sublime.View | None: + """Get the source view being edited, if it still exists.""" + return find_view_by_id(self.source_view_id) + + def destroy(self) -> None: + """Destroy the edit conversation and clean up all resources.""" + self.get_ui_entry().close() + self.reset() + + @staticmethod + def find_by_conversation_id(conversation_id: str) -> 'WindowEditConversationManager | None': + """Find an edit conversation manager by conversation ID across all windows.""" + for window in sublime.windows(): + wecm = WindowEditConversationManager(window) + if wecm.conversation_id == conversation_id: + return wecm + return None + + +class _ConversationEntry(BaseConversationEntry): + """UI entry for regular chat conversations.""" + + def __init__(self, window: sublime.Window, manager: WindowConversationManager): + super().__init__(window, manager) + self.wcm = manager # Type-specific reference for backward compatibility + + @property + def sheet_name(self) -> str: + return "Copilot Chat" @property def completion_content(self) -> str: @@ -319,56 +516,121 @@ def inject_code_block_commands(reply: str, code_block_index: int) -> str: return transformed_conversation - def open(self) -> None: - self.wcm.is_visible = True - active_group = self.window.active_group() - if active_group == self.window.num_groups() - 1: - self._open_in_side_by_side(self.window) - else: - self._open_in_group(self.window, active_group + 1) - - self.window.focus_view(self.window.active_view()) # type: ignore - def update(self) -> None: - if not (sheet := self.window.transient_sheet_in_group(self.wcm.group_id)): - return +class _EditConversationEntry(BaseConversationEntry): + """UI entry for edit conversations.""" - mdpopups.update_html_sheet(sheet=sheet, contents=self.completion_content, md=True, wrapper_class="wrapper") + def __init__(self, window: sublime.Window, manager: WindowEditConversationManager): + super().__init__(window, manager) + self.wecm = manager # Type-specific reference - def close(self) -> None: - if not (sheet := self.window.transient_sheet_in_group(self.wcm.group_id)): - return - - sheet.close() - - self.wcm.is_visible = False - self.wcm.window.run_command("hide_panel") - if self.wcm.original_layout: - self.window.set_layout(self.wcm.original_layout) # type: ignore - self.wcm.original_layout = None - - if view := self.window.active_view(): - self.window.focus_view(view) - - def _open_in_group(self, window: sublime.Window, group_id: int) -> None: - self.wcm.group_id = group_id + @property + def sheet_name(self) -> str: + return "Copilot Edit" - window.focus_group(group_id) - sheet = mdpopups.new_html_sheet( - window=window, - name="Copilot Chat", - contents=self.completion_content, - md=True, - flags=sublime.TRANSIENT, - wrapper_class="wrapper", + @property + def completion_content(self) -> str: + """Generate the HTML content for the edit conversation sheet.""" + # Get source file name if available + source_file = "" + if source_view := self.wecm.get_source_view(): + if file_name := source_view.file_name(): + import os + source_file = os.path.basename(file_name) + + # Process conversation entries into sections + sections = [] + for entry in self.wecm.conversation_entries: + sections.append({ + "kind": entry.get("kind", "unknown"), + "message": entry.get("reply", ""), + "turnId": entry.get("turnId", ""), + "annotations": entry.get("annotations", []), + "thumbs_up_url": sublime.command_url( + "copilot_conversation_rating_shim", + {"turn_id": entry.get("turnId", ""), "rating": 1} + ), + "thumbs_down_url": sublime.command_url( + "copilot_conversation_rating_shim", + {"turn_id": entry.get("turnId", ""), "rating": -1} + ), + "turn_delete_url": sublime.command_url( + "copilot_edit_conversation_turn_delete", + { + "conversation_id": self.wecm.conversation_id, + "turn_id": entry.get("turnId", "") + } + ), + }) + + return load_resource_template("edit_conversation.md.jinja", keep_trailing_newline=True).render( + window_id=self.window.id(), + is_waiting=self.wecm.is_waiting, + avatar_img_src=GithubInfo.get_avatar_img_src(), + source_file=source_file, + sections=sections, + pending_edits=self.wecm.pending_edits, + close_url=sublime.command_url( + "copilot_edit_conversa~tion_close", + {"conversation_id": self.wecm.conversation_id} + ), + destroy_url=sublime.command_url( + "copilot_edit_conversation_destroy_shim", + {"conversation_id": self.wecm.conversation_id} + ), + apply_edits_url=sublime.command_url( + "copilot_apply_edit_conversation_edits", + {} + ), ) - self.wcm.view_id = sheet.id() - def _open_in_side_by_side(self, window: sublime.Window) -> None: - self.wcm.original_layout = window.layout() # type: ignore - window.set_layout({ - "cols": [0.0, 0.5, 1.0], - "rows": [0.0, 1.0], - "cells": [[0, 0, 1, 1], [1, 0, 2, 1]], + def prompt_for_message(self, callback: Callable[[str], None], initial_text: str = "") -> None: + """Show input panel for new message in edit conversation.""" + self.window.show_input_panel("Edit Conversation:", initial_text, callback, None, None) + + def add_user_message(self, message: str) -> None: + """Add a user message to the edit conversation.""" + import uuid + + self.wecm.append_conversation_entry({ + "kind": "user", + "conversationId": self.wecm.conversation_id, + "reply": preprocess_message_for_html(message), + "turnId": str(uuid.uuid4()), + "references": [], + "annotations": [], + "hideText": False, + "warnings": [], }) - self._open_in_group(window, 1) + self.update() + + def add_assistant_message(self, message: str, turn_id: str | None = None) -> None: + """Add an assistant message to the edit conversation.""" + import uuid + + self.wecm.append_conversation_entry({ + "kind": "assistant", + "conversationId": self.wecm.conversation_id, + "reply": preprocess_message_for_html(message), + "turnId": turn_id or str(uuid.uuid4()), + "references": [], + "annotations": [], + "hideText": False, + "warnings": [], + }) + self.update() + + def show_waiting_state(self, waiting: bool = True) -> None: + """Show or hide waiting state in the sheet.""" + self.wecm.is_waiting = waiting + self.update() + + def add_pending_edit(self, edit: dict[str, Any]) -> None: + """Add a pending edit and update the sheet.""" + self.wecm.add_pending_edit(edit) + self.update() + + def clear_pending_edits(self) -> None: + """Clear pending edits and update the sheet.""" + self.wecm.clear_pending_edits() + self.update()