├── .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 |
9 |
× Close
10 |
11 | {% if is_waiting %}
12 | ⌛ Synthesizing {{ sections|length }} unique solutions out of {{ total_solutions }}...
13 | {% else %}
14 | Synthesized {{ sections|length }} unique solutions out of {{ total_solutions }}. (Done)
15 | {% endif %}
16 |
17 |
18 |
19 | {% for section in sections %}
20 |
21 |
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 |
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 |
7 |
8 |
 }})
9 |
 }})
10 |
11 |
12 | {% if is_waiting %} ⌛ {% endif %}Copilot Chat {% if suggested_title %}| {{ suggested_title }}{% endif %}
13 |
14 |
15 |
16 | ---
17 |
18 | {% for section in sections %}
19 |
20 |
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 |
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 | ""
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 |
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 | 
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 | 
60 |
61 | ### Inline Completion Phantom
62 |
63 | 
64 |
65 | ### Panel Completion
66 |
67 | 
68 |
69 | ### Chat
70 |
71 | 
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 |
--------------------------------------------------------------------------------