├── .copilotignore ├── .python-version ├── plugin ├── assets │ ├── copy.png │ ├── close.png │ ├── expand.png │ ├── github.png │ ├── insert.png │ ├── trash.png │ ├── collapse.png │ ├── thumbs_up.png │ ├── thumbs_down.png │ ├── white-pixel.png │ ├── chat_panel.custom.css │ ├── completion@popup.custom.css │ ├── panel_completion.custom.css │ ├── panel_completion.css │ ├── completion@popup.css │ └── chat_panel.css ├── ui │ ├── __init__.py │ ├── panel_completion.py │ ├── completion.py │ └── chat.py ├── decorators.py ├── settings.py ├── templates │ ├── panel_completion.md.jinja │ ├── completion@popup.md.jinja │ └── chat_panel.md.jinja ├── log.py ├── constants.py ├── template.py ├── __init__.py ├── types.py ├── utils.py ├── listeners.py ├── helpers.py ├── client.py └── commands.py ├── language-server ├── package.json └── package-lock.json ├── LSP-copilot.sublime-commands ├── boot.py ├── dependencies.json ├── syntax └── copilotignore.sublime-syntax ├── LSP-copilot.sublime-settings ├── Default.sublime-keymap ├── Main.sublime-menu ├── Main.sublime-commands ├── README.md └── sublime-package.json /.copilotignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.8 2 | -------------------------------------------------------------------------------- /plugin/assets/copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/copy.png -------------------------------------------------------------------------------- /plugin/assets/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/close.png -------------------------------------------------------------------------------- /plugin/assets/expand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/expand.png -------------------------------------------------------------------------------- /plugin/assets/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/github.png -------------------------------------------------------------------------------- /plugin/assets/insert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/insert.png -------------------------------------------------------------------------------- /plugin/assets/trash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/trash.png -------------------------------------------------------------------------------- /plugin/assets/collapse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/collapse.png -------------------------------------------------------------------------------- /plugin/assets/thumbs_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/thumbs_up.png -------------------------------------------------------------------------------- /plugin/assets/thumbs_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/thumbs_down.png -------------------------------------------------------------------------------- /plugin/assets/white-pixel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TerminalFi/LSP-copilot/HEAD/plugin/assets/white-pixel.png -------------------------------------------------------------------------------- /language-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "copilot-node-server": "^1.41.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /plugin/assets/chat_panel.custom.css: -------------------------------------------------------------------------------- 1 | /* User extra CSS rules: Chat Panel */ 2 | /* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */ 3 | -------------------------------------------------------------------------------- /plugin/assets/completion@popup.custom.css: -------------------------------------------------------------------------------- 1 | /* User extra CSS rules: Popup Completion */ 2 | /* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */ 3 | -------------------------------------------------------------------------------- /plugin/assets/panel_completion.custom.css: -------------------------------------------------------------------------------- 1 | /* User extra CSS rules: Panel Completion */ 2 | /* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */ 3 | -------------------------------------------------------------------------------- /plugin/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .chat import WindowConversationManager 4 | from .completion import ViewCompletionManager 5 | from .panel_completion import ViewPanelCompletionManager 6 | 7 | __all__ = ( 8 | "ViewCompletionManager", 9 | "ViewPanelCompletionManager", 10 | "WindowConversationManager", 11 | ) 12 | -------------------------------------------------------------------------------- /LSP-copilot.sublime-commands: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Preferences: LSP-copilot Settings", 4 | "command": "edit_settings", 5 | "args": { 6 | "base_file": "${packages}/LSP-copilot/LSP-copilot.sublime-settings", 7 | "default": "// Settings in here override those in \"LSP-copilot/LSP-copilot.sublime-settings\"\n\n{\n\t$0\n}\n", 8 | }, 9 | }, 10 | ] 11 | -------------------------------------------------------------------------------- /boot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def reload_plugin() -> None: 5 | import sys 6 | 7 | # remove all previously loaded plugin modules 8 | prefix = f"{__package__}." 9 | for module_name in tuple(filter(lambda m: m.startswith(prefix) and m != __name__, sys.modules)): 10 | del sys.modules[module_name] 11 | 12 | 13 | reload_plugin() 14 | 15 | from .plugin import * # noqa: E402, F403 16 | -------------------------------------------------------------------------------- /dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": { 3 | "*": [ 4 | "bracex", 5 | "charset-normalizer", 6 | "idna", 7 | "Jinja2", 8 | "jmespath", 9 | "lsp_utils", 10 | "markupsafe", 11 | "more-itertools", 12 | "requests", 13 | "sublime_lib", 14 | "urllib3", 15 | "watchdog", 16 | "wcmatch" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /syntax/copilotignore.sublime-syntax: -------------------------------------------------------------------------------- 1 | %YAML 1.2 2 | --- 3 | name: Copilot Ignore 4 | scope: text.copilotignore 5 | version: 2 6 | hidden: true 7 | hidden_file_extensions: 8 | - .copilotignore 9 | 10 | contexts: 11 | main: 12 | - include: Git Common.sublime-syntax#comments 13 | - match: '(?=\S)' 14 | push: [pattern-content, Git Common.sublime-syntax#fnmatch-start] 15 | 16 | pattern-content: 17 | - meta_scope: string.unquoted.copilotignore entity.name.pattern.copilotignore 18 | - match: $ 19 | pop: 1 20 | - include: Git Common.sublime-syntax#fnmatch-unquoted-body 21 | -------------------------------------------------------------------------------- /plugin/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Any, Callable, cast 3 | 4 | from .types import T_Callable 5 | from .utils import ( 6 | is_active_view, 7 | ) 8 | 9 | 10 | def must_be_active_view(*, failed_return: Any = None) -> Callable[[T_Callable], T_Callable]: 11 | def decorator(func: T_Callable) -> T_Callable: 12 | @wraps(func) 13 | def wrapped(self: Any, *arg, **kwargs) -> Any: 14 | if is_active_view(self.view): 15 | return func(self, *arg, **kwargs) 16 | return failed_return 17 | 18 | return cast(T_Callable, wrapped) 19 | 20 | return decorator 21 | -------------------------------------------------------------------------------- /plugin/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import lru_cache 4 | from typing import Any 5 | 6 | import jmespath 7 | import sublime 8 | 9 | from .constants import PACKAGE_NAME 10 | 11 | 12 | @lru_cache 13 | def _compile_jmespath_expression(expression: str) -> jmespath.parser.ParsedResult: 14 | return jmespath.compile(expression) 15 | 16 | 17 | def get_plugin_settings() -> sublime.Settings: 18 | return sublime.load_settings(f"{PACKAGE_NAME}.sublime-settings") 19 | 20 | 21 | def get_plugin_setting(key: str, default: Any = None) -> Any: 22 | return get_plugin_settings().get(key, default) 23 | 24 | 25 | def get_plugin_setting_dotted(dotted: str, default: Any = None) -> Any: 26 | return _compile_jmespath_expression(dotted).search(get_plugin_settings()) or default 27 | -------------------------------------------------------------------------------- /plugin/templates/panel_completion.md.jinja: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 8 | 18 | 19 | {% for section in sections %} 20 |
21 |
22 | Accept 23 |
24 | 25 | ``````{{ section.lang }} 26 | {{ section.code }} 27 | `````` 28 | 29 | {% endfor %} 30 | 31 |
32 | -------------------------------------------------------------------------------- /plugin/templates/completion@popup.md.jinja: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 8 |
9 | Accept 10 | × Reject 11 | {% if count > 1 %} 12 | 21 | ({{ index + 1 }} of {{ count }}) 22 | {% endif %} 23 | 24 |
25 | 26 | ``````{{ lang }} 27 | {{ code }} 28 | `````` 29 | 30 |
31 | -------------------------------------------------------------------------------- /language-server/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "language-server", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "copilot-node-server": "^1.41.0" 9 | } 10 | }, 11 | "node_modules/copilot-node-server": { 12 | "version": "1.41.0", 13 | "resolved": "https://registry.npmjs.org/copilot-node-server/-/copilot-node-server-1.41.0.tgz", 14 | "integrity": "sha512-r2+uaWa05wvxNALv8rLegRCOlcopUDLYOd8kAHTAM8xpqBNK5TcMqFbGufxKF7YIWpBwcyfNaAIb724Un5e1eA==", 15 | "bin": { 16 | "copilot-node-server": "copilot/dist/language-server.js" 17 | } 18 | } 19 | }, 20 | "dependencies": { 21 | "copilot-node-server": { 22 | "version": "1.41.0", 23 | "resolved": "https://registry.npmjs.org/copilot-node-server/-/copilot-node-server-1.41.0.tgz", 24 | "integrity": "sha512-r2+uaWa05wvxNALv8rLegRCOlcopUDLYOd8kAHTAM8xpqBNK5TcMqFbGufxKF7YIWpBwcyfNaAIb724Un5e1eA==" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /plugin/log.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import sublime 6 | 7 | from .constants import PACKAGE_NAME 8 | 9 | 10 | def log_debug(message: str) -> None: 11 | print(f"[{PACKAGE_NAME}][DEBUG] {message}") 12 | 13 | 14 | def log_info(message: str) -> None: 15 | print(f"[{PACKAGE_NAME}][INFO] {message}") 16 | 17 | 18 | def log_warning(message: str) -> None: 19 | print(f"[{PACKAGE_NAME}][WARNING] {message}") 20 | 21 | 22 | def log_error(message: str) -> None: 23 | print(f"[{PACKAGE_NAME}][ERROR] {message}") 24 | 25 | 26 | def pluginfy_msg(msg: str, *args: Any, **kwargs: Any) -> str: 27 | return msg.format(*args, _=PACKAGE_NAME, **kwargs) 28 | 29 | 30 | def console_msg(msg: str, *args: Any, **kwargs: Any) -> None: 31 | print(pluginfy_msg(msg, *args, **kwargs)) 32 | 33 | 34 | def status_msg(msg: str, *args: Any, **kwargs: Any) -> None: 35 | sublime.status_message(pluginfy_msg(msg, *args, **kwargs)) 36 | 37 | 38 | def info_box(msg: str, *args: Any, **kwargs: Any) -> None: 39 | sublime.message_dialog(pluginfy_msg(msg, *args, **kwargs)) 40 | 41 | 42 | def error_box(msg: str, *args: Any, **kwargs: Any) -> None: 43 | sublime.error_message(pluginfy_msg(msg, *args, **kwargs)) 44 | -------------------------------------------------------------------------------- /plugin/assets/panel_completion.css: -------------------------------------------------------------------------------- 1 | html { 2 | --copilot-close-foreground: var(--foreground); 3 | --copilot-close-background: var(--background); 4 | --copilot-close-border: var(--foreground); 5 | --copilot-accept-foreground: var(--foreground); 6 | --copilot-accept-background: var(--background); 7 | --copilot-accept-border: var(--greenish); 8 | } 9 | 10 | .wrapper { 11 | margin: 1rem 0.5rem 0 0.5rem; 12 | } 13 | 14 | .wrapper .navbar { 15 | text-align: left; 16 | } 17 | 18 | .wrapper .synthesis-info { 19 | display: inline-block; 20 | font-size: 1.2em; 21 | } 22 | 23 | .wrapper .header { 24 | display: block; 25 | margin-bottom: 1rem; 26 | } 27 | 28 | .wrapper a { 29 | border-radius: 3px; 30 | border-style: solid; 31 | border-width: 1px; 32 | display: inline; 33 | padding: 5px; 34 | text-decoration: none; 35 | } 36 | 37 | .wrapper a.close { 38 | background: var(--copilot-close-background); 39 | border-color: var(--copilot-close-border); 40 | color: var(--copilot-close-foreground); 41 | } 42 | 43 | .wrapper a.close i { 44 | color: var(--copilot-close-border); 45 | } 46 | 47 | .wrapper a.accept { 48 | background: var(--copilot-accept-background); 49 | border-color: var(--copilot-accept-border); 50 | color: var(--copilot-accept-foreground); 51 | } 52 | 53 | .wrapper a.accept i { 54 | color: var(--copilot-accept-border); 55 | } 56 | -------------------------------------------------------------------------------- /plugin/assets/completion@popup.css: -------------------------------------------------------------------------------- 1 | html { 2 | --copilot-accept-foreground: var(--foreground); 3 | --copilot-accept-background: var(--background); 4 | --copilot-accept-border: var(--greenish); 5 | --copilot-reject-foreground: var(--foreground); 6 | --copilot-reject-background: var(--background); 7 | --copilot-reject-border: var(--yellowish); 8 | } 9 | 10 | .wrapper { 11 | margin: 1rem 0.5rem 0 0.5rem; 12 | } 13 | 14 | .wrapper .header { 15 | display: block; 16 | margin-bottom: 1rem; 17 | } 18 | 19 | .wrapper a { 20 | border-radius: 3px; 21 | border-style: solid; 22 | border-width: 1px; 23 | display: inline; 24 | padding: 5px; 25 | text-decoration: none; 26 | } 27 | 28 | .wrapper a.accept { 29 | background: var(--copilot-accept-background); 30 | border-color: var(--copilot-accept-border); 31 | color: var(--copilot-accept-foreground); 32 | } 33 | 34 | .wrapper a.accept i { 35 | color: var(--copilot-accept-border); 36 | } 37 | 38 | .wrapper a.reject { 39 | background: var(--copilot-reject-background); 40 | border-color: var(--copilot-reject-border); 41 | color: var(--copilot-reject-foreground); 42 | } 43 | 44 | .wrapper a.reject i { 45 | color: var(--copilot-reject-border); 46 | } 47 | 48 | .wrapper a.prev { 49 | border-top-right-radius: 0; 50 | border-bottom-right-radius: 0; 51 | border-right-width: 0; 52 | padding-left: 8px; 53 | padding-right: 8px; 54 | } 55 | 56 | .wrapper a.next { 57 | border-top-left-radius: 0; 58 | border-bottom-left-radius: 0; 59 | border-left-width: 0; 60 | padding-left: 8px; 61 | padding-right: 8px; 62 | } 63 | 64 | .wrapper a.panel { 65 | padding-left: 8px; 66 | padding-right: 8px; 67 | } 68 | -------------------------------------------------------------------------------- /LSP-copilot.sublime-settings: -------------------------------------------------------------------------------- 1 | { 2 | "command": [ 3 | "${node_bin}", 4 | "${server_path}", 5 | "--stdio" 6 | ], 7 | "schemes": [ 8 | "file", 9 | "buffer", 10 | "res" 11 | ], 12 | "settings": { 13 | "auto_ask_completions": true, 14 | "commit_completion_on_tab": true, 15 | "completion_style": "popup", 16 | "debug": false, 17 | "hook_to_auto_complete_command": false, 18 | "local_checks": false, 19 | "proxy": "", 20 | "prompts": [ 21 | { 22 | "id": "review", 23 | "description": "Review code and provide feedback.", 24 | "prompt": [ 25 | "Review the referenced code and provide feedback.", 26 | "Feedback should first reply back with the line or lines of code, followed by the feedback about the code.", 27 | "Do not invent new problems.", 28 | "The feedback should be constructive and aim to improve the code quality.", 29 | "If there are no issues detected, reply that the code looks good and no changes are necessary.", 30 | "Group related feedback into a single comment if possible.", 31 | "Present each comment with a brief description of the issue and a suggestion for improvement.", 32 | "Use the format `Comment #: [description] [suggestion]` for each comment, # representing the number of comments.", 33 | "At last provide a summary of the overall code quality and any general suggestions for improvement.", 34 | ] 35 | } 36 | ], 37 | // The (Jinja2) template of the status bar text which is inside the parentheses `(...)`. 38 | // See https://jinja.palletsprojects.com/templates/ 39 | "status_text": "{% if is_copilot_ignored %}{{ is_copilot_ignored }}{% elif is_waiting %}{{ is_waiting }}{% elif server_version %}v{{ server_version }}{% endif %}", 40 | "telemetry": { 41 | "telemetryLevel": null 42 | }, 43 | }, 44 | // ST4 configuration 45 | "selector": "source | text | embedding" 46 | } 47 | -------------------------------------------------------------------------------- /plugin/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | assert __package__ 4 | 5 | PACKAGE_NAME = __package__.partition(".")[0] 6 | 7 | # ---------------- # 8 | # Setting prefixes # 9 | # ---------------- # 10 | 11 | COPILOT_OUTPUT_PANEL_PREFIX = "copilot" 12 | COPILOT_VIEW_SETTINGS_PREFIX = "copilot.completion" 13 | COPILOT_WINDOW_SETTINGS_PREFIX = "copilot" 14 | COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX = "copilot.conversation" 15 | 16 | # ---------------- # 17 | # Copilot requests # 18 | # ---------------- # 19 | 20 | REQ_CHECK_STATUS = "checkStatus" 21 | REQ_FILE_CHECK_STATUS = "checkFileStatus" 22 | REQ_GET_COMPLETIONS = "getCompletions" 23 | REQ_GET_COMPLETIONS_CYCLING = "getCompletionsCycling" 24 | REQ_GET_PROMPT = "getPrompt" 25 | REQ_GET_PANEL_COMPLETIONS = "getPanelCompletions" 26 | REQ_GET_VERSION = "getVersion" 27 | REQ_NOTIFY_ACCEPTED = "notifyAccepted" 28 | REQ_NOTIFY_REJECTED = "notifyRejected" 29 | REQ_NOTIFY_SHOWN = "notifyShown" 30 | REQ_RECORD_TELEMETRY_CONSENT = "recordTelemetryConsent" 31 | REQ_SET_EDITOR_INFO = "setEditorInfo" 32 | REQ_SIGN_IN_CONFIRM = "signInConfirm" 33 | REQ_SIGN_IN_INITIATE = "signInInitiate" 34 | REQ_SIGN_IN_WITH_GITHUB_TOKEN = "signInWithGithubToken" 35 | REQ_SIGN_OUT = "signOut" 36 | 37 | # --------------------- # 38 | # Copilot chat requests # 39 | # --------------------- # 40 | 41 | REQ_CONVERSATION_AGENTS = "conversation/agents" 42 | REQ_CONVERSATION_CONTEXT = "conversation/context" 43 | REQ_CONVERSATION_COPY_CODE = "conversation/copyCode" 44 | REQ_CONVERSATION_CREATE = "conversation/create" 45 | REQ_CONVERSATION_DESTROY = "conversation/destroy" 46 | REQ_CONVERSATION_INSERT_CODE = "conversation/insertCode" 47 | REQ_CONVERSATION_PERSISTANCE = "conversation/persistance" 48 | REQ_CONVERSATION_PRECONDITIONS = "conversation/preconditions" 49 | REQ_CONVERSATION_RATING = "conversation/rating" 50 | REQ_CONVERSATION_TEMPLATES = "conversation/templates" 51 | REQ_CONVERSATION_TURN = "conversation/turn" 52 | REQ_CONVERSATION_TURN_DELETE = "conversation/turnDelete" 53 | 54 | # --------------------- # 55 | # Copilot notifications # 56 | # --------------------- # 57 | 58 | NTFY_FEATURE_FLAGS_NOTIFICATION = "featureFlagsNotification" 59 | NTFY_LOG_MESSAGE = "LogMessage" 60 | NTFY_PANEL_SOLUTION = "PanelSolution" 61 | NTFY_PANEL_SOLUTION_DONE = "PanelSolutionsDone" 62 | NTFY_PROGRESS = "$/progress" 63 | NTFY_STATUS_NOTIFICATION = "statusNotification" 64 | -------------------------------------------------------------------------------- /plugin/template.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import lru_cache 4 | from typing import Iterable 5 | 6 | import jinja2 7 | import sublime 8 | from LSP.plugin.core.url import parse_uri 9 | 10 | from .constants import PACKAGE_NAME 11 | from .helpers import is_debug_mode 12 | 13 | 14 | @lru_cache 15 | def load_string_template(template: str, *, keep_trailing_newline: bool = False) -> jinja2.Template: 16 | return _JINJA_TEMPLATE_ENV.overlay(keep_trailing_newline=keep_trailing_newline).from_string(template) 17 | 18 | 19 | @lru_cache 20 | def load_resource_template(template_path: str, *, keep_trailing_newline: bool = False) -> jinja2.Template: 21 | content = sublime.load_resource(f"Packages/{PACKAGE_NAME}/plugin/templates/{template_path}") 22 | return load_string_template(content, keep_trailing_newline=keep_trailing_newline) 23 | 24 | 25 | def asset_url(asset_path: str) -> str: 26 | return f"res://{_plugin_asset_path(asset_path)}" 27 | 28 | 29 | def include_asset(asset_path: str, *, use_cache: bool = True) -> str: 30 | if not use_cache or asset_path not in _RESOURCE_ASSET_CACHES or is_debug_mode(): 31 | _RESOURCE_ASSET_CACHES[asset_path] = sublime.load_resource(_plugin_asset_path(asset_path)) 32 | return _RESOURCE_ASSET_CACHES[asset_path] 33 | 34 | 35 | def multi_replace(message: str, replacements: Iterable[tuple[str, str]]) -> str: 36 | for old, new in replacements: 37 | message = message.replace(old, new) 38 | return message 39 | 40 | 41 | def uri_to_filename(uri: str, row: int | None = None, col: int | None = None) -> str: 42 | filename = parse_uri(uri)[1] 43 | if row is not None: 44 | filename += f":{row}" 45 | if col is not None: 46 | if row is None: 47 | raise ValueError("Column cannot be specified without row.") 48 | filename += f":{col}" 49 | return filename 50 | 51 | 52 | def _plugin_asset_path(asset_path: str) -> str: 53 | return f"Packages/{PACKAGE_NAME}/plugin/assets/{asset_path}" 54 | 55 | 56 | _JINJA_TEMPLATE_ENV = jinja2.Environment( 57 | extensions=["jinja2.ext.do", "jinja2.ext.loopcontrols"], 58 | ) 59 | _JINJA_TEMPLATE_ENV.filters.update( 60 | multi_replace=multi_replace, 61 | ) 62 | _JINJA_TEMPLATE_ENV.globals.update( 63 | # functions 64 | asset_url=asset_url, 65 | command_url=sublime.command_url, 66 | include_asset=include_asset, 67 | is_debug_mode=is_debug_mode, 68 | uri_to_filename=uri_to_filename, 69 | ) 70 | 71 | _RESOURCE_ASSET_CACHES: dict[str, str] = {} 72 | """key = asset path; value = asset content (str)""" 73 | -------------------------------------------------------------------------------- /Default.sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "keys": [ 4 | "tab" 5 | ], 6 | "command": "copilot_accept_completion", 7 | "context": [ 8 | { 9 | "key": "copilot.commit_completion_on_tab" 10 | }, 11 | { 12 | "key": "copilot.is_on_completion" 13 | } 14 | ] 15 | }, 16 | { 17 | "keys": [ 18 | "escape" 19 | ], 20 | "command": "copilot_reject_completion", 21 | "context": [ 22 | { 23 | "key": "setting.copilot.completion.is_visible" 24 | } 25 | ] 26 | }, 27 | { 28 | "keys": [ 29 | "alt+[" 30 | ], 31 | "command": "copilot_previous_completion", 32 | "context": [ 33 | { 34 | "key": "setting.copilot.completion.is_visible", 35 | } 36 | ] 37 | }, 38 | { 39 | "keys": [ 40 | "alt+]" 41 | ], 42 | "command": "copilot_next_completion", 43 | "context": [ 44 | { 45 | "key": "setting.copilot.completion.is_visible" 46 | } 47 | ] 48 | }, 49 | // { 50 | // "keys": [ 51 | // "UNBOUND" 52 | // ], 53 | // "command": "copilot_get_panel_completions", 54 | // "context": [ 55 | // { 56 | // "key": "setting.copilot.completion.is_visible" 57 | // } 58 | // ] 59 | // }, 60 | // { 61 | // "keys": [ 62 | // "UNBOUND" 63 | // ], 64 | // "command": "copilot_close_panel_completion", 65 | // "context": [ 66 | // { 67 | // "key": "setting.copilot.completion.is_visible_panel_completions" 68 | // } 69 | // ] 70 | // }, 71 | // { 72 | // "keys": [ 73 | // "UNBOUND" 74 | // ], 75 | // "command": "copilot_accept_panel_completion", 76 | // "args": { 77 | // "completion_index": 0 78 | // }, 79 | // "context": [ 80 | // { 81 | // "key": "setting.copilot.completion.is_visible_panel_completions" 82 | // } 83 | // ] 84 | // }, 85 | // { 86 | // "keys": [ 87 | // "UNBOUND" 88 | // ], 89 | // "command": "copilot_ask_completions", 90 | // "context": [ 91 | // { 92 | // "key": "copilot.is_authorized", 93 | // } 94 | // ] 95 | // }, 96 | ] 97 | -------------------------------------------------------------------------------- /plugin/assets/chat_panel.css: -------------------------------------------------------------------------------- 1 | html { 2 | --copilot-delete-foreground: var(--redish); 3 | --copilot-delete-background: var(--background); 4 | --copilot-delete-border: var(--redish); 5 | 6 | --copilot-close-foreground: var(--foreground); 7 | --copilot-close-background: var(--background); 8 | --copilot-close-border: var(--foreground); 9 | 10 | --copilot-accept-foreground: var(--foreground); 11 | --copilot-accept-background: var(--background); 12 | --copilot-accept-border: var(--greenish); 13 | 14 | --copilot-references-foreground: var(--foreground); 15 | --copilot-references-background: var(--background); 16 | --copilot-references-border: color(var(--foreground) alpha(0.25)); 17 | } 18 | 19 | .wrapper { 20 | margin: 1rem 0.5rem 0 0.5rem; 21 | } 22 | 23 | .wrapper hr { 24 | color: color(var(--foreground) a(0.25)); 25 | } 26 | 27 | .wrapper .navbar { 28 | text-align: left; 29 | margin-top: 1rem; 30 | margin-bottom: 2rem; 31 | } 32 | 33 | .wrapper .navbar .suggested-title { 34 | display: inline-block; 35 | font-size: 1.2em; 36 | margin-bottom: 1rem; 37 | padding-bottom: 1rem; 38 | } 39 | 40 | .wrapper .header { 41 | display: block; 42 | margin-bottom: 1rem; 43 | } 44 | 45 | .wrapper a { 46 | border-radius: 3px; 47 | border-style: solid; 48 | border-width: 1px; 49 | display: inline; 50 | padding: 2px; 51 | text-decoration: none; 52 | } 53 | 54 | .wrapper a.icon-link { 55 | border-radius: 0px; 56 | border-style: none; 57 | border-width: 0px; 58 | display: inline; 59 | text-decoration: underline; 60 | } 61 | 62 | .wrapper a.rating { 63 | border-radius: 0px; 64 | border-style: none; 65 | border-width: 0px; 66 | display: inline; 67 | padding: 2px; 68 | text-decoration: none; 69 | } 70 | 71 | .wrapper a.delete { 72 | border-radius: 0px; 73 | border-style: none; 74 | border-width: 0px; 75 | } 76 | 77 | .wrapper a.delete i { 78 | color: var(--copilot-delete-border); 79 | } 80 | 81 | .wrapper a.icon-link { 82 | border-radius: 0px; 83 | border-style: none; 84 | border-width: 0px; 85 | } 86 | 87 | .wrapper a.close { 88 | border-radius: 0px; 89 | border-style: none; 90 | border-width: 0px; 91 | } 92 | 93 | .wrapper .kind { 94 | padding: 1px; 95 | color: color(var(--greenish) alpha(1)); 96 | } 97 | 98 | .wrapper .kind.report { 99 | color: color(var(--yellowish) alpha(1)); 100 | } 101 | 102 | .wrapper .icon { 103 | width: 1.25em; 104 | height: 1.25em; 105 | color: var(--foreground); 106 | } 107 | 108 | .wrapper .icon.delete-icon { 109 | width: 1em; 110 | height: 1em; 111 | color: var(--foreground); 112 | } 113 | 114 | .wrapper div.reference { 115 | margin: 1rem 0; 116 | } 117 | 118 | .wrapper div.references { 119 | margin-top: 1rem; 120 | border-radius: 3px; 121 | border-style: solid; 122 | border-width: 1px; 123 | border-color: var(--copilot-references-border); 124 | } 125 | 126 | .wrapper div .reference_link { 127 | border-radius: 0px; 128 | border-style: none; 129 | border-width: 0px; 130 | font-size: 0.8em; 131 | color: var(--copilot-references-foreground); 132 | } 133 | 134 | .wrapper .code-actions { 135 | display: block; 136 | margin: 8px 0; 137 | text-align: right; 138 | } 139 | -------------------------------------------------------------------------------- /plugin/templates/chat_panel.md.jinja: -------------------------------------------------------------------------------- 1 | 5 | 6 | 15 | 16 | --- 17 | 18 | {% for section in sections %} 19 | 20 |
21 | {% if section.kind == "report" %} 22 | 23 | 24 | {% else %} 25 | 26 | {% endif %} 27 |
28 | 29 |
30 | {%- if section.kind == "report" -%} 31 | Github Copilot 32 | {%- else -%} 33 | {% if avatar_img_src %}{% endif %} {{ section.kind }} 34 | {%- endif -%} 35 |
36 | 37 | 38 | {%- if section.kind == "report" and section.references -%} 39 |
40 | {%- if section.references_expanded -%} 41 | {{ section.references|length }} References 42 |
43 |
    44 | {%- for reference in section.references -%} 45 |
  1. {{ uri_to_filename(reference['uri'], reference['position']['line'], reference['position']['character']) }} 46 |
  2. 47 | {%- endfor -%} 48 |
49 |
50 | {%- else -%} 51 | {{ section.references|length }} References 52 | {%- endif -%} 53 |
54 | {%- endif -%} 55 | 56 | 57 | {% set code_block_replacements = [] %} 58 | {% for index in section.code_block_indices %} 59 | {% do code_block_replacements.append( 60 | ( 61 | "CODE_BLOCK_COMMANDS_" ~ index|string, 62 | ( 63 | "
" ~ 64 | "" ~ 65 | "" ~ 66 | "" ~ 67 | " " ~ 68 | "" ~ 69 | "
" 70 | ) | safe, 71 | ) 72 | ) %} 73 | {% endfor %} 74 | {{ section.message | multi_replace(code_block_replacements) | safe }} 75 | 76 | --- 77 | 78 | {% endfor %} 79 | 80 | {% if follow_up %} 81 |
82 | Follow up: {{ follow_up }} 83 |
84 | {% endif %} 85 | -------------------------------------------------------------------------------- /Main.sublime-menu: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "preferences", 4 | "children": [ 5 | { 6 | "caption": "Package Settings", 7 | "mnemonic": "P", 8 | "id": "package-settings", 9 | "children": [ 10 | { 11 | "caption": "LSP", 12 | "id": "lsp-settings", 13 | "children": [ 14 | { 15 | "caption": "Servers", 16 | "id": "lsp-servers", 17 | "children": [ 18 | { 19 | "caption": "LSP-copilot", 20 | "children": [ 21 | { 22 | "caption": "Settings", 23 | "command": "edit_settings", 24 | "args": { 25 | "base_file": "${packages}/LSP-copilot/LSP-copilot.sublime-settings", 26 | "default": "// Settings in here override those in \"LSP-copilot/LSP-copilot.sublime-settings\"\n\n{\n\t$0\n}\n", 27 | }, 28 | }, 29 | { 30 | "caption": "Key Bindings", 31 | "command": "edit_settings", 32 | "args": { 33 | "base_file": "${packages}/LSP-copilot/Default.sublime-keymap", 34 | "user_file": "${packages}/User/Default (${platform}).sublime-keymap", 35 | "default": "[\n\t$0\n]\n", 36 | }, 37 | }, 38 | { 39 | "caption": "-", 40 | }, 41 | { 42 | "caption": "Copilot: Edit CSS: Popup Completion", 43 | "command": "copilot_prepare_and_edit_settings", 44 | "args": { 45 | "base_file": "${packages}/LSP-copilot/plugin/assets/completion@popup.css", 46 | "user_file": "${packages}/LSP-copilot/plugin/assets/completion@popup.custom.css", 47 | "default": "/* User extra CSS rules: Popup Completion */\n/* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */\n", 48 | }, 49 | }, 50 | { 51 | "caption": "Copilot: Edit CSS: Panel Completion", 52 | "command": "copilot_prepare_and_edit_settings", 53 | "args": { 54 | "base_file": "${packages}/LSP-copilot/plugin/assets/panel_completion.css", 55 | "user_file": "${packages}/LSP-copilot/plugin/assets/panel_completion.custom.css", 56 | "default": "/* User extra CSS rules: Panel Completion */\n/* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */\n", 57 | }, 58 | }, 59 | { 60 | "caption": "Copilot: Edit CSS: Chat Panel", 61 | "command": "copilot_prepare_and_edit_settings", 62 | "args": { 63 | "base_file": "${packages}/LSP-copilot/plugin/assets/chat_panel.css", 64 | "user_file": "${packages}/LSP-copilot/plugin/assets/chat_panel.custom.css", 65 | "default": "/* User extra CSS rules: Chat Panel */\n/* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */\n", 66 | }, 67 | }, 68 | ], 69 | }, 70 | ], 71 | }, 72 | ], 73 | }, 74 | ], 75 | }, 76 | ], 77 | }, 78 | ] 79 | -------------------------------------------------------------------------------- /plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .client import CopilotPlugin 4 | from .commands import ( 5 | CopilotAcceptCompletionCommand, 6 | CopilotAcceptPanelCompletionCommand, 7 | CopilotAcceptPanelCompletionShimCommand, 8 | CopilotAskCompletionsCommand, 9 | CopilotCheckFileStatusCommand, 10 | CopilotCheckStatusCommand, 11 | CopilotClosePanelCompletionCommand, 12 | CopilotConversationAgentsCommand, 13 | CopilotConversationChatCommand, 14 | CopilotConversationChatShimCommand, 15 | CopilotConversationCloseCommand, 16 | CopilotConversationCopyCodeCommand, 17 | CopilotConversationDebugCommand, 18 | CopilotConversationDestroyCommand, 19 | CopilotConversationDestroyShimCommand, 20 | CopilotConversationInsertCodeCommand, 21 | CopilotConversationInsertCodeShimCommand, 22 | CopilotConversationRatingCommand, 23 | CopilotConversationRatingShimCommand, 24 | CopilotConversationTemplatesCommand, 25 | CopilotConversationToggleReferencesBlockCommand, 26 | CopilotConversationTurnDeleteCommand, 27 | CopilotConversationTurnDeleteShimCommand, 28 | CopilotGetPanelCompletionsCommand, 29 | CopilotGetPromptCommand, 30 | CopilotGetVersionCommand, 31 | CopilotNextCompletionCommand, 32 | CopilotPrepareAndEditSettingsCommand, 33 | CopilotPreviousCompletionCommand, 34 | CopilotRejectCompletionCommand, 35 | CopilotSendAnyRequestCommand, 36 | CopilotSignInCommand, 37 | CopilotSignInWithGithubTokenCommand, 38 | CopilotSignOutCommand, 39 | CopilotToggleConversationChatCommand, 40 | ) 41 | from .helpers import CopilotIgnore 42 | from .listeners import EventListener, ViewEventListener, copilot_ignore_observer 43 | from .utils import all_windows 44 | 45 | __all__ = ( 46 | # ST: core 47 | "plugin_loaded", 48 | "plugin_unloaded", 49 | # ST: commands 50 | "CopilotAcceptCompletionCommand", 51 | "CopilotAcceptPanelCompletionCommand", 52 | "CopilotAcceptPanelCompletionShimCommand", 53 | "CopilotAskCompletionsCommand", 54 | "CopilotCheckFileStatusCommand", 55 | "CopilotCheckStatusCommand", 56 | "CopilotClosePanelCompletionCommand", 57 | "CopilotConversationAgentsCommand", 58 | "CopilotConversationChatCommand", 59 | "CopilotConversationChatShimCommand", 60 | "CopilotConversationCloseCommand", 61 | "CopilotConversationCopyCodeCommand", 62 | "CopilotConversationDebugCommand", 63 | "CopilotConversationDestroyCommand", 64 | "CopilotConversationDestroyShimCommand", 65 | "CopilotConversationInsertCodeCommand", 66 | "CopilotConversationInsertCodeShimCommand", 67 | "CopilotConversationRatingCommand", 68 | "CopilotConversationRatingShimCommand", 69 | "CopilotConversationTemplatesCommand", 70 | "CopilotConversationToggleReferencesBlockCommand", 71 | "CopilotConversationTurnDeleteCommand", 72 | "CopilotConversationTurnDeleteShimCommand", 73 | "CopilotGetPanelCompletionsCommand", 74 | "CopilotGetPromptCommand", 75 | "CopilotGetVersionCommand", 76 | "CopilotNextCompletionCommand", 77 | "CopilotPreviousCompletionCommand", 78 | "CopilotRejectCompletionCommand", 79 | "CopilotSendAnyRequestCommand", 80 | "CopilotSignInCommand", 81 | "CopilotSignInWithGithubTokenCommand", 82 | "CopilotSignOutCommand", 83 | "CopilotToggleConversationChatCommand", 84 | # ST: helper commands 85 | "CopilotPrepareAndEditSettingsCommand", 86 | # ST: event listeners 87 | "EventListener", 88 | "ViewEventListener", 89 | ) 90 | 91 | 92 | def plugin_loaded() -> None: 93 | """Executed when this plugin is loaded.""" 94 | CopilotPlugin.setup() 95 | copilot_ignore_observer.setup() 96 | for window in all_windows(): 97 | CopilotIgnore(window).load_patterns() 98 | 99 | 100 | def plugin_unloaded() -> None: 101 | """Executed when this plugin is unloaded.""" 102 | CopilotPlugin.cleanup() 103 | CopilotIgnore.cleanup() 104 | copilot_ignore_observer.cleanup() 105 | -------------------------------------------------------------------------------- /Main.sublime-commands: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Copilot: Chat", 4 | "command": "copilot_conversation_chat" 5 | }, 6 | { 7 | "caption": "Copilot: Explain", 8 | "command": "copilot_conversation_chat", 9 | "args": { 10 | "message": "/explain" 11 | } 12 | }, 13 | { 14 | "caption": "Copilot: Generate Tests", 15 | "command": "copilot_conversation_chat", 16 | "args": { 17 | "message": "/tests" 18 | } 19 | }, 20 | { 21 | "caption": "Copilot: Generate Docs", 22 | "command": "copilot_conversation_chat", 23 | "args": { 24 | "message": "/doc" 25 | } 26 | }, 27 | { 28 | "caption": "Copilot: Get Prompt", 29 | "command": "copilot_get_prompt", 30 | }, 31 | { 32 | "caption": "Copilot: Fix This", 33 | "command": "copilot_conversation_chat", 34 | "args": { 35 | "message": "/fix" 36 | } 37 | }, 38 | { 39 | "caption": "Copilot: Simplify This", 40 | "command": "copilot_conversation_chat", 41 | "args": { 42 | "message": "/simplify" 43 | } 44 | }, 45 | { 46 | "caption": "Copilot: Feedback", 47 | "command": "copilot_conversation_chat", 48 | "args": { 49 | "message": "/feedback" 50 | } 51 | }, 52 | { 53 | "caption": "Copilot: Help", 54 | "command": "copilot_conversation_chat", 55 | "args": { 56 | "message": "/help" 57 | } 58 | }, 59 | { 60 | "caption": "Copilot: Destroy Conversation", 61 | "command": "copilot_conversation_destroy" 62 | }, 63 | { 64 | // Debug Command 65 | "caption": "Copilot: Conversation Agents", 66 | "command": "copilot_conversation_agents", 67 | }, 68 | { 69 | // Debug Command 70 | "caption": "Copilot: Conversation Templates", 71 | "command": "copilot_conversation_templates", 72 | }, 73 | { 74 | "caption": "Copilot: Check Status", 75 | "command": "copilot_check_status" 76 | }, 77 | { 78 | "caption": "Copilot: Check File Status", 79 | "command": "copilot_check_file_status" 80 | }, 81 | { 82 | "caption": "Copilot: Get Panel Completions", 83 | "command": "copilot_get_panel_completions" 84 | }, 85 | { 86 | "caption": "Copilot: Get Version", 87 | "command": "copilot_get_version" 88 | }, 89 | { 90 | "caption": "Copilot: Sign In", 91 | "command": "copilot_sign_in" 92 | }, 93 | { 94 | "caption": "Copilot: Sign In with Github Token", 95 | "command": "copilot_sign_in_with_github_token" 96 | }, 97 | { 98 | "caption": "Copilot: Sign Out", 99 | "command": "copilot_sign_out" 100 | }, 101 | { 102 | "caption": "Copilot: Edit CSS: Popup Completion", 103 | "command": "copilot_prepare_and_edit_settings", 104 | "args": { 105 | "base_file": "${packages}/LSP-copilot/plugin/assets/completion@popup.css", 106 | "user_file": "${packages}/LSP-copilot/plugin/assets/completion@popup.custom.css", 107 | "default": "/* User extra CSS rules: Popup Completion */\n/* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */\n", 108 | }, 109 | }, 110 | { 111 | "caption": "Copilot: Edit CSS: Panel Completion", 112 | "command": "copilot_prepare_and_edit_settings", 113 | "args": { 114 | "base_file": "${packages}/LSP-copilot/plugin/assets/panel_completion.css", 115 | "user_file": "${packages}/LSP-copilot/plugin/assets/panel_completion.custom.css", 116 | "default": "/* User extra CSS rules: Panel Completion */\n/* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */\n", 117 | }, 118 | }, 119 | { 120 | "caption": "Copilot: Edit CSS: Chat Panel", 121 | "command": "copilot_prepare_and_edit_settings", 122 | "args": { 123 | "base_file": "${packages}/LSP-copilot/plugin/assets/chat_panel.css", 124 | "user_file": "${packages}/LSP-copilot/plugin/assets/chat_panel.custom.css", 125 | "default": "/* User extra CSS rules: Chat Panel */\n/* CSS rules: https://www.sublimetext.com/docs/minihtml.html#css */\n", 126 | }, 127 | }, 128 | { 129 | "caption": "Copilot: Send Any Request", 130 | "command": "copilot_send_any_request" 131 | }, 132 | { 133 | "caption": "Copilot: Debug Chat Commands", 134 | "command": "copilot_conversation_debug" 135 | } 136 | ] 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LSP-copilot 2 | 3 | ![Copilot](https://raw.githubusercontent.com/TheSecEng/LSP-copilot/master/docs/screenshot.png) 4 | 5 | GitHub Copilot support for Sublime Text LSP plugin provided through [Copilot.vim][]. 6 | 7 | This plugin uses [Copilot][] distribution which uses OpenAI Codex to suggest codes 8 | and entire functions in real-time right from your editor. 9 | 10 | ## Features 11 | 12 | - [x] Inline completion popup. 13 | - [x] Inline completion phantom. 14 | - [x] Panel completion. 15 | - [x] Chat. 16 | 17 | ## Prerequisites 18 | 19 | * Public network connection. 20 | * Active GitHub Copilot subscription. 21 | 22 | ## Installation 23 | 24 | 1. Install [LSP][] and [LSP-copilot][] via Package Control. 25 | 1. Restart Sublime Text. 26 | 27 | ## Setup 28 | 29 | On the first time use, follow the steps below: 30 | 31 | 1. Open any file. 32 | 1. Execute `Copilot: Sign In` from the command palette. 33 | 1. Follow the prompts to authenticate LSP-copilot. 34 | 1. The `User Code` will be auto copied to your clipboard. 35 | 1. Paste the `User Code` into the pop-up GitHub authentication page. 36 | 1. Return to Sublime Text and press `OK` on the dialog. 37 | 1. If you see a "sign in OK" dialog, LSP-copilot should start working since then. 38 | 39 | ## Settings 40 | 41 | Settings are provide in the `LSP-copilot.sublime-settings` file, accessible using `Preferences: LSP-copilot Settings` in the command palette. 42 | 43 | | Setting | Type | Default | Description | 44 | |-------------------------------|---------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------| 45 | | auto_ask_completions | boolean | true | Auto ask the server for completions. Otherwise, you have to trigger it manually. | 46 | | debug | boolean | false | Enables `debug` mode for LSP-copilot. Enabling all commands regardless of status requirements. | 47 | | hook_to_auto_complete_command | boolean | false | Ask the server for completions when the `auto_complete` command is called. | 48 | | authProvider | string | | The GitHub identity to use for Copilot 49 | | github-enterprise | object | | The configuration for Github Enterprise | 50 | | local_checks | boolean | false | Enables local checks. This feature is not fully understood yet. | 51 | | telemetry | boolean | false | Enables Copilot telemetry requests for `Accept` and `Reject` completions. | 52 | | proxy | string | | The HTTP proxy to use for Copilot requests. It's in the form of `username:password@host:port` or just `host:port`. | 53 | | completion_style | string | popup | Completion style. `popup` is the default, `phantom` is experimental ([there are well-known issues](https://github.com/TheSecEng/LSP-copilot/issues)). | 54 | 55 | ## Screenshots 56 | 57 | ### Inline Completion Popup 58 | 59 | ![Copilot](https://raw.githubusercontent.com/TheSecEng/LSP-copilot/master/docs/screenshot.png) 60 | 61 | ### Inline Completion Phantom 62 | 63 | ![Copilot](https://raw.githubusercontent.com/TheSecEng/LSP-copilot/master/docs/phantom.png) 64 | 65 | ### Panel Completion 66 | 67 | ![Copilot](https://raw.githubusercontent.com/TheSecEng/LSP-copilot/master/docs/panel.png) 68 | 69 | ### Chat 70 | 71 | ![Copilot](https://raw.githubusercontent.com/TheSecEng/LSP-copilot/master/docs/chat.png) 72 | 73 | 74 | ## FAQs 75 | 76 | ### I don't want to use `Tab` for committing Copilot's completion 77 | 78 | It's likely that Copilot's completion appears along with Sublime Text's autocompletion 79 | and both of them use `Tab` for committing the completion. This may cause a nondeterministic result. 80 | 81 | Thus, you may want to let only one of them (or none) use the `Tab` key. 82 | If you don't want LSP-copilot to use the `Tab` key for committing the completion. 83 | You can set LSP-copilot's `commit_completion_on_tab` setting to `false` and add a custom keybinding like below. 84 | 85 | ```js 86 | { 87 | "keys": ["YOUR_OWN_DEDICATE_KEYBINDING"], 88 | "command": "copilot_accept_completion", 89 | "context": [ 90 | { 91 | "key": "copilot.is_on_completion" 92 | } 93 | ] 94 | }, 95 | ``` 96 | 97 | ### I see `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` error 98 | 99 | If working behind a VPN and/or Proxy, you may be required to add your CA file into the NODE environment. 100 | See below for LSP-copilots support for this. 101 | 102 | In LSP-copilot's plugin settings, add the following `env` key: 103 | 104 | ```js 105 | { 106 | "env": { 107 | "NODE_EXTRA_CA_CERTS": "/path/to/certificate.crt", 108 | }, 109 | // other custom settings... 110 | } 111 | ``` 112 | 113 | [Copilot]: https://github.com/features/copilot 114 | [Copilot.vim]: https://github.com/github/copilot.vim 115 | [LSP]: https://packagecontrol.io/packages/LSP 116 | [LSP-copilot]: https://packagecontrol.io/packages/LSP-copilot 117 | -------------------------------------------------------------------------------- /sublime-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "contributions": { 3 | "settings": [ 4 | { 5 | "file_patterns": [ 6 | "/LSP-copilot.sublime-settings" 7 | ], 8 | "schema": { 9 | "$id": "sublime://settings/LSP-copilot", 10 | "definitions": { 11 | "PluginConfig": { 12 | "properties": { 13 | "initializationOptions": { 14 | "additionalProperties": false, 15 | "type": "object", 16 | "properties": {} 17 | }, 18 | "settings": { 19 | "additionalProperties": false, 20 | "type": "object", 21 | "properties": { 22 | "authProvider": { 23 | "default": "github", 24 | "markdownDescription": "The GitHub identity to use for Copilot", 25 | "type": "string", 26 | "enumDescriptions": [ 27 | "GitHub.com", 28 | "GitHub Enterprise" 29 | ], 30 | "enum": [ 31 | "github", 32 | "github-enterprise" 33 | ], 34 | }, 35 | "auto_ask_completions": { 36 | "default": true, 37 | "description": "Auto ask the server for completions. Otherwise, you have to trigger it manually.", 38 | "type": "boolean" 39 | }, 40 | "commit_completion_on_tab": { 41 | "default": true, 42 | "markdownDescription": "Use the `Tab` key for committing Copilot's completion. This may conflict with Sublime Text's `auto_complete_commit_on_tab` setting.", 43 | "type": "boolean" 44 | }, 45 | "completion_style": { 46 | "default": "popup", 47 | "markdownDescription": "Completion style. `popup` is the default, `phantom` is experimental(there are [well-known issues](https://github.com/TheSecEng/LSP-copilot/issues)).", 48 | "type": "string", 49 | "enum": [ 50 | "popup", 51 | "phantom" 52 | ] 53 | }, 54 | "debug": { 55 | "default": false, 56 | "markdownDescription": "Enables `debug` mode fo the LSP-copilot. Enabling all commands regardless of status requirements.", 57 | "type": "boolean" 58 | }, 59 | "github-enterprise": { 60 | "type": "object", 61 | "markdownDescription": "The configuration for Github Enterprise.", 62 | "properties": { 63 | "url": { 64 | "default": "", 65 | "description": "The URL of the GitHub Enterprise instance.", 66 | "type": "string" 67 | } 68 | } 69 | }, 70 | "hook_to_auto_complete_command": { 71 | "default": false, 72 | "markdownDescription": "Ask the server for completions when the `auto_complete` command is called.", 73 | "type": "boolean" 74 | }, 75 | "local_checks": { 76 | "default": false, 77 | "description": "Enables local checks. This feature is not fully understood yet.", 78 | "type": "boolean" 79 | }, 80 | "prompts": { 81 | "default": true, 82 | "markdownDescription": "Enables custom user prompts for Copilot completions.", 83 | "type": "array", 84 | "items": { 85 | "type": "object", 86 | "properties": { 87 | "id": { 88 | "markdownDescription": "The ID of the prompt that acts as the trigger. `/` is automatically assumed and shouldn't be included.", 89 | "type": "string" 90 | }, 91 | "description": { 92 | "markdownDescription": "The description of the prompt.", 93 | "type": "string" 94 | }, 95 | "prompt": { 96 | "markdownDescription": "The prompt message.", 97 | "type": "array", 98 | "items": { 99 | "type": "string" 100 | } 101 | } 102 | } 103 | } 104 | }, 105 | "proxy": { 106 | "default": "", 107 | "markdownDescription": "The HTTP proxy to use for Copilot requests. It's in the form of `username:password@host:port` or just `host:port`.", 108 | "type": "string" 109 | }, 110 | "status_text": { 111 | "default": "{% if server_version %}v{{ server_version }}{% endif %}", 112 | "markdownDescription": "The (Jinja2) template of the status bar text which is inside the parentheses `(...)`. See https://jinja.palletsprojects.com/templates/", 113 | "type": "string" 114 | }, 115 | "telemetry": { 116 | "markdownDescription": "Enables Copilot telemetry requests for `Accept` and `Reject` completions.", 117 | "type": "object", 118 | "items": { 119 | "telemetryLevel": { 120 | "markdownDescription": "Level of Telemetry to be sent. (null, 'all')", 121 | "type": "string" 122 | } 123 | } 124 | } 125 | } 126 | } 127 | } 128 | } 129 | }, 130 | "allOf": [ 131 | { 132 | "$ref": "sublime://settings/LSP-plugin-base" 133 | }, 134 | { 135 | "$ref": "sublime://settings/LSP-copilot#/definitions/PluginConfig" 136 | } 137 | ] 138 | } 139 | }, 140 | { 141 | "file_patterns": [ 142 | "/*.sublime-project" 143 | ], 144 | "schema": { 145 | "properties": { 146 | "settings": { 147 | "properties": { 148 | "LSP": { 149 | "properties": { 150 | "LSP-copilot": { 151 | "$ref": "sublime://settings/LSP-copilot#/definitions/PluginConfig" 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | ] 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /plugin/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Any, Callable, Literal, Tuple, TypedDict, TypeVar 5 | 6 | from LSP.plugin.core.protocol import Position as LspPosition 7 | from LSP.plugin.core.protocol import Range as LspRange 8 | from LSP.plugin.core.typing import StrEnum 9 | 10 | T_Callable = TypeVar("T_Callable", bound=Callable[..., Any]) 11 | 12 | 13 | @dataclass 14 | class AccountStatus: 15 | has_signed_in: bool 16 | """Whether the user has signed in.""" 17 | is_authorized: bool 18 | """Whether user's account can use the Copilot service.""" 19 | user: str = "" 20 | """User's GitHub ID.""" 21 | 22 | 23 | class EnhancedStrEnum(StrEnum): 24 | @classmethod 25 | def has_value(cls, value: str) -> bool: 26 | return value in iter(cls) # type: ignore 27 | 28 | 29 | # ---------------------------- # 30 | # realted to Sublime Text APIs # 31 | # ---------------------------- # 32 | 33 | StPoint = int 34 | StRegion = Tuple[StPoint, StPoint] 35 | 36 | 37 | class StLayout(TypedDict, total=True): 38 | cols: list[float] 39 | rows: list[float] 40 | cells: list[list[int]] 41 | 42 | 43 | class NetworkProxy(TypedDict, total=True): 44 | host: str 45 | port: int 46 | username: str 47 | password: str 48 | rejectUnauthorized: bool 49 | 50 | 51 | # ------------------- # 52 | # basic Copilot types # 53 | # ------------------- # 54 | 55 | 56 | class CopilotDocType(TypedDict, total=True): 57 | source: str 58 | tabSize: int 59 | indentSize: int 60 | insertSpaces: bool 61 | path: str 62 | uri: str 63 | relativePath: str 64 | languageId: str 65 | position: LspPosition 66 | version: int 67 | 68 | 69 | # --------------- # 70 | # Copilot payload # 71 | # --------------- # 72 | 73 | 74 | class CopilotPayloadFileStatus(TypedDict, total=True): 75 | status: Literal["not included", "included"] 76 | 77 | 78 | class CopilotPayloadCompletion(TypedDict, total=True): 79 | text: str 80 | position: LspPosition 81 | uuid: str 82 | range: LspRange 83 | displayText: str 84 | point: StPoint 85 | region: StRegion 86 | 87 | 88 | class CopilotPayloadCompletions(TypedDict, total=True): 89 | completions: list[CopilotPayloadCompletion] 90 | 91 | 92 | class CopilotPayloadFeatureFlagsNotification(TypedDict, total=True): 93 | ssc: bool 94 | chat: bool 95 | rt: bool 96 | 97 | 98 | class CopilotPayloadGetVersion(TypedDict, total=True): 99 | version: str 100 | """E.g., `"1.202.0"`.""" 101 | buildType: str 102 | """E.g., `"prod"`.""" 103 | runtimeVersion: str 104 | """E.g., `"node/20.14.0"`.""" 105 | 106 | 107 | class CopilotPayloadNotifyAccepted(TypedDict, total=True): 108 | uuid: str 109 | 110 | 111 | class CopilotPayloadNotifyRejected(TypedDict, total=True): 112 | uuids: list[str] 113 | 114 | 115 | class CopilotPayloadSignInInitiate(TypedDict, total=True): 116 | verificationUri: str 117 | status: str 118 | userCode: str 119 | expiresIn: int 120 | interval: int 121 | 122 | 123 | class CopilotPayloadSignInWithGithubToken(TypedDict, total=True): 124 | user: str 125 | githubToken: str 126 | 127 | 128 | class CopilotPayloadSignInConfirm(TypedDict, total=True): 129 | status: Literal[ 130 | "AlreadySignedIn", 131 | "MaybeOk", 132 | "NotAuthorized", 133 | "NotSignedIn", 134 | "OK", 135 | ] 136 | user: str 137 | 138 | 139 | class CopilotPayloadSignOut(TypedDict, total=True): 140 | status: Literal["NotSignedIn"] 141 | 142 | 143 | class CopilotPayloadLogMessage(TypedDict, total=True): 144 | metadataStr: str 145 | extra: str 146 | level: int 147 | message: str 148 | 149 | 150 | class CopilotPayloadStatusNotification(TypedDict, total=True): 151 | message: str 152 | status: Literal["InProgress", "Normal"] 153 | 154 | 155 | class CopilotPayloadPanelSolution(TypedDict, total=True): 156 | displayText: str 157 | solutionId: str 158 | score: int 159 | panelId: str 160 | completionText: str 161 | range: LspRange 162 | region: StRegion 163 | 164 | 165 | class CopilotPayloadPanelCompletionSolutionCount(TypedDict, total=True): 166 | solutionCountTarget: int 167 | 168 | 169 | # --------------------- # 170 | # Copilot Chat Types # 171 | # --------------------- # 172 | 173 | 174 | class CopilotConversationTemplates(EnhancedStrEnum): 175 | FIX = "/fix" 176 | TESTS = "/tests" 177 | DOC = "/doc" 178 | EXPLAIN = "/explain" 179 | SIMPLIFY = "/simplify" 180 | 181 | 182 | class CopilotConversationDebugTemplates(EnhancedStrEnum): 183 | FAIL = "/debug.fail" 184 | FILTER = "/debug.filter" 185 | DUMP = "/debug.dump" 186 | TREE = "/debug.tree" 187 | ECHO = "/debug.echo" 188 | PROMPT = "/debug.prompt" 189 | SKILLS = "/debug.skills" 190 | VULNERABILITY = "/debug.vulnerability" 191 | MARKDOWN = "/debug.markdown" 192 | 193 | 194 | class CopilotPayloadConversationEntry(TypedDict, total=True): 195 | kind: str 196 | conversationId: str 197 | turnId: str 198 | reply: str 199 | annotations: list[str] 200 | references: list[CopilotRequestConversationTurnReference | CopilotGitHubWebSearch] 201 | hideText: bool 202 | warnings: list[Any] # @todo define a detailed type 203 | 204 | 205 | class CopilotPayloadConversationEntryTransformed(TypedDict, total=True): 206 | """Our own transformation of `CopilotPayloadConversationEntry`.""" 207 | 208 | kind: str 209 | turnId: str 210 | messages: list[str] 211 | codeBlocks: list[str] 212 | codeBlockIndices: list[int] 213 | references: list[CopilotRequestConversationTurnReference | CopilotGitHubWebSearch] 214 | 215 | 216 | class CopilotPayloadConversationTemplate(TypedDict, total=True): 217 | id: str 218 | description: str 219 | shortDescription: str 220 | scopes: list[str] 221 | 222 | 223 | class CopilotRequestConversationTurn(TypedDict, total=True): 224 | conversationId: str 225 | message: str 226 | workDoneToken: str 227 | doc: CopilotDocType 228 | computeSuggestions: bool 229 | references: list[CopilotRequestConversationTurnReference | CopilotGitHubWebSearch] 230 | source: Literal["panel", "inline"] 231 | 232 | 233 | class CopilotRequestConversationTurnReference(TypedDict, total=True): 234 | type: str 235 | status: str 236 | uri: str 237 | position: LspPosition 238 | range: LspRange 239 | visibleRange: LspRange 240 | selection: LspRange 241 | openedAt: str | None 242 | activeAt: str | None 243 | 244 | 245 | class CopilotGitHubWebDataResult(TypedDict, total=True): 246 | title: str 247 | excerpt: str 248 | url: str 249 | 250 | 251 | class CopilotGitHubWebData(TypedDict, total=True): 252 | query: str 253 | type: str 254 | results: list[CopilotGitHubWebDataResult] | None 255 | 256 | 257 | class CopilotGitHubWebMetadata(TypedDict, total=False): 258 | display_name: str | None 259 | display_icon: str | None 260 | 261 | 262 | class CopilotGitHubWebSearch(TypedDict, total=True): 263 | type: Literal["github.web-search"] 264 | id: str 265 | data: CopilotGitHubWebData 266 | metadata: CopilotGitHubWebMetadata | None 267 | 268 | 269 | class CopilotRequestConversationAgent(TypedDict, total=True): 270 | slug: str 271 | name: str 272 | description: str 273 | 274 | 275 | class CopilotPayloadConversationPreconditions(TypedDict, total=True): 276 | pass 277 | 278 | 279 | class CopilotPayloadConversationCreate(TypedDict, total=True): 280 | conversationId: str 281 | """E.g., `"15d1791c-42f4-490c-9f79-0b79c4142d17"`.""" 282 | turnId: str 283 | """E.g., `"a4a3785f-808f-41cc-8037-cd6707ffe584"`.""" 284 | 285 | 286 | class CopilotPayloadConversationContext(TypedDict, total=True): 287 | conversationId: str 288 | """E.g., `"e3b0d5e3-0c3b-4292-a5ea-15d6003e7c45"`.""" 289 | turnId: str 290 | """E.g., `"09ac7601-6c28-4617-b3e4-13f5ff8502b7"`.""" 291 | skillId: Literal[ 292 | "current-editor", 293 | "project-labels", 294 | "recent-files", 295 | "references", 296 | "problems-in-active-document", 297 | ] # not the complet list yet 298 | 299 | 300 | class CopilotUserDefinedPromptTemplates(TypedDict, total=True): 301 | id: str 302 | description: str 303 | prompt: list[str] 304 | scopes: list[str] 305 | -------------------------------------------------------------------------------- /plugin/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import os 5 | import sys 6 | import threading 7 | from collections.abc import Callable, Generator, Iterable 8 | from functools import wraps 9 | from typing import Any, Mapping, Sequence, TypeVar, Union, cast 10 | 11 | import sublime 12 | from LSP.plugin.core.sessions import Session 13 | from LSP.plugin.core.types import basescope2languageid 14 | from more_itertools import first, first_true 15 | 16 | from .constants import COPILOT_VIEW_SETTINGS_PREFIX, PACKAGE_NAME 17 | from .types import T_Callable 18 | 19 | _T = TypeVar("_T") 20 | _KT = TypeVar("_KT") 21 | _VT = TypeVar("_VT") 22 | _T_Number = TypeVar("_T_Number", bound=Union[int, float]) 23 | 24 | 25 | def all_windows() -> Generator[sublime.Window, None, None]: 26 | yield from sublime.windows() # just to unify the return type with other `all_*` functions 27 | 28 | 29 | def all_views( 30 | window: sublime.Window | None = None, 31 | *, 32 | include_transient: bool = False, 33 | ) -> Generator[sublime.View, None, None]: 34 | windows: Iterable[sublime.Window] = (window,) if window else all_windows() 35 | for window in windows: 36 | yield from window.views(include_transient=include_transient) 37 | 38 | 39 | def all_sheets( 40 | window: sublime.Window | None = None, 41 | *, 42 | include_transient: bool = False, 43 | ) -> Generator[sublime.Sheet, None, None]: 44 | windows: Iterable[sublime.Window] = (window,) if window else all_windows() 45 | for window in windows: 46 | if include_transient: 47 | yield from drop_falsy(map(window.transient_sheet_in_group, range(window.num_groups()))) 48 | yield from window.sheets() 49 | 50 | 51 | def clamp(val: _T_Number, min_val: _T_Number | None = None, max_val: _T_Number | None = None) -> _T_Number: 52 | """Returns the bounded value of `val` in the range of `[min_val, max_val]`.""" 53 | if min_val is not None and val < min_val: # type: ignore 54 | return min_val 55 | if max_val is not None and val > max_val: # type: ignore 56 | return max_val 57 | return val 58 | 59 | 60 | def debounce(time_s: float = 0.3) -> Callable[[T_Callable], T_Callable]: 61 | """ 62 | Debounce a function so that it's called after `time_s` seconds. 63 | If it's called multiple times in the time frame, it will only run the last call. 64 | 65 | Taken and modified from https://github.com/salesforce/decorator-operations 66 | """ 67 | 68 | def decorator(func: T_Callable) -> T_Callable: 69 | @wraps(func) 70 | def debounced(*args: Any, **kwargs: Any) -> None: 71 | def call_function() -> Any: 72 | delattr(debounced, "_timer") 73 | return func(*args, **kwargs) 74 | 75 | timer: threading.Timer | None = getattr(debounced, "_timer", None) 76 | if timer is not None: 77 | timer.cancel() 78 | 79 | timer = threading.Timer(time_s, call_function) 80 | timer.start() 81 | setattr(debounced, "_timer", timer) 82 | 83 | setattr(debounced, "_timer", None) 84 | return cast(T_Callable, debounced) 85 | 86 | return decorator 87 | 88 | 89 | def drop_falsy(iterable: Iterable[_T | None]) -> Generator[_T, None, None]: 90 | """Drops falsy values from the iterable.""" 91 | yield from filter(None, iterable) 92 | 93 | 94 | def find_sheet_by_id(id: int) -> sublime.Sheet | None: 95 | return first_true(all_sheets(include_transient=True), pred=lambda sheet: sheet.id() == id) 96 | 97 | 98 | def find_view_by_id(id: int) -> sublime.View | None: 99 | return first_true(all_views(include_transient=True), pred=lambda view: view.id() == id) 100 | 101 | 102 | def find_window_by_id(id: int) -> sublime.Window | None: 103 | return first_true(all_windows(), pred=lambda window: window.id() == id) 104 | 105 | 106 | def is_active_view(obj: Any) -> bool: 107 | return bool(obj and obj == sublime.active_window().active_view()) 108 | 109 | 110 | def fix_completion_syntax_highlight(view: sublime.View, point: int, code: str) -> str: 111 | if view.match_selector(point, "source.php"): 112 | return f" Any: 117 | """Gets the Copilot-related window setting. Note that what you get is just a "deepcopy" of the value.""" 118 | return instance.settings().get(f"{prefix}.{key}", default) 119 | 120 | 121 | def set_copilot_setting(instance: sublime.Window | sublime.View, prefix: str, key: str, default: Any = None) -> Any: 122 | instance.settings().set(f"{prefix}.{key}", default) 123 | 124 | 125 | def erase_copilot_setting(instance: sublime.Window | sublime.View, prefix: str, key: str) -> Any: 126 | instance.settings().erase(f"{prefix}.{key}") 127 | 128 | 129 | def get_copilot_view_setting(view: sublime.View, key: str, default: Any = None) -> Any: 130 | """Gets the Copilot-related view setting. Note that what you get is just a "deepcopy" of the value.""" 131 | return get_copilot_setting(view, COPILOT_VIEW_SETTINGS_PREFIX, key, default) 132 | 133 | 134 | def set_copilot_view_setting(view: sublime.View, key: str, value: Any) -> None: 135 | set_copilot_setting(view, COPILOT_VIEW_SETTINGS_PREFIX, key, value) 136 | 137 | 138 | def erase_copilot_view_setting(view: sublime.View, key: str) -> None: 139 | erase_copilot_setting(view, COPILOT_VIEW_SETTINGS_PREFIX, key) 140 | 141 | 142 | def get_project_relative_path(path: str) -> str: 143 | """Get the relative path regarding the project root directory. If not possible, return the path as-is.""" 144 | relpath = path 145 | for folder in sublime.active_window().folders(): 146 | with contextlib.suppress(ValueError): 147 | relpath = min(relpath, os.path.relpath(path, folder), key=len) 148 | return relpath 149 | 150 | 151 | def get_session_setting(session: Session, key: str, default: Any = None) -> Any: 152 | """Get the value of the `key` in "settings" in this plugin's "LSP-*.sublime-settings".""" 153 | return default if (value := session.config.settings.get(key)) is None else value 154 | 155 | 156 | def get_view_language_id(view: sublime.View, point: int = 0) -> str: 157 | """Find the language ID for the `view` at `point`.""" 158 | # the deepest scope satisfying `source | text | embedding` will be used to find the language ID 159 | for scope in reversed(view.scope_name(point).split(" ")): 160 | if sublime.score_selector(scope, "source | text | embedding"): 161 | # For some embedded languages, they are scoped as "EMBEDDED_LANG.embedded.PARENT_LANG" 162 | # such as "source.php.embedded.html" and we only want "source.php" (those parts before "embedded"). 163 | return basescope2languageid(scope.partition(".embedded.")[0]) 164 | return "" 165 | 166 | 167 | def message_dialog(msg: str, *, error: bool = False, console: bool = False) -> None: 168 | """ 169 | Show a message dialog, whose message is prefixed with "[PACKAGE_NAME]". 170 | 171 | :param msg: The message 172 | :param error: Use ST error dialog instead of message dialog 173 | :param console: Show message in console as well 174 | """ 175 | full_msg = f"[{PACKAGE_NAME}] {msg}" 176 | messenger = sublime.error_message if error else sublime.message_dialog 177 | messenger(full_msg) 178 | 179 | if console: 180 | print(full_msg) 181 | 182 | 183 | @contextlib.contextmanager 184 | def mutable_view(view: sublime.View) -> Generator[sublime.View, Any, None]: 185 | try: 186 | view.set_read_only(False) 187 | yield view 188 | finally: 189 | view.set_read_only(True) 190 | 191 | 192 | def ok_cancel_dialog(msg: str) -> bool: 193 | """ 194 | Show an OK/cancel dialog, whose message is prefixed with "[PACKAGE_NAME]". 195 | 196 | :param msg: The message 197 | """ 198 | return sublime.ok_cancel_dialog(f"[{PACKAGE_NAME}] {msg}") 199 | 200 | 201 | if sys.version_info >= (3, 9): 202 | remove_prefix = str.removeprefix 203 | remove_suffix = str.removesuffix 204 | else: 205 | 206 | def remove_prefix(s: str, prefix: str) -> str: 207 | """Remove the prefix from the string. I.e., `str.removeprefix` in Python 3.9.""" 208 | return s[len(prefix) :] if s.startswith(prefix) else s 209 | 210 | def remove_suffix(s: str, suffix: str) -> str: 211 | """Remove the suffix from the string. I.e., `str.removesuffix` in Python 3.9.""" 212 | return s[: -len(suffix)] if suffix and s.endswith(suffix) else s 213 | 214 | 215 | def status_message(msg: str, icon: str | None = "✈", *, console: bool = False) -> None: 216 | """ 217 | Show a status message in the status bar, whose message is prefixed with `icon` and "Copilot". 218 | 219 | :param msg: The message 220 | :param icon: The icon 221 | :param console: Show message in console as well 222 | """ 223 | prefix = f"{icon} " if icon else "" 224 | full_msg = f"{prefix}Copilot {msg}" 225 | sublime.status_message(full_msg) 226 | 227 | if console: 228 | print(full_msg) 229 | 230 | 231 | def find_index_by_key_value(items: Sequence[Mapping[_KT, _VT]], key: _KT, value: _VT) -> int: 232 | """ 233 | Finds the index of the first map-like item in `items` whose `key` is equal to `value`. 234 | If not found, returns `-1`. 235 | """ 236 | return first((idx for idx, item in enumerate(items) if key in item and item[key] == value), -1) 237 | -------------------------------------------------------------------------------- /plugin/listeners.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from collections.abc import Iterable 5 | from typing import Any 6 | 7 | import sublime 8 | import sublime_plugin 9 | from watchdog.events import FileSystemEvent, FileSystemEventHandler 10 | from watchdog.observers import Observer 11 | from watchdog.observers.api import ObservedWatch 12 | 13 | from .client import CopilotPlugin 14 | from .decorators import must_be_active_view 15 | from .helpers import CopilotIgnore 16 | from .ui import ViewCompletionManager, ViewPanelCompletionManager, WindowConversationManager 17 | from .utils import all_windows, get_copilot_view_setting, get_session_setting, set_copilot_view_setting 18 | 19 | 20 | class ViewEventListener(sublime_plugin.ViewEventListener): 21 | def __init__(self, view: sublime.View) -> None: 22 | super().__init__(view) 23 | 24 | @classmethod 25 | def applies_to_primary_view_only(cls) -> bool: 26 | # To fix "https://github.com/TerminalFi/LSP-copilot/issues/102", 27 | # let cloned views trigger their event listeners too. 28 | # But we guard some of event listeners only work for the activate view. 29 | return False 30 | 31 | @property 32 | def _is_modified(self) -> bool: 33 | return get_copilot_view_setting(self.view, "_is_modified", False) 34 | 35 | @_is_modified.setter 36 | def _is_modified(self, value: bool) -> None: 37 | set_copilot_view_setting(self.view, "_is_modified", value) 38 | 39 | @property 40 | def _is_saving(self) -> bool: 41 | return get_copilot_view_setting(self.view, "_is_saving", False) 42 | 43 | @_is_saving.setter 44 | def _is_saving(self, value: bool) -> None: 45 | set_copilot_view_setting(self.view, "_is_saving", value) 46 | 47 | @must_be_active_view() 48 | def on_modified_async(self) -> None: 49 | self._is_modified = True 50 | 51 | plugin, session = CopilotPlugin.plugin_session(self.view) 52 | if not plugin or not session: 53 | return 54 | 55 | vcm = ViewCompletionManager(self.view) 56 | vcm.handle_text_change() 57 | 58 | if not self._is_saving and get_session_setting(session, "auto_ask_completions") and not vcm.is_waiting: 59 | plugin.request_get_completions(self.view) 60 | 61 | def on_activated_async(self) -> None: 62 | _, session = CopilotPlugin.plugin_session(self.view) 63 | 64 | # if (session and CopilotPlugin.should_ignore(self.view)) or ( 65 | # not session and not CopilotPlugin.should_ignore(self.view) 66 | # ): 67 | # Hacky way to trigger adding and removing views from session 68 | # prev_setting = self.view.settings().get("lsp_uri") 69 | # self.view.settings().set("lsp_uri", "") 70 | # sublime.set_timeout_async(lambda: self.view.settings().set("lsp_uri", prev_setting), 5) 71 | 72 | if session and not CopilotPlugin.should_ignore(self.view): 73 | if (window := self.view.window()) and self.view.name() != "Copilot Chat": 74 | WindowConversationManager(window).last_active_view_id = self.view.id() 75 | 76 | def on_deactivated_async(self) -> None: 77 | ViewCompletionManager(self.view).hide() 78 | 79 | def on_pre_close(self) -> None: 80 | # close corresponding panel completion 81 | ViewPanelCompletionManager(self.view).close() 82 | 83 | def on_close(self) -> None: 84 | ViewCompletionManager(self.view).handle_close() 85 | 86 | def on_query_context(self, key: str, operator: int, operand: Any, match_all: bool) -> bool | None: 87 | def test(value: Any) -> bool | None: 88 | if operator == sublime.OP_EQUAL: 89 | return value == operand 90 | if operator == sublime.OP_NOT_EQUAL: 91 | return value != operand 92 | return None 93 | 94 | if key == "copilot.has_signed_in": 95 | return test(CopilotPlugin.get_account_status().has_signed_in) 96 | 97 | if key == "copilot.is_authorized": 98 | return test(CopilotPlugin.get_account_status().is_authorized) 99 | 100 | if key == "copilot.is_on_completion": 101 | if not ( 102 | (vcm := ViewCompletionManager(self.view)).is_visible 103 | and len(self.view.sel()) >= 1 104 | and vcm.current_completion 105 | ): 106 | return test(False) 107 | 108 | point = self.view.sel()[0].begin() 109 | line = self.view.line(point) 110 | beginning_of_line = self.view.substr(sublime.Region(line.begin(), point)) 111 | 112 | return test(beginning_of_line.strip() != "" or not re.match(r"\s", vcm.current_completion["displayText"])) 113 | 114 | plugin, session = CopilotPlugin.plugin_session(self.view) 115 | if not plugin or not session: 116 | return None 117 | 118 | if key == "copilot.commit_completion_on_tab": 119 | return test(get_session_setting(session, "commit_completion_on_tab")) 120 | 121 | return None 122 | 123 | def on_post_text_command(self, command_name: str, args: dict[str, Any] | None) -> None: 124 | if command_name == "lsp_save": 125 | self._is_saving = True 126 | 127 | if command_name == "auto_complete": 128 | plugin, session = CopilotPlugin.plugin_session(self.view) 129 | if plugin and session and get_session_setting(session, "hook_to_auto_complete_command"): 130 | plugin.request_get_completions(self.view) 131 | 132 | def on_post_save_async(self) -> None: 133 | self._is_saving = False 134 | 135 | @must_be_active_view() 136 | def on_selection_modified_async(self) -> None: 137 | if not self._is_modified: 138 | ViewCompletionManager(self.view).handle_selection_change() 139 | 140 | self._is_modified = False 141 | 142 | 143 | class EventListener(sublime_plugin.EventListener): 144 | def on_window_command( 145 | self, 146 | window: sublime.Window, 147 | command_name: str, 148 | args: dict[str, Any] | None, 149 | ) -> tuple[str, dict[str, Any] | None] | None: 150 | sheet = window.active_sheet() 151 | 152 | # if the user tries to close panel completion via Ctrl+W 153 | if ( 154 | isinstance(sheet, sublime.HtmlSheet) 155 | and command_name in {"close", "close_file"} 156 | and (vcm := ViewPanelCompletionManager.from_sheet_id(sheet.id())) 157 | ): 158 | vcm.close() 159 | return "noop", None 160 | 161 | return None 162 | 163 | def on_new_window(self, window: sublime.Window) -> None: 164 | copilot_ignore_observer.add_folders(window.folders()) 165 | 166 | def on_pre_close_window(self, window: sublime.Window) -> None: 167 | copilot_ignore_observer.remove_folders(window.folders()) 168 | 169 | 170 | class CopilotIgnoreHandler(FileSystemEventHandler): 171 | def __init__(self) -> None: 172 | self.filename = ".copilotignore" 173 | 174 | def on_modified(self, event: FileSystemEvent) -> None: 175 | if not event.is_directory and event.src_path.endswith(self.filename): 176 | self.update_window_patterns(event.src_path) 177 | 178 | def on_created(self, event: FileSystemEvent) -> None: 179 | if not event.is_directory and event.src_path.endswith(self.filename): 180 | self.update_window_patterns(event.src_path) 181 | 182 | def update_window_patterns(self, path: str) -> None: 183 | for window in all_windows(): 184 | if not self._best_matched_folder(path, window.folders()): 185 | continue 186 | # Update patterns for specific window and folder 187 | CopilotIgnore(window).load_patterns() 188 | return 189 | 190 | def _best_matched_folder(self, path: str, folders: list[str]) -> str | None: 191 | matching_folder = None 192 | for folder in folders: 193 | if path.startswith(folder) and (matching_folder is None or len(folder) > len(matching_folder)): 194 | matching_folder = folder 195 | return matching_folder 196 | 197 | 198 | class CopilotIgnoreObserver: 199 | def __init__(self, folders: Iterable[str] | None = None) -> None: 200 | self.observer = Observer() 201 | self._event_handler = CopilotIgnoreHandler() 202 | self._folders = list(folders or []) 203 | self._scheduler: dict[str, ObservedWatch] = {} 204 | 205 | def setup(self) -> None: 206 | self.add_folders(self._folders) 207 | self.observer.start() 208 | 209 | def cleanup(self) -> None: 210 | self.observer.stop() 211 | self.observer.join() 212 | 213 | def add_folders(self, folders: Iterable[str]) -> None: 214 | for folder in folders: 215 | self.add_folder(folder) 216 | 217 | def add_folder(self, folder: str) -> None: 218 | if folder not in self._folders: 219 | self._folders.append(folder) 220 | observer = self.observer.schedule(self._event_handler, folder, recursive=False) 221 | self._scheduler[folder] = observer 222 | 223 | def remove_folders(self, folders: list[str]) -> None: 224 | for folder in folders: 225 | self.remove_folder(folder) 226 | 227 | def remove_folder(self, folder: str) -> None: 228 | if folder in self._folders: 229 | self._folders.remove(folder) 230 | self.observer.unschedule(self._scheduler[folder]) 231 | 232 | 233 | copilot_ignore_observer = CopilotIgnoreObserver() 234 | -------------------------------------------------------------------------------- /plugin/ui/panel_completion.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import textwrap 4 | from collections.abc import Iterable 5 | 6 | import mdpopups 7 | import sublime 8 | from more_itertools import first_true, unique_everseen 9 | 10 | from ..template import load_resource_template 11 | from ..types import CopilotPayloadPanelSolution, StLayout 12 | from ..utils import ( 13 | all_views, 14 | find_view_by_id, 15 | fix_completion_syntax_highlight, 16 | get_copilot_view_setting, 17 | get_view_language_id, 18 | remove_prefix, 19 | set_copilot_view_setting, 20 | ) 21 | 22 | 23 | class ViewPanelCompletionManager: 24 | # ------------- # 25 | # view settings # 26 | # ------------- # 27 | 28 | @property 29 | def is_visible(self) -> bool: 30 | """Whether the panel completions is visible.""" 31 | return get_copilot_view_setting(self.view, "is_visible_panel_completions", False) 32 | 33 | @is_visible.setter 34 | def is_visible(self, value: bool) -> None: 35 | set_copilot_view_setting(self.view, "is_visible_panel_completions", value) 36 | 37 | @property 38 | def is_waiting(self) -> bool: 39 | """Whether the panel completions synthesis has been done.""" 40 | return get_copilot_view_setting(self.view, "is_waiting_panel_completions", False) 41 | 42 | @is_waiting.setter 43 | def is_waiting(self, value: bool) -> None: 44 | set_copilot_view_setting(self.view, "is_waiting_panel_completions", value) 45 | 46 | @property 47 | def group_id(self) -> int: 48 | """The ID of the group which is used to show panel completions.""" 49 | return get_copilot_view_setting(self.view, "panel_group_id", -1) 50 | 51 | @group_id.setter 52 | def group_id(self, value: int) -> None: 53 | set_copilot_view_setting(self.view, "panel_group_id", value) 54 | 55 | @property 56 | def sheet_id(self) -> int: 57 | """The ID of the sheet which is used to show panel completions.""" 58 | return get_copilot_view_setting(self.view, "panel_sheet_id", -1) 59 | 60 | @sheet_id.setter 61 | def sheet_id(self, value: int) -> None: 62 | set_copilot_view_setting(self.view, "panel_sheet_id", value) 63 | 64 | @property 65 | def original_layout(self) -> StLayout | None: 66 | """The original window layout prior to panel presentation.""" 67 | return get_copilot_view_setting(self.view, "original_layout", None) 68 | 69 | @original_layout.setter 70 | def original_layout(self, value: StLayout | None) -> None: 71 | set_copilot_view_setting(self.view, "original_layout", value) 72 | 73 | @property 74 | def completion_target_count(self) -> int: 75 | """How many completions are synthesized in panel completions.""" 76 | return get_copilot_view_setting(self.view, "panel_completion_target_count", 0) 77 | 78 | @completion_target_count.setter 79 | def completion_target_count(self, value: int) -> None: 80 | set_copilot_view_setting(self.view, "panel_completion_target_count", value) 81 | 82 | @property 83 | def completions(self) -> list[CopilotPayloadPanelSolution]: 84 | """All `completions` in the view. Note that this is a copy.""" 85 | return get_copilot_view_setting(self.view, "panel_completions", []) 86 | 87 | @completions.setter 88 | def completions(self, value: list[CopilotPayloadPanelSolution]) -> None: 89 | set_copilot_view_setting(self.view, "panel_completions", value) 90 | 91 | @property 92 | def panel_id(self) -> str: 93 | """The panel ID sent to Copilot `getPanelCompletions` request.""" 94 | return f"copilot://{self.view.id()}" 95 | 96 | # -------------- # 97 | # normal methods # 98 | # -------------- # 99 | 100 | def __init__(self, view: sublime.View) -> None: 101 | self.view = view 102 | 103 | def reset(self) -> None: 104 | self.is_waiting = False 105 | self.is_visible = False 106 | self.original_layout = None 107 | 108 | def get_completion(self, index: int) -> CopilotPayloadPanelSolution | None: 109 | try: 110 | return self.completions[index] 111 | except IndexError: 112 | return None 113 | 114 | def append_completion(self, completion: CopilotPayloadPanelSolution) -> None: 115 | completions = self.completions 116 | completions.append(completion) 117 | self.completions = completions 118 | 119 | @staticmethod 120 | def find_view_by_panel_id(panel_id: str) -> sublime.View | None: 121 | view_id = int(remove_prefix(panel_id, "copilot://")) 122 | return find_view_by_id(view_id) 123 | 124 | @classmethod 125 | def from_sheet_id(cls, sheet_id: int) -> ViewPanelCompletionManager | None: 126 | return first_true(map(cls, all_views()), pred=lambda self: self.sheet_id == sheet_id) 127 | 128 | def open(self, *, completion_target_count: int | None = None) -> None: 129 | """Open the completion panel.""" 130 | if completion_target_count is not None: 131 | self.completion_target_count = completion_target_count 132 | 133 | _PanelCompletion(self.view).open() 134 | 135 | def update(self) -> None: 136 | """Update the completion panel.""" 137 | _PanelCompletion(self.view).update() 138 | 139 | def close(self) -> None: 140 | """Close the completion panel.""" 141 | _PanelCompletion(self.view).close() 142 | 143 | 144 | class _PanelCompletion: 145 | def __init__(self, view: sublime.View) -> None: 146 | self.view = view 147 | self.completion_manager = ViewPanelCompletionManager(view) 148 | 149 | @property 150 | def completion_content(self) -> str: 151 | completions = self._synthesize(self.completion_manager.completions) 152 | 153 | return load_resource_template("panel_completion.md.jinja").render( 154 | close_url=sublime.command_url("copilot_close_panel_completion", {"view_id": self.view.id()}), 155 | is_waiting=self.completion_manager.is_waiting, 156 | sections=[ 157 | { 158 | "accept_url": sublime.command_url( 159 | "copilot_accept_panel_completion_shim", 160 | {"view_id": self.view.id(), "completion_index": index}, 161 | ), 162 | "code": fix_completion_syntax_highlight( 163 | self.view, 164 | completion["region"][1], 165 | self._prepare_popup_code_display_text(completion["displayText"]), 166 | ), 167 | "lang": get_view_language_id(self.view, completion["region"][1]), 168 | } 169 | for index, completion in completions 170 | ], 171 | total_solutions=self.completion_manager.completion_target_count, 172 | ) 173 | 174 | def open(self) -> None: 175 | window = self.view.window() 176 | if not window: 177 | return 178 | 179 | active_group = window.active_group() 180 | if active_group == window.num_groups() - 1: 181 | self._open_in_side_by_side(window) 182 | else: 183 | self._open_in_group(window, active_group + 1) 184 | 185 | window.focus_view(self.view) 186 | 187 | def update(self) -> None: 188 | window = self.view.window() 189 | if not window: 190 | return 191 | 192 | sheet = window.transient_sheet_in_group(self.completion_manager.group_id) 193 | if not isinstance(sheet, sublime.HtmlSheet): 194 | return 195 | 196 | mdpopups.update_html_sheet(sheet=sheet, contents=self.completion_content, md=True) 197 | 198 | def close(self) -> None: 199 | window = self.view.window() 200 | if not window: 201 | return 202 | 203 | sheet = window.transient_sheet_in_group(self.completion_manager.group_id) 204 | if not isinstance(sheet, sublime.HtmlSheet): 205 | return 206 | 207 | sheet.close() 208 | self.completion_manager.is_visible = False 209 | if self.completion_manager.original_layout: 210 | window.set_layout(self.completion_manager.original_layout) # type: ignore 211 | self.completion_manager.original_layout = None 212 | 213 | window.focus_view(self.view) 214 | 215 | @staticmethod 216 | def _prepare_popup_code_display_text(display_text: str) -> str: 217 | # The returned completion is in the form of 218 | # - the first won't be indented 219 | # - the rest of lines will be indented basing on the indentation level of the current line 220 | # The rest of lines don't visually look good if the current line is deeply indented. 221 | # Hence we modify the rest of lines into always indented by one level if it's originally indented. 222 | first_line, sep, rest = display_text.partition("\n") 223 | 224 | if rest.startswith((" ", "\t")): 225 | return first_line + sep + textwrap.indent(textwrap.dedent(rest), "\t") 226 | 227 | return display_text 228 | 229 | @staticmethod 230 | def _synthesize( 231 | completions: Iterable[CopilotPayloadPanelSolution], 232 | ) -> list[tuple[int, CopilotPayloadPanelSolution]]: 233 | """Return sorted-by-`score` completions in the form of `[(completion_index, completion), ...]`.""" 234 | return sorted( 235 | # note that we must keep completion's original index 236 | unique_everseen(enumerate(completions), key=lambda pair: pair[1]["completionText"]), 237 | key=lambda pair: pair[1]["score"], 238 | reverse=True, 239 | ) 240 | 241 | def _open_in_group(self, window: sublime.Window, group_id: int) -> None: 242 | self.completion_manager.group_id = group_id 243 | 244 | window.focus_group(group_id) 245 | sheet = mdpopups.new_html_sheet( 246 | window=window, 247 | name="Panel Completions", 248 | contents=self.completion_content, 249 | md=True, 250 | flags=sublime.TRANSIENT, 251 | ) 252 | self.completion_manager.sheet_id = sheet.id() 253 | 254 | def _open_in_side_by_side(self, window: sublime.Window) -> None: 255 | self.completion_manager.original_layout = window.layout() # type: ignore 256 | window.set_layout({ 257 | "cols": [0.0, 0.5, 1.0], 258 | "rows": [0.0, 1.0], 259 | "cells": [[0, 0, 1, 1], [1, 0, 2, 1]], 260 | }) 261 | self._open_in_group(window, 1) 262 | -------------------------------------------------------------------------------- /plugin/ui/completion.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import html 4 | import textwrap 5 | from abc import ABC, abstractmethod 6 | from typing import Sequence 7 | 8 | import mdpopups 9 | import sublime 10 | from more_itertools import first_true 11 | 12 | from ..template import load_resource_template 13 | from ..types import CopilotPayloadCompletion 14 | from ..utils import ( 15 | clamp, 16 | fix_completion_syntax_highlight, 17 | get_copilot_view_setting, 18 | get_view_language_id, 19 | is_active_view, 20 | set_copilot_view_setting, 21 | ) 22 | 23 | _view_to_phantom_set: dict[int, sublime.PhantomSet] = {} 24 | 25 | 26 | class ViewCompletionManager: 27 | # ------------- # 28 | # view settings # 29 | # ------------- # 30 | 31 | @property 32 | def is_visible(self) -> bool: 33 | """Whether Copilot's completion popup is visible.""" 34 | return get_copilot_view_setting(self.view, "is_visible", False) 35 | 36 | @is_visible.setter 37 | def is_visible(self, value: bool) -> None: 38 | set_copilot_view_setting(self.view, "is_visible", value) 39 | 40 | @property 41 | def is_waiting(self) -> bool: 42 | """Whether the view is waiting for Copilot's completion response.""" 43 | return get_copilot_view_setting(self.view, "is_waiting_completion", False) 44 | 45 | @is_waiting.setter 46 | def is_waiting(self, value: bool) -> None: 47 | set_copilot_view_setting(self.view, "is_waiting_completion", value) 48 | 49 | @property 50 | def completions(self) -> list[CopilotPayloadCompletion]: 51 | """All `completions` in the view. Note that this is a copy.""" 52 | return get_copilot_view_setting(self.view, "completions", []) 53 | 54 | @completions.setter 55 | def completions(self, value: list[CopilotPayloadCompletion]) -> None: 56 | set_copilot_view_setting(self.view, "completions", value) 57 | 58 | @property 59 | def completion_style(self) -> str: 60 | """The completion style.""" 61 | return get_copilot_view_setting(self.view, "completion_style", "") 62 | 63 | @completion_style.setter 64 | def completion_style(self, value: str) -> None: 65 | set_copilot_view_setting(self.view, "completion_style", value) 66 | 67 | @property 68 | def completion_index(self) -> int: 69 | """The index of the current chosen completion.""" 70 | return get_copilot_view_setting(self.view, "completion_index", 0) 71 | 72 | @completion_index.setter 73 | def completion_index(self, value: int) -> None: 74 | set_copilot_view_setting( 75 | self.view, 76 | "completion_index", 77 | self._tidy_completion_index(value), 78 | ) 79 | 80 | # -------------- # 81 | # normal methods # 82 | # -------------- # 83 | 84 | def __init__(self, view: sublime.View) -> None: 85 | self.view = view 86 | 87 | def reset(self) -> None: 88 | self.is_visible = False 89 | self.is_waiting = False 90 | 91 | @property 92 | def current_completion(self) -> CopilotPayloadCompletion | None: 93 | """The current chosen `completion`.""" 94 | return self.completions[self.completion_index] if self.completions else None 95 | 96 | @property 97 | def completion_style_type(self) -> type[_BaseCompletion]: 98 | if completion_cls := first_true( 99 | _BaseCompletion.__subclasses__(), 100 | pred=lambda t: t.name == self.completion_style, 101 | ): 102 | return completion_cls 103 | raise RuntimeError(f"Unknown completion style type: {self.completion_style}") 104 | 105 | @property 106 | def is_phantom(self) -> bool: 107 | return self.completion_style == _PhantomCompletion.name 108 | 109 | def show_previous_completion(self) -> None: 110 | """Show the previous completion.""" 111 | self.show(completion_index=self.completion_index - 1) 112 | 113 | def show_next_completion(self) -> None: 114 | """Show the next completion.""" 115 | self.show(completion_index=self.completion_index + 1) 116 | 117 | def handle_selection_change(self) -> None: 118 | if not (self.is_phantom and self.is_visible): 119 | return 120 | 121 | self.hide() 122 | 123 | def handle_text_change(self) -> None: 124 | if not (self.is_phantom and self.is_visible): 125 | return 126 | 127 | self.hide() 128 | 129 | def handle_close(self) -> None: 130 | if not self.is_phantom: 131 | return 132 | 133 | self.completion_style_type.close(self.view) 134 | 135 | def hide(self) -> None: 136 | """Hide Copilot's completion popup.""" 137 | # prevent from hiding other plugin's popup 138 | if self.is_visible: 139 | self.completion_style_type.hide(self.view) 140 | 141 | self.is_visible = False 142 | 143 | def show( 144 | self, 145 | completions: list[CopilotPayloadCompletion] | None = None, 146 | completion_index: int | None = None, 147 | completion_style: str | None = None, 148 | ) -> None: 149 | if not is_active_view(self.view): 150 | return 151 | 152 | """Show Copilot's completion popup.""" 153 | if completions is not None: 154 | self.completions = completions 155 | if completion_index is not None: 156 | self.completion_index = completion_index 157 | if completion_style is not None: 158 | self.completion_style = completion_style 159 | 160 | completion = self.current_completion 161 | if not completion: 162 | return 163 | 164 | # the text after completion is the same 165 | current_line = self.view.line(completion["point"]) 166 | if completion["text"] == self.view.substr(current_line): 167 | return 168 | 169 | self.completion_style_type(self.view, completion, self.completion_index, len(self.completions)).show() 170 | 171 | self.is_visible = True 172 | 173 | def _tidy_completion_index(self, index: int) -> int: 174 | """Revise `completion_index` to a valid value, or `0` if `self.completions` is empty.""" 175 | completions_cnt = len(self.completions) 176 | if not completions_cnt: 177 | return 0 178 | 179 | # clamp if it's out-of-bounds or treat it as cyclic? 180 | if self.view.settings().get("auto_complete_cycle"): 181 | return index % completions_cnt 182 | return clamp(index, 0, completions_cnt - 1) 183 | 184 | 185 | class _BaseCompletion(ABC): 186 | name = "" 187 | 188 | def __init__( 189 | self, 190 | view: sublime.View, 191 | completion: CopilotPayloadCompletion, 192 | index: int = 0, 193 | count: int = 1, 194 | ) -> None: 195 | self.view = view 196 | self.completion = completion 197 | self.index = index 198 | self.count = count 199 | 200 | self._settings = self.view.settings() 201 | 202 | @abstractmethod 203 | def show(self) -> None: 204 | pass 205 | 206 | @classmethod 207 | @abstractmethod 208 | def hide(cls, view: sublime.View) -> None: 209 | pass 210 | 211 | @classmethod 212 | def close(cls, view: sublime.View) -> None: 213 | pass 214 | 215 | 216 | class _PopupCompletion(_BaseCompletion): 217 | name = "popup" 218 | 219 | @property 220 | def popup_content(self) -> str: 221 | return load_resource_template("completion@popup.md.jinja").render( 222 | code=self.popup_code, 223 | completion=self.completion, 224 | count=self.count, 225 | index=self.index, 226 | lang=get_view_language_id(self.view, self.completion["point"]), 227 | ) 228 | 229 | @property 230 | def popup_code(self) -> str: 231 | return fix_completion_syntax_highlight( 232 | self.view, 233 | self.completion["point"], 234 | textwrap.dedent(self.completion["text"]), 235 | ) 236 | 237 | def show(self) -> None: 238 | mdpopups.show_popup( 239 | view=self.view, 240 | content=self.popup_content, 241 | md=True, 242 | layout=sublime.LAYOUT_INLINE, 243 | flags=sublime.COOPERATE_WITH_AUTO_COMPLETE, 244 | max_width=640, 245 | ) 246 | 247 | @classmethod 248 | def hide(cls, view: sublime.View) -> None: 249 | mdpopups.hide_popup(view) 250 | 251 | 252 | class _PhantomCompletion(_BaseCompletion): 253 | name = "phantom" 254 | 255 | COPILOT_PHANTOM_COMPLETION = "copilot_phantom_completion" 256 | PHANTOM_TEMPLATE = """ 257 | 258 | 274 | {body} 275 | 276 | """ 277 | PHANTOM_LINE_TEMPLATE = '
{content}
' 278 | 279 | def __init__( 280 | self, 281 | view: sublime.View, 282 | completion: CopilotPayloadCompletion, 283 | index: int = 0, 284 | count: int = 1, 285 | ) -> None: 286 | super().__init__(view, completion, index, count) 287 | 288 | self._phantom_set = self._get_phantom_set(view) 289 | 290 | @classmethod 291 | def _get_phantom_set(cls, view: sublime.View) -> sublime.PhantomSet: 292 | view_id = view.id() 293 | 294 | # create phantom set if there is no existing one 295 | if not _view_to_phantom_set.get(view_id): 296 | _view_to_phantom_set[view_id] = sublime.PhantomSet(view, cls.COPILOT_PHANTOM_COMPLETION) 297 | 298 | return _view_to_phantom_set[view_id] 299 | 300 | def normalize_phantom_line(self, line: str) -> str: 301 | return html.escape(line).replace(" ", " ").replace("\t", " " * self._settings.get("tab_size")) 302 | 303 | def _build_phantom( 304 | self, 305 | lines: str | Sequence[str], 306 | begin: int, 307 | end: int | None = None, 308 | *, 309 | inline: bool = True, 310 | ) -> sublime.Phantom: 311 | body = ( 312 | self.normalize_phantom_line(lines) 313 | if isinstance(lines, str) 314 | else "".join( 315 | self.PHANTOM_LINE_TEMPLATE.format( 316 | class_name=("rest" if index else "first"), 317 | content=self.normalize_phantom_line(line), 318 | ) 319 | for index, line in enumerate(lines) 320 | ) 321 | ) 322 | 323 | return sublime.Phantom( 324 | sublime.Region(begin, begin if end is None else end), 325 | self.PHANTOM_TEMPLATE.format( 326 | body=body, 327 | line_padding_top=int(self._settings.get("line_padding_top")) * 2, # TODO: play with this more 328 | line_padding_bottom=int(self._settings.get("line_padding_bottom")) * 2, 329 | ), 330 | sublime.LAYOUT_INLINE if inline else sublime.LAYOUT_BLOCK, 331 | ) 332 | 333 | def show(self) -> None: 334 | first_line, *rest_lines = self.completion["displayText"].splitlines() 335 | 336 | assert self._phantom_set 337 | self._phantom_set.update([ 338 | self._build_phantom(first_line, self.completion["point"] + 1, self.completion["point"]), 339 | # an empty phantom is required to prevent the cursor from jumping, even if there is only one line 340 | self._build_phantom(rest_lines, self.completion["point"], inline=False), 341 | ]) 342 | 343 | @classmethod 344 | def hide(cls, view: sublime.View) -> None: 345 | cls._get_phantom_set(view).update([]) 346 | 347 | @classmethod 348 | def close(cls, view: sublime.View) -> None: 349 | _view_to_phantom_set.pop(view.id(), None) 350 | -------------------------------------------------------------------------------- /plugin/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | import os 5 | import re 6 | import threading 7 | import time 8 | from operator import itemgetter 9 | from pathlib import Path 10 | from typing import Any, Callable, Literal, Sequence, cast 11 | 12 | import requests 13 | import sublime 14 | from LSP.plugin.core.protocol import Position as LspPosition 15 | from LSP.plugin.core.protocol import Range as LspRange 16 | from LSP.plugin.core.url import filename_to_uri 17 | from more_itertools import duplicates_everseen, first_true 18 | from wcmatch import glob 19 | 20 | from .constants import COPILOT_WINDOW_SETTINGS_PREFIX, PACKAGE_NAME 21 | from .log import log_error 22 | from .settings import get_plugin_setting_dotted 23 | from .types import ( 24 | CopilotConversationTemplates, 25 | CopilotDocType, 26 | CopilotGitHubWebSearch, 27 | CopilotPayloadCompletion, 28 | CopilotPayloadPanelSolution, 29 | CopilotRequestConversationTurn, 30 | CopilotRequestConversationTurnReference, 31 | CopilotUserDefinedPromptTemplates, 32 | ) 33 | from .utils import ( 34 | all_views, 35 | all_windows, 36 | drop_falsy, 37 | erase_copilot_setting, 38 | erase_copilot_view_setting, 39 | get_copilot_setting, 40 | get_project_relative_path, 41 | get_view_language_id, 42 | set_copilot_setting, 43 | ) 44 | 45 | 46 | class ActivityIndicator: 47 | def __init__(self, callback: Callable[[dict[str, Any]], None] | None = None) -> None: 48 | self.thread: threading.Thread | None = None 49 | self.animation = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"] # taken from Package Control 50 | self.animation_cycled = itertools.cycle(self.animation) 51 | self.callback = callback 52 | self.stop_event = threading.Event() 53 | 54 | def start(self) -> None: 55 | if not (self.thread and self.thread.is_alive()): 56 | self.stop_event.clear() 57 | self.thread = threading.Thread(target=self._run, daemon=True) 58 | self.thread.start() 59 | 60 | def stop(self) -> None: 61 | if self.thread: 62 | self.stop_event.set() 63 | self.thread.join() 64 | if self.callback: 65 | self.callback({"is_waiting": ""}) 66 | 67 | def _run(self) -> None: 68 | while not self.stop_event.is_set(): 69 | if self.callback: 70 | self.callback({"is_waiting": next(self.animation_cycled)}) 71 | time.sleep(0.1) 72 | 73 | 74 | class GithubInfo: 75 | AVATAR_PATH = Path(sublime.cache_path()) / f"{PACKAGE_NAME}/avatar.png" 76 | AVATAR_RESOURCE_URL = f"res://Cache/{PACKAGE_NAME}/avatar.png" 77 | 78 | @classmethod 79 | def get_avatar_img_src(cls) -> str: 80 | return cls.AVATAR_RESOURCE_URL if cls.AVATAR_PATH.is_file() else "" 81 | 82 | @classmethod 83 | def fetch_avatar(cls, username: str, *, size: int = 64) -> None: 84 | """If there is no cached avatar, fetches the avatar from GitHub and saves it to the cache.""" 85 | if not username: 86 | log_error("No username provided for fetching avatar.") 87 | return 88 | 89 | cls.update_avatar(username, size=size) 90 | 91 | @classmethod 92 | def update_avatar(cls, username: str, *, size: int = 64) -> None: 93 | """Updates the avatar from GitHub and saves it to the cache directory.""" 94 | if not username: 95 | cls.clear_avatar() 96 | return 97 | 98 | if (req := requests.get(f"https://github.com/{username}.png?size={size}")).ok: 99 | data = req.content 100 | # see https://github.com/TerminalFi/LSP-copilot/issues/218#issuecomment-2535522265 101 | elif req.status_code == 404: 102 | data = sublime.load_binary_resource(f"Packages/{PACKAGE_NAME}/plugin/assets/white-pixel.png") 103 | else: 104 | log_error(f'Failed to fetch avatar for "{username}" with status code {req.status_code}.') 105 | cls.clear_avatar() 106 | return 107 | 108 | cls.AVATAR_PATH.parent.mkdir(parents=True, exist_ok=True) 109 | cls.AVATAR_PATH.write_bytes(data) 110 | 111 | @classmethod 112 | def clear_avatar(cls) -> None: 113 | cls.AVATAR_PATH.unlink(missing_ok=True) 114 | 115 | 116 | class CopilotIgnore: 117 | def __init__(self, window: sublime.Window) -> None: 118 | self.window = window 119 | self.patterns: dict[str, list[str]] = {} 120 | self.load_patterns() 121 | 122 | @classmethod 123 | def cleanup(cls) -> None: 124 | for window in all_windows(): 125 | erase_copilot_setting(window, COPILOT_WINDOW_SETTINGS_PREFIX, "copilotignore.patterns") 126 | for view in all_views(): 127 | erase_copilot_view_setting(view, "is_copilot_ignored") 128 | 129 | def unload_patterns(self) -> None: 130 | self.patterns.clear() 131 | erase_copilot_setting(self.window, COPILOT_WINDOW_SETTINGS_PREFIX, "copilotignore.patterns") 132 | 133 | def load_patterns(self) -> None: 134 | self.patterns.clear() 135 | 136 | # Load workspace patterns 137 | for folder in self.window.folders(): 138 | self.add_patterns_from_file(os.path.join(folder, ".copilotignore"), folder) 139 | 140 | set_copilot_setting(self.window, COPILOT_WINDOW_SETTINGS_PREFIX, "copilotignore.patterns", self.patterns) 141 | 142 | def read_ignore_patterns(self, file_path: str) -> list[str]: 143 | if os.path.isfile(file_path): 144 | with open(file_path, encoding="utf-8") as f: 145 | return list(drop_falsy(map(str.strip, f))) 146 | return [] 147 | 148 | def add_patterns_from_file(self, file_path: str, folder: str) -> None: 149 | if patterns := self.read_ignore_patterns(file_path): 150 | self.patterns[folder] = patterns 151 | 152 | def matches_any_pattern(self, file_path: str | Path) -> bool: 153 | file_path = Path(file_path) 154 | loaded_patterns: dict[str, list[str]] = get_copilot_setting( 155 | self.window, 156 | COPILOT_WINDOW_SETTINGS_PREFIX, 157 | "copilotignore.patterns", 158 | self.patterns, 159 | ) 160 | for folder, patterns in loaded_patterns.items(): 161 | try: 162 | relative_path = file_path.relative_to(folder).as_posix() 163 | except ValueError: 164 | continue 165 | if glob.globmatch(relative_path, patterns, flags=glob.GLOBSTAR): 166 | return True 167 | return False 168 | 169 | def trigger(self, view: sublime.View) -> bool: 170 | if self.patterns and (file := view.file_name()): 171 | return self.matches_any_pattern(file) 172 | return False 173 | 174 | 175 | def st_point_to_lsp_position(point: int, view: sublime.View) -> LspPosition: 176 | row, col = view.rowcol_utf16(point) 177 | return {"line": row, "character": col} 178 | 179 | 180 | def lsp_position_to_st_point(position: LspPosition, view: sublime.View) -> int: 181 | return view.text_point_utf16(position["line"], position["character"]) 182 | 183 | 184 | def st_region_to_lsp_range(region: sublime.Region, view: sublime.View) -> LspRange: 185 | return { 186 | "start": st_point_to_lsp_position(region.begin(), view), 187 | "end": st_point_to_lsp_position(region.end(), view), 188 | } 189 | 190 | 191 | def lsp_range_to_st_region(range_: LspRange, view: sublime.View) -> sublime.Region: 192 | return sublime.Region( 193 | lsp_position_to_st_point(range_["start"], view), 194 | lsp_position_to_st_point(range_["end"], view), 195 | ) 196 | 197 | 198 | def prepare_completion_request_doc(view: sublime.View) -> CopilotDocType | None: 199 | selection = view.sel()[0] 200 | file_path = view.file_name() or f"buffer:{view.buffer().id()}" 201 | return { 202 | "source": view.substr(sublime.Region(0, view.size())), 203 | "tabSize": cast(int, view.settings().get("tab_size")), 204 | "indentSize": 1, # there is no such concept in ST 205 | "insertSpaces": cast(bool, view.settings().get("translate_tabs_to_spaces")), 206 | "path": file_path, 207 | "uri": file_path if file_path.startswith("buffer:") else filename_to_uri(file_path), 208 | "relativePath": get_project_relative_path(file_path), 209 | "languageId": get_view_language_id(view), 210 | "position": st_point_to_lsp_position(selection.begin(), view), 211 | # Buffer Version. Generally this is handled by LSP, but we need to handle it here 212 | # Will need to test getting the version from LSP 213 | "version": view.change_count(), 214 | } 215 | 216 | 217 | def prepare_conversation_turn_request( 218 | conversation_id: str, 219 | window_id: int, 220 | message: str, 221 | view: sublime.View, 222 | views: list[sublime.View], 223 | source: Literal["panel", "inline"] = "panel", 224 | ) -> CopilotRequestConversationTurn | None: 225 | if not (doc := prepare_completion_request_doc(view)): 226 | return None 227 | 228 | # References can technicaly be across multiple files 229 | # TODO: Support references across multiple files 230 | references: list[CopilotRequestConversationTurnReference | CopilotGitHubWebSearch] = [] 231 | visible_range = st_region_to_lsp_range(view.visible_region(), view) 232 | views.append(view) 233 | for view_ in views: 234 | if not (selection := view_.sel()[0]) or view_.substr(selection).isspace(): 235 | continue 236 | 237 | references.append({ 238 | "type": "file", 239 | "status": "included", # included, blocked, notfound, empty 240 | "uri": filename_to_uri(file_path) if (file_path := view_.file_name()) else f"buffer:{view.buffer().id()}", 241 | "position": st_point_to_lsp_position(selection.begin(), view_), 242 | "range": st_region_to_lsp_range(selection, view), 243 | "visibleRange": visible_range, 244 | "selection": st_region_to_lsp_range(selection, view_), 245 | "openedAt": None, 246 | "activeAt": None, 247 | }) 248 | 249 | return { 250 | "conversationId": conversation_id, 251 | "message": message, 252 | "workDoneToken": f"copilot_chat://{window_id}", 253 | "doc": doc, 254 | "computeSuggestions": True, 255 | "references": references, 256 | "source": source, 257 | } 258 | 259 | 260 | def preprocess_message_for_html(message: str) -> str: 261 | def _escape_html(text: str) -> str: 262 | return re.sub(r"<(.*?)>", r"<\1>", text) 263 | 264 | new_lines: list[str] = [] 265 | inside_code_block = False 266 | inline_code_pattern = re.compile(r"`([^`]*)`") 267 | for line in message.split("\n"): 268 | if line.lstrip().startswith("```"): 269 | inside_code_block = not inside_code_block 270 | new_lines.append(line) 271 | continue 272 | if not inside_code_block: 273 | escaped_line = "" 274 | start = 0 275 | for match in inline_code_pattern.finditer(line): 276 | escaped_line += _escape_html(line[start : match.start()]) + match.group(0) 277 | start = match.end() 278 | escaped_line += _escape_html(line[start:]) 279 | new_lines.append(escaped_line) 280 | else: 281 | new_lines.append(line) 282 | return "\n".join(new_lines) 283 | 284 | 285 | def preprocess_chat_message( 286 | view: sublime.View, 287 | message: str, 288 | templates: Sequence[CopilotUserDefinedPromptTemplates] | None = None, 289 | ) -> tuple[bool, str]: 290 | from .template import load_string_template 291 | 292 | templates = templates or [] 293 | user_template = first_true(templates, pred=lambda t: f"/{t['id']}" == message) 294 | 295 | if is_template := bool(user_template or CopilotConversationTemplates.has_value(message)): 296 | message += "\n\n{{ user_prompt }}\n\n{{ code }}" 297 | 298 | region = view.sel()[0] 299 | lang = get_view_language_id(view, region.begin()) 300 | 301 | template = load_string_template(message) 302 | message = template.render( 303 | code=f"\n```{lang}\n{view.substr(region)}\n```\n", 304 | user_prompt="\n".join(user_template["prompt"]) if user_template else "", 305 | ) 306 | 307 | return is_template, message 308 | 309 | 310 | def preprocess_completions(view: sublime.View, completions: list[CopilotPayloadCompletion]) -> None: 311 | """Preprocess the `completions` from "getCompletions" request.""" 312 | # in-place de-duplication 313 | duplicate_indexes = list( 314 | map( 315 | itemgetter(0), # the index from enumerate 316 | duplicates_everseen(enumerate(completions), key=lambda pair: pair[1]["displayText"]), 317 | ) 318 | ) 319 | # delete from the end to avoid changing the index during iteration 320 | for index in reversed(duplicate_indexes): 321 | del completions[index] 322 | 323 | # inject extra information for convenience 324 | for completion in completions: 325 | completion["point"] = lsp_position_to_st_point(completion["position"], view) 326 | completion["region"] = lsp_range_to_st_region(completion["range"], view).to_tuple() 327 | 328 | 329 | def preprocess_panel_completions(view: sublime.View, completions: Sequence[CopilotPayloadPanelSolution]) -> None: 330 | """Preprocess the `completions` from "getCompletionsCycling" request.""" 331 | for completion in completions: 332 | completion["region"] = lsp_range_to_st_region(completion["range"], view).to_tuple() 333 | 334 | 335 | def is_debug_mode() -> bool: 336 | return bool(get_plugin_setting_dotted("settings.debug", False)) 337 | -------------------------------------------------------------------------------- /plugin/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import json 5 | import os 6 | import weakref 7 | from collections.abc import Callable 8 | from dataclasses import dataclass 9 | from functools import wraps 10 | from typing import Any, cast 11 | from urllib.parse import urlparse 12 | 13 | import jmespath 14 | import sublime 15 | from LSP.plugin import ClientConfig, DottedDict, Notification, Request, Session, WorkspaceFolder 16 | from lsp_utils import ApiWrapperInterface, NpmClientHandler, notification_handler, request_handler 17 | 18 | from .constants import ( 19 | NTFY_FEATURE_FLAGS_NOTIFICATION, 20 | NTFY_LOG_MESSAGE, 21 | NTFY_PANEL_SOLUTION, 22 | NTFY_PANEL_SOLUTION_DONE, 23 | NTFY_STATUS_NOTIFICATION, 24 | PACKAGE_NAME, 25 | REQ_CHECK_STATUS, 26 | REQ_CONVERSATION_CONTEXT, 27 | REQ_GET_COMPLETIONS, 28 | REQ_GET_COMPLETIONS_CYCLING, 29 | REQ_GET_VERSION, 30 | REQ_SET_EDITOR_INFO, 31 | ) 32 | from .helpers import ( 33 | ActivityIndicator, 34 | CopilotIgnore, 35 | GithubInfo, 36 | prepare_completion_request_doc, 37 | preprocess_completions, 38 | preprocess_panel_completions, 39 | ) 40 | from .log import log_warning 41 | from .template import load_string_template 42 | from .types import ( 43 | AccountStatus, 44 | CopilotPayloadCompletions, 45 | CopilotPayloadConversationContext, 46 | CopilotPayloadFeatureFlagsNotification, 47 | CopilotPayloadGetVersion, 48 | CopilotPayloadLogMessage, 49 | CopilotPayloadPanelSolution, 50 | CopilotPayloadSignInConfirm, 51 | CopilotPayloadStatusNotification, 52 | NetworkProxy, 53 | T_Callable, 54 | ) 55 | from .ui import ViewCompletionManager, ViewPanelCompletionManager, WindowConversationManager 56 | from .utils import ( 57 | all_views, 58 | all_windows, 59 | debounce, 60 | get_session_setting, 61 | status_message, 62 | ) 63 | 64 | WindowId = int 65 | 66 | 67 | @dataclass 68 | class WindowAttr: 69 | client: CopilotPlugin | None = None 70 | """The LSP client instance for the window.""" 71 | 72 | 73 | def _guard_view(*, failed_return: Any = None) -> Callable[[T_Callable], T_Callable]: 74 | """ 75 | The first two arguments have to be `self` and `view` for a decorated method. 76 | If `view` doesn't meeting some requirements, it will be early failed and return `failed_return`. 77 | """ 78 | 79 | def decorator(func: T_Callable) -> T_Callable: 80 | @wraps(func) 81 | def wrapped(self: Any, view: sublime.View, *arg, **kwargs) -> Any: 82 | view_settings = view.settings() 83 | if ( 84 | not view.is_valid() 85 | or view.element() 86 | or view.is_read_only() 87 | or view_settings.get("command_mode") 88 | or view_settings.get("is_widget") 89 | ): 90 | return failed_return 91 | 92 | return func(self, view, *arg, **kwargs) 93 | 94 | return cast(T_Callable, wrapped) 95 | 96 | return decorator 97 | 98 | 99 | class CopilotPlugin(NpmClientHandler): 100 | package_name = PACKAGE_NAME 101 | server_directory = "language-server" 102 | server_binary_path = os.path.join( 103 | server_directory, 104 | "node_modules", 105 | "copilot-node-server", 106 | "copilot", 107 | "dist", 108 | "language-server.js", 109 | ) 110 | 111 | server_version = "" 112 | """The version of the [copilot.vim](https://github.com/github/copilot.vim) package.""" 113 | server_version_gh = "" 114 | """The version of the Github Copilot language server.""" 115 | 116 | window_attrs: weakref.WeakKeyDictionary[sublime.Window, WindowAttr] = weakref.WeakKeyDictionary() 117 | """Per-window attributes. I.e., per-session attributes.""" 118 | 119 | _account_status = AccountStatus( 120 | has_signed_in=False, 121 | is_authorized=False, 122 | ) 123 | 124 | _activity_indicator: ActivityIndicator | None = None 125 | 126 | def __init__(self, session: weakref.ref[Session]) -> None: 127 | super().__init__(session) 128 | 129 | if sess := session(): 130 | self.window_attrs[sess.window].client = self 131 | 132 | self._activity_indicator = ActivityIndicator(self.update_status_bar_text) 133 | 134 | # Note that ST persists view settings after ST is closed. If the user closes ST 135 | # during awaiting Copilot's response, the internal state management will be corrupted. 136 | # So, we have to reset some status when started. 137 | for view in all_views(): 138 | ViewCompletionManager(view).reset() 139 | ViewPanelCompletionManager(view).reset() 140 | 141 | for window in all_windows(): 142 | WindowConversationManager(window).reset() 143 | 144 | @classmethod 145 | def setup(cls) -> None: 146 | super().setup() 147 | 148 | cls.server_version = cls.parse_server_version() 149 | 150 | @classmethod 151 | def cleanup(cls) -> None: 152 | cls.window_attrs.clear() 153 | super().cleanup() 154 | 155 | @classmethod 156 | def can_start( 157 | cls, 158 | window: sublime.Window, 159 | initiating_view: sublime.View, 160 | workspace_folders: list[WorkspaceFolder], 161 | configuration: ClientConfig, 162 | ) -> str | None: 163 | if message := super().can_start(window, initiating_view, workspace_folders, configuration): 164 | return message 165 | 166 | cls.window_attrs.setdefault(window, WindowAttr()) 167 | return None 168 | 169 | def on_ready(self, api: ApiWrapperInterface) -> None: 170 | def _on_get_version(response: CopilotPayloadGetVersion, failed: bool) -> None: 171 | self.server_version_gh = response.get("version", "") 172 | 173 | def _on_check_status(result: CopilotPayloadSignInConfirm, failed: bool) -> None: 174 | user = result.get("user") 175 | self.set_account_status( 176 | signed_in=result["status"] in {"NotAuthorized", "OK"}, 177 | authorized=result["status"] == "OK", 178 | user=user, 179 | ) 180 | 181 | def _on_set_editor_info(result: str, failed: bool) -> None: 182 | pass 183 | 184 | api.send_request(REQ_GET_VERSION, {}, _on_get_version) 185 | api.send_request(REQ_CHECK_STATUS, {}, _on_check_status) 186 | api.send_request(REQ_SET_EDITOR_INFO, self.editor_info(), _on_set_editor_info) 187 | 188 | def on_settings_changed(self, settings: DottedDict) -> None: 189 | def parse_proxy(proxy: str) -> NetworkProxy | None: 190 | # in the form of "username:password@host:port" or "host:port" 191 | if not proxy: 192 | return None 193 | parsed = urlparse(f"http://{proxy}") 194 | return { 195 | "host": parsed.hostname or "", 196 | "port": parsed.port or 80, 197 | "username": parsed.username or "", 198 | "password": parsed.password or "", 199 | "rejectUnauthorized": True, 200 | } 201 | 202 | super().on_settings_changed(settings) 203 | 204 | if not (session := self.weaksession()): 205 | return 206 | 207 | editor_info = self.editor_info() 208 | 209 | if networkProxy := parse_proxy(settings.get("proxy") or ""): 210 | editor_info["networkProxy"] = networkProxy 211 | 212 | session.send_request(Request(REQ_SET_EDITOR_INFO, editor_info), lambda response: None) 213 | self.update_status_bar_text() 214 | 215 | @staticmethod 216 | def version() -> str: 217 | """Return this plugin's version. If it's not installed by Package Control, return `"unknown"`.""" 218 | try: 219 | return json.loads(sublime.load_resource(f"Packages/{PACKAGE_NAME}/package-metadata.json"))["version"] 220 | except Exception: 221 | return "unknown" 222 | 223 | @classmethod 224 | def editor_info(cls) -> dict[str, Any]: 225 | return { 226 | "editorInfo": { 227 | "name": "vscode", 228 | "version": sublime.version(), 229 | }, 230 | "editorPluginInfo": { 231 | "name": PACKAGE_NAME, 232 | "version": cls.version(), 233 | }, 234 | } 235 | 236 | @classmethod 237 | def required_node_version(cls) -> str: 238 | """ 239 | Testing playground at https://semver.npmjs.com 240 | And `0.0.0` means "no restrictions". 241 | """ 242 | return ">=18" 243 | 244 | @classmethod 245 | def get_account_status(cls) -> AccountStatus: 246 | """Return the account status object.""" 247 | return cls._account_status 248 | 249 | @classmethod 250 | def set_account_status( 251 | cls, 252 | *, 253 | signed_in: bool | None = None, 254 | authorized: bool | None = None, 255 | user: str | None = None, 256 | quiet: bool = False, 257 | ) -> None: 258 | if signed_in is not None: 259 | cls._account_status.has_signed_in = signed_in 260 | if authorized is not None: 261 | cls._account_status.is_authorized = authorized 262 | if user is not None: 263 | cls._account_status.user = user 264 | GithubInfo.fetch_avatar(user) 265 | 266 | if not quiet: 267 | if not cls._account_status.has_signed_in: 268 | icon, msg = "❌", "has NOT been signed in." 269 | elif not cls._account_status.is_authorized: 270 | icon, msg = "⚠", "has signed in but not authorized." 271 | else: 272 | icon, msg = "✈", "has been signed in and authorized." 273 | status_message(msg, icon=icon, console=True) 274 | 275 | @classmethod 276 | def from_view(cls, view: sublime.View) -> CopilotPlugin | None: 277 | if ( 278 | (window := view.window()) 279 | and (window_attr := cls.window_attrs.get(window)) 280 | and (self := window_attr.client) 281 | and self.is_valid_for_view(view) 282 | ): 283 | return self 284 | return None 285 | 286 | @classmethod 287 | def parse_server_version(cls) -> str: 288 | lock_file_content = sublime.load_resource(f"Packages/{PACKAGE_NAME}/language-server/package-lock.json") 289 | return jmespath.search('dependencies."copilot-node-server".version', json.loads(lock_file_content)) or "" 290 | 291 | @classmethod 292 | def plugin_session(cls, view: sublime.View) -> tuple[None, None] | tuple[CopilotPlugin, Session | None]: 293 | plugin = cls.from_view(view) 294 | return (plugin, plugin.weaksession()) if plugin else (None, None) 295 | 296 | @classmethod 297 | def should_ignore(cls, view: sublime.View) -> bool: 298 | if not (window := view.window()): 299 | return False 300 | return CopilotIgnore(window).trigger(view) 301 | 302 | def is_valid_for_view(self, view: sublime.View) -> bool: 303 | session = self.weaksession() 304 | return bool(session and session.session_view_for_view_async(view)) 305 | 306 | def update_status_bar_text(self, extra_variables: dict[str, Any] | None = None) -> None: 307 | if not (session := self.weaksession()): 308 | return 309 | 310 | variables: dict[str, Any] = { 311 | "server_version": self.server_version, 312 | "server_version_gh": self.server_version_gh, 313 | } 314 | 315 | if extra_variables: 316 | variables.update(extra_variables) 317 | 318 | rendered_text = "" 319 | if template_text := str(session.config.settings.get("status_text") or ""): 320 | try: 321 | rendered_text = load_string_template(template_text).render(variables) 322 | except Exception as e: 323 | log_warning(f'Invalid "status_text" template: {e}') 324 | session.set_config_status_async(rendered_text) 325 | 326 | def on_server_notification_async(self, notification: Notification) -> None: 327 | if notification.method == "$/progress": 328 | if ( 329 | (token := notification.params["token"]).startswith("copilot_chat://") 330 | and (params := notification.params["value"]) 331 | and (window := WindowConversationManager.find_window_by_token_id(token)) 332 | ): 333 | wcm = WindowConversationManager(window) 334 | if params.get("kind", None) == "end": 335 | wcm.is_waiting = False 336 | 337 | if suggest_title := params.get("suggestedTitle", None): 338 | wcm.suggested_title = suggest_title 339 | 340 | if params.get("reply", None): 341 | wcm.append_conversation_entry(params) 342 | 343 | if followup := params.get("followUp", None): 344 | message = followup.get("message", "") 345 | wcm.follow_up = message 346 | 347 | wcm.update() 348 | 349 | @notification_handler(NTFY_FEATURE_FLAGS_NOTIFICATION) 350 | def _handle_feature_flags_notification(self, payload: CopilotPayloadFeatureFlagsNotification) -> None: 351 | pass 352 | 353 | @notification_handler(NTFY_LOG_MESSAGE) 354 | def _handle_log_message_notification(self, payload: CopilotPayloadLogMessage) -> None: 355 | pass 356 | 357 | @notification_handler(NTFY_PANEL_SOLUTION) 358 | def _handle_panel_solution_notification(self, payload: CopilotPayloadPanelSolution) -> None: 359 | if not (view := ViewPanelCompletionManager.find_view_by_panel_id(payload["panelId"])): 360 | return 361 | 362 | preprocess_panel_completions(view, [payload]) 363 | 364 | vcm = ViewPanelCompletionManager(view) 365 | vcm.append_completion(payload) 366 | vcm.update() 367 | 368 | @notification_handler(NTFY_PANEL_SOLUTION_DONE) 369 | def _handle_panel_solution_done_notification(self, payload) -> None: 370 | if not (view := ViewPanelCompletionManager.find_view_by_panel_id(payload["panelId"])): 371 | return 372 | 373 | vcm = ViewPanelCompletionManager(view) 374 | vcm.is_waiting = False 375 | vcm.update() 376 | 377 | @notification_handler(NTFY_STATUS_NOTIFICATION) 378 | def _handle_status_notification_notification(self, payload: CopilotPayloadStatusNotification) -> None: 379 | pass 380 | 381 | @request_handler(REQ_CONVERSATION_CONTEXT) 382 | def _handle_conversation_context_request( 383 | self, 384 | payload: CopilotPayloadConversationContext, 385 | respond: Callable[[Any], None], 386 | ) -> None: 387 | respond(None) # what? 388 | 389 | @_guard_view() 390 | @debounce() 391 | def request_get_completions(self, view: sublime.View) -> None: 392 | self._request_completions(view, REQ_GET_COMPLETIONS, no_callback=True) 393 | self._request_completions(view, REQ_GET_COMPLETIONS_CYCLING) 394 | 395 | def _request_completions(self, view: sublime.View, request: str, *, no_callback: bool = False) -> None: 396 | vcm = ViewCompletionManager(view) 397 | vcm.hide() 398 | 399 | if not ( 400 | (session := self.weaksession()) 401 | and self._account_status.has_signed_in 402 | and self._account_status.is_authorized 403 | and len(sel := view.sel()) == 1 404 | ): 405 | return 406 | 407 | if not (doc := prepare_completion_request_doc(view)): 408 | return 409 | 410 | if no_callback: 411 | callback = lambda _: None # noqa: E731 412 | else: 413 | vcm.is_waiting = True 414 | if self._activity_indicator: 415 | self._activity_indicator.start() 416 | callback = functools.partial(self._on_get_completions, view, region=sel[0].to_tuple()) 417 | 418 | session.send_request_async(Request(request, {"doc": doc}), callback) 419 | 420 | def _on_get_completions( 421 | self, 422 | view: sublime.View, 423 | payload: CopilotPayloadCompletions, 424 | region: tuple[int, int], 425 | ) -> None: 426 | vcm = ViewCompletionManager(view) 427 | vcm.is_waiting = False 428 | if self._activity_indicator: 429 | self._activity_indicator.stop() 430 | 431 | if not (session := self.weaksession()): 432 | return 433 | 434 | if len(sel := view.sel()) != 1: 435 | return 436 | 437 | # re-request completions because the cursor position changed during awaiting Copilot's response 438 | if sel[0].to_tuple() != region: 439 | self.request_get_completions(view) 440 | return 441 | 442 | if not (completions := payload["completions"]): 443 | return 444 | 445 | preprocess_completions(view, completions) 446 | vcm.show(completions, 0, get_session_setting(session, "completion_style")) 447 | -------------------------------------------------------------------------------- /plugin/ui/chat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable 4 | 5 | import mdpopups 6 | import sublime 7 | 8 | from ..constants import COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX 9 | from ..helpers import GithubInfo, preprocess_message_for_html 10 | from ..template import load_resource_template 11 | from ..types import CopilotPayloadConversationEntry, CopilotPayloadConversationEntryTransformed, StLayout 12 | from ..utils import find_view_by_id, find_window_by_id, get_copilot_setting, remove_prefix, set_copilot_setting 13 | 14 | 15 | class WindowConversationManager: 16 | # --------------- # 17 | # window settings # 18 | # --------------- # 19 | 20 | @property 21 | def group_id(self) -> int: 22 | """The ID of the group which is used to show conversation panel.""" 23 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "view_group_id", -1) 24 | 25 | @group_id.setter 26 | def group_id(self, value: int) -> None: 27 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "view_group_id", value) 28 | 29 | @property 30 | def last_active_view_id(self) -> int: 31 | """The ID of the last active view that is not the conversation panel""" 32 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "last_active_view_id", -1) 33 | 34 | @last_active_view_id.setter 35 | def last_active_view_id(self, value: int) -> None: 36 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "last_active_view_id", value) 37 | 38 | @property 39 | def original_layout(self) -> StLayout | None: 40 | """The original window layout prior to panel presentation.""" 41 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "original_layout", None) 42 | 43 | @original_layout.setter 44 | def original_layout(self, value: StLayout | None) -> None: 45 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "original_layout", value) 46 | 47 | @property 48 | def view_id(self) -> int: 49 | """The ID of the sheet which is used to show conversation panel.""" 50 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "view_id", -1) 51 | 52 | @view_id.setter 53 | def view_id(self, value: int) -> None: 54 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "view_id", value) 55 | 56 | @property 57 | def suggested_title(self) -> str: 58 | """Suggested title of the conversation""" 59 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "suggested_title", "") 60 | 61 | @suggested_title.setter 62 | def suggested_title(self, value: str) -> None: 63 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "suggested_title", value) 64 | 65 | @property 66 | def follow_up(self) -> str: 67 | """Suggested follow up of the conversation provided by copilot.""" 68 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "follow_up", "") 69 | 70 | @follow_up.setter 71 | def follow_up(self, value: str) -> None: 72 | # Fixes: https://github.com/TerminalFi/LSP-copilot/issues/182 73 | # Replaces ` with ` to avoid breaking the HTML 74 | set_copilot_setting( 75 | self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "follow_up", value.replace("`", "`") 76 | ) 77 | 78 | @property 79 | def conversation_id(self) -> str: 80 | """The conversation uuid used to identify the conversation.""" 81 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "conversation_id", "") 82 | 83 | @conversation_id.setter 84 | def conversation_id(self, value: str) -> None: 85 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "conversation_id", value) 86 | 87 | @property 88 | def code_block_index(self) -> dict[str, str]: 89 | """The tracking of code blocks across the conversation. Used to support Copy and Insert code commands.""" 90 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "code_block_index", {}) 91 | 92 | @code_block_index.setter 93 | def code_block_index(self, value: dict[str, str]) -> None: 94 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "code_block_index", value) 95 | 96 | @property 97 | def is_waiting(self) -> bool: 98 | """Whether the converation completions is streaming.""" 99 | return get_copilot_setting( 100 | self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "is_waiting_conversation", False 101 | ) 102 | 103 | @is_waiting.setter 104 | def is_waiting(self, value: bool) -> None: 105 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "is_waiting_conversation", value) 106 | 107 | @property 108 | def is_visible(self) -> bool: 109 | """Whether the converation completions is streaming.""" 110 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "is_visible", False) 111 | 112 | @is_visible.setter 113 | def is_visible(self, value: bool) -> None: 114 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "is_visible", value) 115 | 116 | @property 117 | def reference_block_state(self) -> dict[str, bool]: 118 | return get_copilot_setting( 119 | self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "reference_block_state", {} 120 | ) 121 | 122 | @reference_block_state.setter 123 | def reference_block_state(self, value: dict[str, bool]) -> None: 124 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "reference_block_state", value) 125 | 126 | @property 127 | def conversation(self) -> list[CopilotPayloadConversationEntry]: 128 | """All `conversation` in the view. Note that this is a copy.""" 129 | return get_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "conversation_entries", []) 130 | 131 | @conversation.setter 132 | def conversation(self, value: list[CopilotPayloadConversationEntry]) -> None: 133 | set_copilot_setting(self.window, COPILOT_WINDOW_CONVERSATION_SETTINGS_PREFIX, "conversation_entries", value) 134 | 135 | # -------------- # 136 | # normal methods # 137 | # -------------- # 138 | 139 | def __init__(self, window: sublime.Window) -> None: 140 | self.window = window 141 | 142 | def reset(self) -> None: 143 | self.is_waiting = False 144 | self.is_visible = False 145 | self.original_layout = None 146 | self.suggested_title = "" 147 | self.follow_up = "" 148 | self.conversation_id = "" 149 | self.conversation = [] 150 | self.reference_block_state = {} 151 | self.code_block_index = {} 152 | 153 | if view := find_view_by_id(self.view_id): 154 | view.close() 155 | 156 | def append_conversation_entry(self, entry: CopilotPayloadConversationEntry) -> None: 157 | # `self.conversation` is a deepcopy of the original value 158 | # So if we do `self.conversation.append(entry)`, the source value won't be modified 159 | conversation_history = self.conversation 160 | conversation_history.append(entry) 161 | self.conversation = conversation_history 162 | self.append_reference_block_state(entry["turnId"], False) 163 | 164 | def append_reference_block_state(self, turn_id: str, state: bool) -> None: 165 | # `self.reference_block_state` is a deepcopy of the original value 166 | # So if we do self.`reference_block_state[turn_id] = state`, the source value won't be modified 167 | reference_block_state = self.reference_block_state 168 | reference_block_state[turn_id] = state 169 | self.reference_block_state = reference_block_state 170 | 171 | def insert_code_block_index(self, index: int, code_block: str) -> None: 172 | # `self.code_block_index` is a deepcopy of the original value 173 | # So if we do `self.code_block_index[str(index)] = code_block_index`, the source value won't be modified 174 | code_block_index = self.code_block_index 175 | code_block_index[str(index)] = code_block 176 | self.code_block_index = code_block_index 177 | 178 | def toggle_references_block(self, turn_id: str) -> None: 179 | reference_block_state = self.reference_block_state 180 | reference_block_state.setdefault(turn_id, False) 181 | reference_block_state[turn_id] = not reference_block_state[turn_id] 182 | self.reference_block_state = reference_block_state 183 | 184 | @staticmethod 185 | def find_window_by_token_id(token_id: str) -> sublime.Window | None: 186 | window_id = int(remove_prefix(token_id, "copilot_chat://")) 187 | return find_window_by_id(window_id) 188 | 189 | def prompt(self, callback: Callable[[str], None], initial_text: str = "") -> None: 190 | self.window.show_input_panel("Copilot Chat", initial_text, callback, None, None) 191 | 192 | def open(self) -> None: 193 | _ConversationEntry(self.window).open() 194 | 195 | def update(self) -> None: 196 | """Update the completion panel.""" 197 | _ConversationEntry(self.window).update() 198 | 199 | def close(self) -> None: 200 | """Close the completion panel.""" 201 | _ConversationEntry(self.window).close() 202 | 203 | 204 | class _ConversationEntry: 205 | def __init__(self, window: sublime.Window) -> None: 206 | self.window = window 207 | self.wcm = WindowConversationManager(window) 208 | 209 | @property 210 | def completion_content(self) -> str: 211 | conversations_entries = self._synthesize() 212 | return load_resource_template("chat_panel.md.jinja", keep_trailing_newline=True).render( 213 | window_id=self.wcm.window.id(), 214 | is_waiting=self.wcm.is_waiting, 215 | avatar_img_src=GithubInfo.get_avatar_img_src(), 216 | suggested_title=preprocess_message_for_html(self.wcm.suggested_title), 217 | follow_up=preprocess_message_for_html(self.wcm.follow_up), 218 | follow_up_url=sublime.command_url( 219 | "copilot_conversation_chat_shim", 220 | {"window_id": self.wcm.window.id(), "message": self.wcm.follow_up}, 221 | ), 222 | close_url=sublime.command_url( 223 | "copilot_conversation_close", 224 | {"window_id": self.wcm.window.id()}, 225 | ), 226 | delete_url=sublime.command_url( 227 | "copilot_conversation_destroy_shim", 228 | {"conversation_id": self.wcm.conversation_id}, 229 | ), 230 | sections=[ 231 | { 232 | "kind": entry["kind"], 233 | "message": "".join(entry["messages"]), 234 | "code_block_indices": entry["codeBlockIndices"], 235 | "toggle_references_url": sublime.command_url( 236 | "copilot_conversation_toggle_references_block", 237 | { 238 | "conversation_id": self.wcm.conversation_id, 239 | "window_id": self.wcm.window.id(), 240 | "turn_id": entry["turnId"], 241 | }, 242 | ), 243 | "references": [] if entry["kind"] != "report" else entry["references"], 244 | "references_expanded": self.wcm.reference_block_state.get(entry["turnId"], False), 245 | "turn_delete_url": sublime.command_url( 246 | "copilot_conversation_turn_delete_shim", 247 | { 248 | "conversation_id": self.wcm.conversation_id, 249 | "window_id": self.wcm.window.id(), 250 | "turn_id": entry["turnId"], 251 | }, 252 | ), 253 | "thumbs_up_url": sublime.command_url( 254 | "copilot_conversation_rating_shim", 255 | {"turn_id": entry["turnId"], "rating": 1}, 256 | ), 257 | "thumbs_down_url": sublime.command_url( 258 | "copilot_conversation_rating_shim", 259 | {"turn_id": entry["turnId"], "rating": -1}, 260 | ), 261 | } 262 | for entry in conversations_entries 263 | ], 264 | ) 265 | 266 | def _synthesize(self) -> list[CopilotPayloadConversationEntryTransformed]: 267 | def inject_code_block_commands(reply: str, code_block_index: int) -> str: 268 | return f"CODE_BLOCK_COMMANDS_{code_block_index}\n\n{reply}" 269 | 270 | transformed_conversation: list[CopilotPayloadConversationEntryTransformed] = [] 271 | current_entry: CopilotPayloadConversationEntryTransformed | None = None 272 | is_inside_code_block = False 273 | code_block_index = -1 274 | 275 | for idx, entry in enumerate(self.wcm.conversation): 276 | kind = entry["kind"] 277 | reply = entry["reply"] 278 | turn_id = entry["turnId"] 279 | 280 | if current_entry and current_entry["kind"] == kind: 281 | if reply.startswith("```"): 282 | is_inside_code_block = not is_inside_code_block 283 | if is_inside_code_block: 284 | code_block_index += 1 285 | current_entry["codeBlockIndices"].append(code_block_index) 286 | reply = inject_code_block_commands(reply, code_block_index) 287 | else: 288 | self.wcm.insert_code_block_index(code_block_index, "".join(current_entry["codeBlocks"])) 289 | current_entry["codeBlocks"] = [] 290 | elif is_inside_code_block: 291 | current_entry["codeBlocks"].append(reply) 292 | current_entry["messages"].append(reply) 293 | else: 294 | if current_entry: 295 | transformed_conversation.append(current_entry) 296 | current_entry = { 297 | "kind": kind, 298 | "turnId": turn_id, 299 | "messages": [reply], 300 | "codeBlockIndices": [], 301 | "codeBlocks": [], 302 | "references": [], 303 | } 304 | if kind == "report": 305 | current_entry["references"] = self.wcm.conversation[idx - 1].get("references", []) 306 | 307 | if reply.startswith("```") and kind == "report": 308 | is_inside_code_block = True 309 | code_block_index += 1 310 | current_entry["codeBlockIndices"].append(code_block_index) 311 | reply = inject_code_block_commands(reply, code_block_index) 312 | current_entry["messages"] = [reply] 313 | 314 | if current_entry: 315 | # Fixes: https://github.com/TerminalFi/LSP-copilot/issues/187 316 | if is_inside_code_block: 317 | current_entry["messages"].append("```") 318 | transformed_conversation.append(current_entry) 319 | 320 | return transformed_conversation 321 | 322 | def open(self) -> None: 323 | self.wcm.is_visible = True 324 | active_group = self.window.active_group() 325 | if active_group == self.window.num_groups() - 1: 326 | self._open_in_side_by_side(self.window) 327 | else: 328 | self._open_in_group(self.window, active_group + 1) 329 | 330 | self.window.focus_view(self.window.active_view()) # type: ignore 331 | 332 | def update(self) -> None: 333 | if not (sheet := self.window.transient_sheet_in_group(self.wcm.group_id)): 334 | return 335 | 336 | mdpopups.update_html_sheet(sheet=sheet, contents=self.completion_content, md=True, wrapper_class="wrapper") 337 | 338 | def close(self) -> None: 339 | if not (sheet := self.window.transient_sheet_in_group(self.wcm.group_id)): 340 | return 341 | 342 | sheet.close() 343 | 344 | self.wcm.is_visible = False 345 | self.wcm.window.run_command("hide_panel") 346 | if self.wcm.original_layout: 347 | self.window.set_layout(self.wcm.original_layout) # type: ignore 348 | self.wcm.original_layout = None 349 | 350 | if view := self.window.active_view(): 351 | self.window.focus_view(view) 352 | 353 | def _open_in_group(self, window: sublime.Window, group_id: int) -> None: 354 | self.wcm.group_id = group_id 355 | 356 | window.focus_group(group_id) 357 | sheet = mdpopups.new_html_sheet( 358 | window=window, 359 | name="Copilot Chat", 360 | contents=self.completion_content, 361 | md=True, 362 | flags=sublime.TRANSIENT, 363 | wrapper_class="wrapper", 364 | ) 365 | self.wcm.view_id = sheet.id() 366 | 367 | def _open_in_side_by_side(self, window: sublime.Window) -> None: 368 | self.wcm.original_layout = window.layout() # type: ignore 369 | window.set_layout({ 370 | "cols": [0.0, 0.5, 1.0], 371 | "rows": [0.0, 1.0], 372 | "cells": [[0, 0, 1, 1], [1, 0, 2, 1]], 373 | }) 374 | self._open_in_group(window, 1) 375 | -------------------------------------------------------------------------------- /plugin/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | import uuid 6 | from abc import ABC 7 | from collections.abc import Callable 8 | from functools import partial, wraps 9 | from pathlib import Path 10 | from typing import Any, Literal, Sequence, cast 11 | 12 | import sublime 13 | import sublime_plugin 14 | from LSP.plugin import Request, Session 15 | from LSP.plugin.core.registry import LspTextCommand, LspWindowCommand 16 | from LSP.plugin.core.url import filename_to_uri 17 | from lsp_utils.helpers import rmtree_ex 18 | 19 | from .client import CopilotPlugin 20 | from .constants import ( 21 | COPILOT_OUTPUT_PANEL_PREFIX, 22 | PACKAGE_NAME, 23 | REQ_CHECK_STATUS, 24 | REQ_CONVERSATION_AGENTS, 25 | REQ_CONVERSATION_CREATE, 26 | REQ_CONVERSATION_DESTROY, 27 | REQ_CONVERSATION_PRECONDITIONS, 28 | REQ_CONVERSATION_RATING, 29 | REQ_CONVERSATION_TEMPLATES, 30 | REQ_CONVERSATION_TURN, 31 | REQ_CONVERSATION_TURN_DELETE, 32 | REQ_FILE_CHECK_STATUS, 33 | REQ_GET_PANEL_COMPLETIONS, 34 | REQ_GET_PROMPT, 35 | REQ_GET_VERSION, 36 | REQ_NOTIFY_ACCEPTED, 37 | REQ_NOTIFY_REJECTED, 38 | REQ_SIGN_IN_CONFIRM, 39 | REQ_SIGN_IN_INITIATE, 40 | REQ_SIGN_IN_WITH_GITHUB_TOKEN, 41 | REQ_SIGN_OUT, 42 | ) 43 | from .decorators import must_be_active_view 44 | from .helpers import ( 45 | GithubInfo, 46 | prepare_completion_request_doc, 47 | prepare_conversation_turn_request, 48 | preprocess_chat_message, 49 | preprocess_message_for_html, 50 | ) 51 | from .log import log_info 52 | from .types import ( 53 | CopilotConversationDebugTemplates, 54 | CopilotPayloadConversationCreate, 55 | CopilotPayloadConversationPreconditions, 56 | CopilotPayloadConversationTemplate, 57 | CopilotPayloadFileStatus, 58 | CopilotPayloadGetVersion, 59 | CopilotPayloadNotifyAccepted, 60 | CopilotPayloadNotifyRejected, 61 | CopilotPayloadPanelCompletionSolutionCount, 62 | CopilotPayloadSignInConfirm, 63 | CopilotPayloadSignInInitiate, 64 | CopilotPayloadSignOut, 65 | CopilotRequestConversationAgent, 66 | CopilotUserDefinedPromptTemplates, 67 | T_Callable, 68 | ) 69 | from .ui import ViewCompletionManager, ViewPanelCompletionManager, WindowConversationManager 70 | from .utils import ( 71 | find_index_by_key_value, 72 | find_view_by_id, 73 | find_window_by_id, 74 | get_session_setting, 75 | message_dialog, 76 | mutable_view, 77 | ok_cancel_dialog, 78 | status_message, 79 | ) 80 | 81 | REQUIRE_NOTHING = 0 82 | REQUIRE_SIGN_IN = 1 << 0 83 | REQUIRE_NOT_SIGN_IN = 1 << 1 84 | REQUIRE_AUTHORIZED = 1 << 2 85 | 86 | 87 | def _provide_plugin_session(*, failed_return: Any = None) -> Callable[[T_Callable], T_Callable]: 88 | """ 89 | The first argument is always `self` for a decorated method. 90 | We want to provide `plugin` and `session` right after it. If we failed to find a `session`, 91 | then it will be early failed and return `failed_return`. 92 | """ 93 | 94 | def decorator(func: T_Callable) -> T_Callable: 95 | @wraps(func) 96 | def wrapped(self: Any, *arg, **kwargs) -> Any: 97 | if not isinstance(self, (LspTextCommand)): 98 | raise RuntimeError('"_provide_session" decorator is only for LspTextCommand.') 99 | 100 | plugin, session = CopilotPlugin.plugin_session(self.view) 101 | if not (plugin and session): 102 | return failed_return 103 | 104 | return func(self, plugin, session, *arg, **kwargs) 105 | 106 | return cast(T_Callable, wrapped) 107 | 108 | return decorator 109 | 110 | 111 | class CopilotPrepareAndEditSettingsCommand(sublime_plugin.ApplicationCommand): 112 | def run(self, *, base_file: str, user_file: str, default: str = "") -> None: 113 | window = sublime.active_window() 114 | user_file_resolved: str = sublime.expand_variables(user_file, window.extract_variables()) # type: ignore 115 | Path(user_file_resolved).parent.mkdir(parents=True, exist_ok=True) 116 | sublime.run_command("edit_settings", {"base_file": base_file, "user_file": user_file, "default": default}) 117 | 118 | 119 | class BaseCopilotCommand(ABC): 120 | session_name = PACKAGE_NAME 121 | requirement = REQUIRE_SIGN_IN | REQUIRE_AUTHORIZED 122 | 123 | def _can_meet_requirement(self, session: Session) -> bool: 124 | if get_session_setting(session, "debug"): 125 | return True 126 | 127 | account_status = CopilotPlugin.get_account_status() 128 | return not ( 129 | ((self.requirement & REQUIRE_SIGN_IN) and not account_status.has_signed_in) 130 | or ((self.requirement & REQUIRE_NOT_SIGN_IN) and account_status.has_signed_in) 131 | or ((self.requirement & REQUIRE_AUTHORIZED) and not account_status.is_authorized) 132 | ) 133 | 134 | 135 | class CopilotTextCommand(BaseCopilotCommand, LspTextCommand, ABC): 136 | def want_event(self) -> bool: 137 | return False 138 | 139 | def _record_telemetry( 140 | self, 141 | session: Session, 142 | request: str, 143 | payload: CopilotPayloadNotifyAccepted | CopilotPayloadNotifyRejected, 144 | ) -> None: 145 | if not get_session_setting(session, "telemetry"): 146 | return 147 | 148 | session.send_request(Request(request, payload), lambda _: None) 149 | 150 | @must_be_active_view(failed_return=False) 151 | @_provide_plugin_session(failed_return=False) 152 | def is_enabled(self, plugin: CopilotPlugin, session: Session) -> bool: # type: ignore 153 | return self._can_meet_requirement(session) 154 | 155 | 156 | class CopilotWindowCommand(BaseCopilotCommand, LspWindowCommand, ABC): 157 | def is_enabled(self) -> bool: 158 | if not (session := self.session()): 159 | return False 160 | return self._can_meet_requirement(session) 161 | 162 | 163 | class CopilotGetVersionCommand(CopilotTextCommand): 164 | requirement = REQUIRE_NOTHING 165 | 166 | @_provide_plugin_session() 167 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 168 | session.send_request(Request(REQ_GET_VERSION, {}), self._on_result_get_version) 169 | 170 | def _on_result_get_version(self, payload: CopilotPayloadGetVersion) -> None: 171 | message_dialog(f"Server version: {payload['version']}") 172 | 173 | 174 | class CopilotAskCompletionsCommand(CopilotTextCommand): 175 | @_provide_plugin_session() 176 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 177 | plugin.request_get_completions(self.view) 178 | 179 | 180 | class CopilotAcceptPanelCompletionShimCommand(CopilotWindowCommand): 181 | def run(self, view_id: int, completion_index: int) -> None: 182 | if not (view := find_view_by_id(view_id)): 183 | return 184 | # Focus the view so that the command runs 185 | self.window.focus_view(view) 186 | view.run_command("copilot_accept_panel_completion", {"completion_index": completion_index}) 187 | 188 | 189 | class CopilotAcceptPanelCompletionCommand(CopilotTextCommand): 190 | def run(self, edit: sublime.Edit, completion_index: int) -> None: 191 | vcm = ViewPanelCompletionManager(self.view) 192 | if not (completion := vcm.get_completion(completion_index)): 193 | return 194 | 195 | # it seems that `completionText` always assume your cursor is at the end of the line 196 | source_line_region = self.view.line(sublime.Region(*completion["region"])) 197 | self.view.insert(edit, source_line_region.end(), completion["completionText"]) 198 | self.view.show(self.view.sel(), show_surrounds=False, animate=self.view.settings().get("animation_enabled")) 199 | 200 | vcm.close() 201 | 202 | 203 | class CopilotClosePanelCompletionCommand(CopilotWindowCommand): 204 | def run(self, view_id: int | None = None) -> None: 205 | if view_id is None: 206 | view = self.window.active_view() 207 | else: 208 | view = find_view_by_id(view_id) 209 | 210 | if not view: 211 | return 212 | 213 | ViewPanelCompletionManager(view).close() 214 | 215 | 216 | class CopilotConversationChatShimCommand(CopilotWindowCommand): 217 | def run(self, window_id: int, message: str = "") -> None: 218 | if not (window := find_window_by_id(window_id)): 219 | return 220 | 221 | wcm = WindowConversationManager(window) 222 | if not (view := find_view_by_id(wcm.last_active_view_id)): 223 | return 224 | 225 | # Focus the view so that the command runs 226 | self.window.focus_view(view) 227 | view.run_command("copilot_conversation_chat", {"message": message}) 228 | 229 | 230 | class CopilotToggleConversationChatCommand(CopilotWindowCommand): 231 | def run(self) -> None: 232 | if not (wcm := WindowConversationManager(self.window)): 233 | return 234 | 235 | if wcm.is_visible: 236 | wcm.close() 237 | elif view := self.window.active_view(): 238 | view.run_command("copilot_conversation_chat") 239 | 240 | 241 | class CopilotConversationChatCommand(CopilotTextCommand): 242 | @_provide_plugin_session() 243 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit, message: str = "") -> None: 244 | if not (window := self.view.window()): 245 | return 246 | 247 | wcm = WindowConversationManager(window) 248 | if wcm.conversation_id: 249 | wcm.open() 250 | wcm.prompt(callback=lambda msg: self._on_prompt(plugin, session, msg), initial_text=message) 251 | return 252 | 253 | session.send_request( 254 | Request( 255 | REQ_CONVERSATION_PRECONDITIONS, 256 | {}, 257 | ), 258 | lambda response: self._on_result_conversation_preconditions(plugin, session, response, message), 259 | ) 260 | 261 | def _on_result_conversation_preconditions( 262 | self, 263 | plugin: CopilotPlugin, 264 | session: Session, 265 | payload: CopilotPayloadConversationPreconditions, 266 | initial_message: str, 267 | ) -> None: 268 | if not (window := self.view.window()): 269 | return 270 | 271 | wcm = WindowConversationManager(window) 272 | if not (view := find_view_by_id(wcm.last_active_view_id)): 273 | return 274 | 275 | user_prompts: list[CopilotUserDefinedPromptTemplates] = session.config.settings.get("prompts") or [] 276 | is_template, msg = preprocess_chat_message(view, initial_message, user_prompts) 277 | if msg: 278 | wcm.append_conversation_entry({ 279 | "kind": plugin.get_account_status().user or "user", 280 | "conversationId": wcm.conversation_id, 281 | "reply": msg.split()[0] if is_template else preprocess_message_for_html(msg), 282 | "turnId": str(uuid.uuid4()), 283 | "references": [], 284 | "annotations": [], 285 | "hideText": False, 286 | "warnings": [], 287 | }) 288 | session.send_request( 289 | Request( 290 | REQ_CONVERSATION_CREATE, 291 | { 292 | "turns": [{"request": msg}], 293 | "capabilities": { 294 | "allSkills": True, 295 | "skills": [], 296 | }, 297 | "workDoneToken": f"copilot_chat://{window.id()}", 298 | "computeSuggestions": True, 299 | "source": "panel", 300 | }, 301 | ), 302 | lambda msg: self._on_result_conversation_create(plugin, session, msg), 303 | ) 304 | wcm.is_waiting = True 305 | wcm.update() 306 | 307 | def _on_result_conversation_create( 308 | self, 309 | plugin: CopilotPlugin, 310 | session: Session, 311 | payload: CopilotPayloadConversationCreate, 312 | ) -> None: 313 | if not (window := self.view.window()): 314 | return 315 | 316 | wcm = WindowConversationManager(window) 317 | wcm.conversation_id = payload["conversationId"] 318 | wcm.open() 319 | wcm.prompt(callback=lambda msg: self._on_prompt(plugin, session, msg)) 320 | 321 | def _on_prompt(self, plugin: CopilotPlugin, session: Session, msg: str): 322 | if not (window := self.view.window()): 323 | return 324 | 325 | wcm = WindowConversationManager(window) 326 | if wcm.is_waiting: 327 | wcm.prompt(callback=lambda x: self._on_prompt(plugin, session, x), initial_text=msg) 328 | return 329 | 330 | if not (view := find_view_by_id(wcm.last_active_view_id)): 331 | return 332 | user_prompts: list[CopilotUserDefinedPromptTemplates] = session.config.settings.get("prompts") or [] 333 | is_template, msg = preprocess_chat_message(view, msg, user_prompts) 334 | views = [sv.view for sv in session.session_views_async() if sv.view.id() != view.id()] 335 | if not (request := prepare_conversation_turn_request(wcm.conversation_id, wcm.window.id(), msg, view, views)): 336 | return 337 | 338 | wcm.append_conversation_entry({ 339 | "kind": plugin.get_account_status().user or "user", 340 | "conversationId": wcm.conversation_id, 341 | "reply": msg.split()[0] if is_template else preprocess_message_for_html(msg), 342 | "turnId": str(uuid.uuid4()), 343 | "references": request["references"], 344 | "annotations": [], 345 | "hideText": False, 346 | "warnings": [], 347 | }) 348 | session.send_request( 349 | Request(REQ_CONVERSATION_TURN, request), 350 | lambda _: wcm.prompt(callback=lambda x: self._on_prompt(plugin, session, x)), 351 | ) 352 | wcm.is_waiting = True 353 | wcm.update() 354 | 355 | 356 | class CopilotConversationCloseCommand(CopilotWindowCommand): 357 | def run(self, window_id: int | None = None) -> None: 358 | if not window_id: 359 | return 360 | if not (window := find_window_by_id(window_id)): 361 | return 362 | 363 | WindowConversationManager(window).close() 364 | 365 | 366 | class CopilotConversationRatingShimCommand(CopilotWindowCommand): 367 | def run(self, turn_id: str, rating: int) -> None: 368 | wcm = WindowConversationManager(self.window) 369 | if not (view := find_view_by_id(wcm.last_active_view_id)): 370 | return 371 | # Focus the view so that the command runs 372 | self.window.focus_view(view) 373 | view.run_command("copilot_conversation_rating", {"turn_id": turn_id, "rating": rating}) 374 | 375 | 376 | class CopilotConversationRatingCommand(CopilotTextCommand): 377 | @_provide_plugin_session() 378 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit, turn_id: str, rating: int) -> None: 379 | session.send_request( 380 | Request( 381 | REQ_CONVERSATION_RATING, 382 | { 383 | "turnId": turn_id, 384 | "rating": rating, 385 | }, 386 | ), 387 | self._on_result_conversation_rating, 388 | ) 389 | 390 | def _on_result_conversation_rating(self, payload: Literal["OK"]) -> None: 391 | # Returns OK 392 | pass 393 | 394 | def is_enabled(self, event: dict[Any, Any] | None = None, point: int | None = None) -> bool: # type: ignore 395 | return True 396 | 397 | 398 | class CopilotConversationDestroyShimCommand(CopilotWindowCommand): 399 | def run(self, conversation_id: str) -> None: 400 | wcm = WindowConversationManager(self.window) 401 | if not (view := find_view_by_id(wcm.last_active_view_id)): 402 | status_message("Failed to find last active view.") 403 | return 404 | # Focus the view so that the command runs 405 | self.window.focus_view(view) 406 | view.run_command("copilot_conversation_destroy", {"conversation_id": conversation_id}) 407 | 408 | 409 | class CopilotConversationDestroyCommand(CopilotTextCommand): 410 | @_provide_plugin_session() 411 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit, conversation_id: str) -> None: 412 | if not ( 413 | (window := self.view.window()) 414 | and (wcm := WindowConversationManager(window)) 415 | and wcm.conversation_id == conversation_id 416 | ): 417 | status_message("Failed to find window or conversation.") 418 | return 419 | 420 | session.send_request( 421 | Request( 422 | REQ_CONVERSATION_DESTROY, 423 | { 424 | "conversationId": conversation_id, 425 | "options": {}, 426 | }, 427 | ), 428 | self._on_result_conversation_destroy, 429 | ) 430 | 431 | def _on_result_conversation_destroy(self, payload: str) -> None: 432 | if not (window := self.view.window()): 433 | status_message("Failed to find window") 434 | return 435 | if payload != "OK": 436 | status_message("Failed to destroy conversation.") 437 | return 438 | 439 | status_message("Destroyed conversation.") 440 | wcm = WindowConversationManager(window) 441 | wcm.close() 442 | wcm.reset() 443 | 444 | def is_enabled(self, event: dict[Any, Any] | None = None, point: int | None = None) -> bool: # type: ignore 445 | if not (window := self.view.window()): 446 | return False 447 | return bool(WindowConversationManager(window).conversation_id) 448 | 449 | 450 | class CopilotConversationToggleReferencesBlockCommand(CopilotWindowCommand): 451 | def run(self, window_id: int, conversation_id: str, turn_id: str) -> None: 452 | wcm = WindowConversationManager(self.window) 453 | if conversation_id != wcm.conversation_id: 454 | return 455 | 456 | wcm.toggle_references_block(turn_id) 457 | wcm.update() 458 | 459 | 460 | class CopilotConversationTurnDeleteShimCommand(CopilotWindowCommand): 461 | def run(self, window_id: int, conversation_id: str, turn_id: str) -> None: 462 | wcm = WindowConversationManager(self.window) 463 | if not (view := find_view_by_id(wcm.last_active_view_id)): 464 | return 465 | # Focus the view so that the command runs 466 | self.window.focus_view(view) 467 | view.run_command( 468 | "copilot_conversation_turn_delete", 469 | {"window_id": window_id, "conversation_id": conversation_id, "turn_id": turn_id}, 470 | ) 471 | 472 | 473 | class CopilotConversationTurnDeleteCommand(CopilotTextCommand): 474 | @_provide_plugin_session() 475 | def run( 476 | self, 477 | plugin: CopilotPlugin, 478 | session: Session, 479 | _: sublime.Edit, 480 | window_id: int, 481 | conversation_id: str, 482 | turn_id: str, 483 | ) -> None: 484 | if not (window := find_window_by_id(window_id)): 485 | return 486 | 487 | wcm = WindowConversationManager(window) 488 | if wcm.conversation_id != conversation_id: 489 | return 490 | 491 | # Fixes: https://github.com/TerminalFi/LSP-copilot/issues/181 492 | index = find_index_by_key_value(wcm.conversation, "turnId", turn_id) + 1 493 | if index >= len(wcm.conversation): 494 | return 495 | retrieved_turn_id = wcm.conversation[index]["turnId"] 496 | 497 | session.send_request( 498 | Request( 499 | REQ_CONVERSATION_TURN_DELETE, 500 | { 501 | "conversationId": conversation_id, 502 | "turnId": retrieved_turn_id, 503 | "options": {}, 504 | }, 505 | ), 506 | lambda x: self._on_result_conversation_turn_delete(window_id, conversation_id, turn_id, x), 507 | ) 508 | 509 | def _on_result_conversation_turn_delete( 510 | self, 511 | window_id: int, 512 | conversation_id: str, 513 | turn_id: str, 514 | payload: str, 515 | ) -> None: 516 | if payload != "OK": 517 | status_message("Failed to delete turn.") 518 | return 519 | 520 | if not (window := find_window_by_id(window_id)): 521 | return 522 | 523 | wcm = WindowConversationManager(window) 524 | if wcm.conversation_id != conversation_id: 525 | return 526 | 527 | index = find_index_by_key_value(wcm.conversation, "turnId", turn_id) 528 | conversation = wcm.conversation 529 | del conversation[index:] 530 | wcm.follow_up = "" 531 | wcm.conversation = conversation 532 | wcm.update() 533 | 534 | def is_enabled(self, event: dict[Any, Any] | None = None, point: int | None = None) -> bool: # type: ignore 535 | return True 536 | 537 | 538 | class CopilotConversationCopyCodeCommand(CopilotWindowCommand): 539 | def run(self, window_id: int, code_block_index: int) -> None: 540 | if not (window := find_window_by_id(window_id)): 541 | return 542 | 543 | wcm = WindowConversationManager(window) 544 | if not (code := wcm.code_block_index.get(str(code_block_index), None)): 545 | return 546 | 547 | sublime.set_clipboard(code) 548 | 549 | 550 | class CopilotConversationInsertCodeShimCommand(CopilotWindowCommand): 551 | def run(self, window_id: int, code_block_index: int) -> None: 552 | if not (window := find_window_by_id(window_id)): 553 | status_message(f"Failed to find window based on ID. ({window_id})") 554 | return 555 | 556 | wcm = WindowConversationManager(window) 557 | if not (view := find_view_by_id(wcm.last_active_view_id)): 558 | status_message("Window has no active view") 559 | return 560 | 561 | if not (code := wcm.code_block_index.get(str(code_block_index), None)): 562 | status_message(f"Failed to find code based on index. {code_block_index}") 563 | return 564 | 565 | # Focus the view so that the command runs 566 | self.window.focus_view(view) 567 | view.run_command("copilot_conversation_insert_code", {"characters": code}) 568 | 569 | 570 | class CopilotConversationInsertCodeCommand(sublime_plugin.TextCommand): 571 | def run(self, edit: sublime.Edit, characters: str) -> None: 572 | if len(self.view.sel()) > 1: 573 | return 574 | 575 | begin = self.view.sel()[0].begin() 576 | self.view.erase(edit, self.view.sel()[0]) 577 | self.view.insert(edit, begin, characters) 578 | 579 | 580 | class CopilotConversationAgentsCommand(CopilotTextCommand): 581 | @_provide_plugin_session() 582 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 583 | session.send_request(Request(REQ_CONVERSATION_AGENTS, {"options": {}}), self._on_result_conversation_agents) 584 | 585 | def _on_result_conversation_agents(self, payload: list[CopilotRequestConversationAgent]) -> None: 586 | if not (window := self.view.window()): 587 | return 588 | window.show_quick_panel([[item["slug"], item["description"]] for item in payload], lambda _: None) 589 | 590 | 591 | class CopilotGetPromptCommand(CopilotTextCommand): 592 | @_provide_plugin_session() 593 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 594 | doc = prepare_completion_request_doc(self.view) 595 | session.send_request(Request(REQ_GET_PROMPT, {"doc": doc}), self._on_result_get_prompt) 596 | 597 | def _on_result_get_prompt(self, payload) -> None: 598 | if not (window := self.view.window()): 599 | return 600 | view = window.create_output_panel(f"{COPILOT_OUTPUT_PANEL_PREFIX}.prompt_view", unlisted=True) 601 | view.assign_syntax("scope:source.json") 602 | 603 | with mutable_view(view) as view: 604 | view.run_command("append", {"characters": json.dumps(payload, indent=4)}) 605 | window.run_command("show_panel", {"panel": f"output.{COPILOT_OUTPUT_PANEL_PREFIX}.prompt_view"}) 606 | 607 | 608 | class CopilotConversationTemplatesCommand(CopilotTextCommand): 609 | @_provide_plugin_session() 610 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 611 | user_prompts: list[CopilotUserDefinedPromptTemplates] = session.config.settings.get("prompts") or [] 612 | session.send_request( 613 | Request(REQ_CONVERSATION_TEMPLATES, {"options": {}}), 614 | lambda payload: self._on_result_conversation_templates(user_prompts, payload), 615 | ) 616 | 617 | def _on_result_conversation_templates( 618 | self, 619 | user_prompts: list[CopilotUserDefinedPromptTemplates], 620 | payload: list[CopilotPayloadConversationTemplate], 621 | ) -> None: 622 | if not (window := self.view.window()): 623 | return 624 | 625 | templates = payload + user_prompts 626 | prompts = [ 627 | [ 628 | item["id"], 629 | item["description"], 630 | ", ".join(item["scopes"]) if item.get("scopes") else "chat-panel", 631 | ] 632 | for item in templates 633 | ] 634 | window.show_quick_panel( 635 | prompts, 636 | lambda index: self._on_selected(index, templates), 637 | ) 638 | 639 | def _on_selected( 640 | self, 641 | index: int, 642 | items: list[CopilotPayloadConversationTemplate | CopilotUserDefinedPromptTemplates], 643 | ) -> None: 644 | if index == -1: 645 | return 646 | self.view.run_command("copilot_conversation_chat", {"message": f"/{items[index]['id']}"}) 647 | 648 | 649 | class CopilotAcceptCompletionCommand(CopilotTextCommand): 650 | @_provide_plugin_session() 651 | def run(self, plugin: CopilotPlugin, session: Session, edit: sublime.Edit) -> None: 652 | if not (vcm := ViewCompletionManager(self.view)).is_visible: 653 | return 654 | 655 | vcm.hide() 656 | if not (completion := vcm.current_completion): 657 | return 658 | 659 | # Remove the current line and then insert full text. 660 | # We don't have to care whether it's an inline completion or not. 661 | source_line_region = self.view.line(completion["point"]) 662 | self.view.erase(edit, source_line_region) 663 | self.view.insert(edit, source_line_region.begin(), completion["text"]) 664 | self.view.show(self.view.sel(), show_surrounds=False, animate=self.view.settings().get("animation_enabled")) 665 | 666 | self._record_telemetry(session, REQ_NOTIFY_ACCEPTED, {"uuid": completion["uuid"]}) 667 | 668 | other_uuids = [completion["uuid"] for completion in vcm.completions] 669 | other_uuids.remove(completion["uuid"]) 670 | if other_uuids: 671 | self._record_telemetry(session, REQ_NOTIFY_REJECTED, {"uuids": other_uuids}) 672 | 673 | 674 | class CopilotRejectCompletionCommand(CopilotTextCommand): 675 | @_provide_plugin_session() 676 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 677 | vcm = ViewCompletionManager(self.view) 678 | vcm.hide() 679 | 680 | self._record_telemetry( 681 | session, 682 | REQ_NOTIFY_REJECTED, 683 | {"uuids": [completion["uuid"] for completion in vcm.completions]}, 684 | ) 685 | 686 | 687 | class CopilotGetPanelCompletionsCommand(CopilotTextCommand): 688 | @_provide_plugin_session() 689 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 690 | if not (doc := prepare_completion_request_doc(self.view)): 691 | return 692 | 693 | vcm = ViewPanelCompletionManager(self.view) 694 | vcm.is_waiting = True 695 | vcm.is_visible = True 696 | vcm.completions = [] 697 | 698 | params = {"doc": doc, "panelId": vcm.panel_id} 699 | session.send_request(Request(REQ_GET_PANEL_COMPLETIONS, params), self._on_result_get_panel_completions) 700 | 701 | def _on_result_get_panel_completions(self, payload: CopilotPayloadPanelCompletionSolutionCount) -> None: 702 | count = payload["solutionCountTarget"] 703 | status_message(f"retrieving panel completions: {count}") 704 | 705 | ViewPanelCompletionManager(self.view).open(completion_target_count=count) 706 | 707 | 708 | class CopilotPreviousCompletionCommand(CopilotTextCommand): 709 | def run(self, _: sublime.Edit) -> None: 710 | ViewCompletionManager(self.view).show_previous_completion() 711 | 712 | 713 | class CopilotNextCompletionCommand(CopilotTextCommand): 714 | def run(self, _: sublime.Edit) -> None: 715 | ViewCompletionManager(self.view).show_next_completion() 716 | 717 | 718 | class CopilotCheckStatusCommand(CopilotTextCommand): 719 | requirement = REQUIRE_NOTHING 720 | 721 | @_provide_plugin_session() 722 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 723 | local_checks = get_session_setting(session, "local_checks") 724 | session.send_request(Request(REQ_CHECK_STATUS, {"localChecksOnly": local_checks}), self._on_result_check_status) 725 | 726 | def _on_result_check_status(self, payload: CopilotPayloadSignInConfirm | CopilotPayloadSignOut) -> None: 727 | if not ((user := payload.get("user")) and isinstance(user, str)): 728 | user = "" 729 | 730 | CopilotPlugin.set_account_status(user=user) 731 | GithubInfo.update_avatar(user) 732 | 733 | if payload["status"] == "OK": 734 | CopilotPlugin.set_account_status(signed_in=True, authorized=True) 735 | message_dialog(f'Signed in and authorized with user "{user}".') 736 | elif payload["status"] == "MaybeOk": 737 | CopilotPlugin.set_account_status(signed_in=True, authorized=True) 738 | message_dialog(f'(localChecksOnly) Signed in and authorized with user "{user}".') 739 | elif payload["status"] == "NotAuthorized": 740 | CopilotPlugin.set_account_status(signed_in=True, authorized=False) 741 | message_dialog("Your GitHub account doesn't subscribe to Copilot.", error=True) 742 | else: 743 | CopilotPlugin.set_account_status(signed_in=False, authorized=False) 744 | message_dialog("You haven't signed in yet.") 745 | 746 | 747 | class CopilotCheckFileStatusCommand(CopilotTextCommand): 748 | @_provide_plugin_session() 749 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 750 | file_path = self.view.file_name() or "" 751 | uri = file_path and filename_to_uri(file_path) 752 | session.send_request(Request(REQ_FILE_CHECK_STATUS, {"uri": uri}), self._on_result_check_file_status) 753 | 754 | def _on_result_check_file_status(self, payload: CopilotPayloadFileStatus) -> None: 755 | status_message(f"File is {payload['status']} in session") 756 | 757 | 758 | class CopilotSignInCommand(CopilotTextCommand): 759 | requirement = REQUIRE_NOT_SIGN_IN 760 | 761 | @_provide_plugin_session() 762 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 763 | session.send_request( 764 | Request(REQ_SIGN_IN_INITIATE, {}), 765 | partial(self._on_result_sign_in_initiate, session), 766 | ) 767 | 768 | def _on_result_sign_in_initiate( 769 | self, 770 | session: Session, 771 | payload: CopilotPayloadSignInConfirm | CopilotPayloadSignInInitiate, 772 | ) -> None: 773 | if payload["status"] == "AlreadySignedIn": 774 | return 775 | CopilotPlugin.set_account_status(signed_in=False, authorized=False, quiet=True) 776 | 777 | user_code = str(payload.get("userCode", "")) 778 | verification_uri = str(payload.get("verificationUri", "")) 779 | if not (user_code and verification_uri): 780 | return 781 | sublime.set_clipboard(user_code) 782 | sublime.run_command("open_url", {"url": verification_uri}) 783 | log_info(f"Sign-in URL: {verification_uri} (User code = {user_code})") 784 | if not ok_cancel_dialog( 785 | "The device activation code has been copied." 786 | + " Please paste it in the popup GitHub page. Press OK when completed." 787 | + " If you don't see a popup GitHub page, please check Sublime Text's console," 788 | + " open the given URL and paste the user code manually.", 789 | ): 790 | return 791 | session.send_request( 792 | Request(REQ_SIGN_IN_CONFIRM, {"userCode": user_code}), 793 | self._on_result_sign_in_confirm, 794 | ) 795 | 796 | def _on_result_sign_in_confirm(self, payload: CopilotPayloadSignInConfirm) -> None: 797 | self.view.run_command("copilot_check_status") 798 | 799 | 800 | class CopilotSignInWithGithubTokenCommand(CopilotTextCommand): 801 | requirement = REQUIRE_NOT_SIGN_IN 802 | 803 | @_provide_plugin_session() 804 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 805 | session.send_request( 806 | Request(REQ_SIGN_IN_INITIATE, {}), 807 | partial(self._on_result_sign_in_initiate, session), 808 | ) 809 | 810 | def _on_result_sign_in_initiate( 811 | self, 812 | session: Session, 813 | payload: CopilotPayloadSignInConfirm | CopilotPayloadSignInInitiate, 814 | ) -> None: 815 | if payload["status"] == "AlreadySignedIn": 816 | return 817 | CopilotPlugin.set_account_status(signed_in=False, authorized=False, quiet=True) 818 | 819 | if not (window := self.view.window()): 820 | return 821 | 822 | window.show_input_panel( 823 | "Github Username", 824 | "", 825 | on_done=lambda username: self._on_select_github_username(session, username), 826 | on_change=None, 827 | on_cancel=None, 828 | ) 829 | 830 | def _on_select_github_username(self, session: Session, username: str) -> None: 831 | if not (window := self.view.window()): 832 | return 833 | 834 | window.show_input_panel( 835 | "Github Token", 836 | "ghu_", 837 | on_done=lambda token: session.send_request( 838 | Request(REQ_SIGN_IN_WITH_GITHUB_TOKEN, {"githubToken": token, "user": username}), 839 | self._on_result_sign_in_confirm, 840 | ), 841 | on_change=None, 842 | on_cancel=None, 843 | ) 844 | 845 | def _on_result_sign_in_confirm(self, payload: CopilotPayloadSignInConfirm) -> None: 846 | self.view.run_command("copilot_check_status") 847 | 848 | 849 | class CopilotSignOutCommand(CopilotTextCommand): 850 | requirement = REQUIRE_SIGN_IN 851 | 852 | @_provide_plugin_session() 853 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 854 | session.send_request(Request(REQ_SIGN_OUT, {}), self._on_result_sign_out) 855 | 856 | def _on_result_sign_out(self, payload: CopilotPayloadSignOut) -> None: 857 | if sublime.platform() == "windows": 858 | session_dir = Path(os.environ.get("LOCALAPPDATA", "")) / "github-copilot" 859 | else: 860 | session_dir = Path.home() / ".config/github-copilot" 861 | 862 | if not session_dir.is_dir(): 863 | message_dialog(f"Failed to find the session directory: {session_dir}", error=True) 864 | return 865 | 866 | rmtree_ex(str(session_dir), ignore_errors=True) 867 | if not session_dir.is_dir(): 868 | CopilotPlugin.set_account_status(signed_in=False, authorized=False, user=None) 869 | message_dialog("Sign out OK. Bye!") 870 | 871 | GithubInfo.clear_avatar() 872 | 873 | 874 | class CopilotConversationDebugCommand(CopilotTextCommand): 875 | @_provide_plugin_session() 876 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit) -> None: 877 | if not (window := self.view.window()): 878 | return 879 | 880 | templates = tuple(CopilotConversationDebugTemplates) 881 | window.show_quick_panel( 882 | [[template.name, template.value] for template in templates], 883 | lambda index: self._on_selected(index, templates), 884 | ) 885 | 886 | def _on_selected(self, index: int, templates: Sequence[CopilotConversationDebugTemplates]) -> None: 887 | if index == -1: 888 | return 889 | self.view.run_command("copilot_conversation_chat", {"message": f"{templates[index].value}"}) 890 | 891 | 892 | class CopilotSendAnyRequestCommand(CopilotTextCommand): 893 | @_provide_plugin_session() 894 | def run(self, plugin: CopilotPlugin, session: Session, _: sublime.Edit, request_type: str, payload: str) -> None: 895 | try: 896 | decode_payload = sublime.decode_value(payload) 897 | except ValueError as e: 898 | message_dialog(f"Failed to parse payload: {e}", error=True) 899 | decode_payload = {} 900 | session.send_request(Request(request_type, decode_payload), self._on_results_any_request) 901 | 902 | def _on_results_any_request(self, payload: Any) -> None: 903 | print(payload) 904 | 905 | def input(self, args: dict[str, Any]) -> sublime_plugin.CommandInputHandler | None: 906 | return CopilotSendAnyRequestCommandTextInputHandler() 907 | 908 | 909 | class CopilotSendAnyRequestCommandTextInputHandler(sublime_plugin.TextInputHandler): 910 | def placeholder(self) -> str: 911 | return "Enter type of request. Example: conversation/turn" 912 | 913 | def name(self) -> str: 914 | return "request_type" 915 | 916 | def next_input(self, args: dict[str, Any]) -> sublime_plugin.CommandInputHandler | None: 917 | return CopilotSendAnyRequestPayloadInputHandler(args) 918 | 919 | 920 | class CopilotSendAnyRequestPayloadInputHandler(sublime_plugin.TextInputHandler): 921 | def __init__(self, args: dict[str, Any]) -> None: 922 | self.args = args 923 | 924 | def placeholder(self) -> str: 925 | return 'Enter payload JSON. Example: {"conversationId": "12345"}' 926 | 927 | def name(self) -> str: 928 | return "payload" 929 | --------------------------------------------------------------------------------