diff --git a/README.md b/README.md index 3ff4136c55..6423db7dc6 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ lsp-bridge first looks for the content of the first *.pub file in the `~/.ssh` d - `acm-enable-icon`: Whether the completion menu displays icons (Many macOS users have reported that emacs-plus28 cannot display icons properly, showing colored squares instead. There are two ways to solve this: install Emacs Mac Port or add the `--with-rsvg` option to the brew command when compiling Emacs yourself) - `acm-enable-tabnine`: Enable tabnine support, enable by default, when enable need execute `lsp-bridge-install-tabnine` command to install TabNine, and it can be used. TabNine will consume huge CPUs, causing your entire computer to be slow. If the computer performance is not good, it is not recommended to enable this option - `acm-enable-codeium`: Enable Codeium support, when enable need execute `lsp-bridge-install-update-codeium` command to install Codeium, then execute `lsp-bridge-codeium-auth` command to get auth token and execute `lsp-bridge-codeium-input-auth-token` command to get API Key, and it can be used. +- `acm-enable-copilot`: Enable copilot support, when enable need install agent first `npm install -g copilot-node-server`, then execute `lsp-bridge-copilot-auth` command to login, and it can be used. - `acm-enable-search-file-words`: Whether the complete menu display the word of the file, enable by default - `acm-enable-quick-access`: Whether to display an index after the icon, quickly select candidate words using Alt + Number, default is off - `acm-quick-access-use-number-select`: Whether to use number keys for quick selection of candidate words, default is off, turning on this option may sometimes interfere with number input or accidentally select candidate words @@ -368,6 +369,7 @@ The following is the directory structure of the lsp-bridge project: | core/hanlder/ | Implementation of LSP message sending and receiving, where `__init__.py` is the base class. | | core/tabnine.py | The backend searches and completes with TabNine. | | core/codeium.py | The backend searches and completes with Codeium. | +| core/copilot.py | The backend searches and completes with Copilot. | | core/search_file_words.py | Asynchronous search backend for file words. | | core/search_paths.py | Asynchronous search backend for file paths. | | core/search_sdcv_words.py | English word search backend, interchangeable with other language’s StarDict dictionaries. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 77a8181fbb..ec7daca8f4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -191,6 +191,7 @@ lsp-bridge 优先从`~/.ssh`目录下找第一个 *.pub 文件的内容作为远 - `acm-enable-doc-markdown-render`: 对补全文档中的 Markdown 内容进行语法着色, 你可以选择`'async`, `t` 或者 `nil`. 当选择`'async` 时, lsp-bridge 会采用异步渲, 当选择 `t` 时, lsp-bridge 会采用同步渲染, 同步渲染会降低补全速度, 默认是 `async` 选项 - `acm-enable-tabnine`: 是否打开 tabnine 补全支持, 默认打开, 打开后需要运行命令 `lsp-bridge-install-tabnine` 来安装 tabnine 后就可以使用了。 TabNine 会消耗巨大的 CPU, 导致你整个电脑都卡顿, 如果电脑性能不好, 不建议开启此选项 - `acm-enable-codeium`: 是否打开 Codeium 补全支持, 打开后需要运行命令 `lsp-bridge-install-update-codeium` 来安装 Codeium, 再运行命令 `lsp-bridge-codeium-auth` 来获取 auth token 再运行命令 `lsp-bridge-codeium-input-auth-token` 获取 API Key 后就可以使用了。 +- `acm-enable-copilot`: 是否打开 Copilot 补全支持, 打开后需要运行终端命令 `npm install -g copilot-node-server` 来安装 Copilot, 再运行命令 `lsp-bridge-copilot-auth` 来登录。 - `acm-enable-search-file-words`: 补全菜单是否显示打开文件的单词, 默认打开 - `acm-enable-quick-access`: 是否在图标后面显示索引, 通过 Alt + Number 来快速选择候选词, 默认关闭 - `acm-quick-access-use-number-select`: 是否用数字键快速选择候选词, 默认关闭, 打开这个选项会导致有时候干扰数字输入或误选候选词 @@ -369,6 +370,7 @@ lsp-bridge 每种语言的服务器配置存储在 [lsp-bridge/langserver](https | core/hanlder/ | LSP 消息发送和接受的实现, 其中 `__init__.py` 是基类 | | core/tabnine.py | TabNine 后端搜索和补全 | | core/codeium.py | Codeium 后端搜索和补全 | +| core/copilot.py | Copilot 后端搜索和补全 | | core/search_file_words.py | 文件单词异步搜索后端 | | core/search_paths.py | 文件路径异步搜索后端 | | core/search_sdcv_words.py | 英文单词搜索后端, 可更换为其他语言的 StarDict 词典 | diff --git a/acm/acm-backend-copilot.el b/acm/acm-backend-copilot.el new file mode 100644 index 0000000000..4b1549ccb3 --- /dev/null +++ b/acm/acm-backend-copilot.el @@ -0,0 +1,85 @@ +;;; acm-backend-copilot.el --- Description -*- lexical-binding: t; -*- +;; +;; Copyright (C) 2023 royokong +;; +;; Author: royokong +;; Maintainer: royokong +;; Created: 七月 22, 2023 +;; Modified: 七月 22, 2023 +;; Version: 0.0.1 +;; Keywords: abbrev bib c calendar comm convenience data docs emulations extensions faces files frames games hardware help hypermedia i18n internal languages lisp local maint mail matching mouse multimedia news outlines processes terminals tex tools unix vc wp +;; Homepage: https://github.com/royokong/acm-backend-copilot +;; Package-Requires: ((emacs "24.3")) +;; +;; This file is not part of GNU Emacs. +;; +;;; Commentary: +;; +;; Description +;; +;;; Code: + +(defcustom acm-enable-copilot nil + "Enable copilot support." + :type 'boolean + :group 'acm-backend-copilot) + +(defcustom acm-backend-copilot-node-path "node" + "The path to store Codeium API Key." + :type 'string + :group 'acm-backend-copilot) + +(defcustom acm-backend-copilot-accept nil + "Send accept request." + :type 'boolean + :group 'acm-backend-copilot) + +(defcustom acm-backend-copilot-network-proxy nil + " from `copilot.el' + Network proxy to use for Copilot. Nil means no proxy. +Format: '(:host \"127.0.0.1\" :port 80 :username \"username\" :password \"password\") +Username and password are optional. + +If you are using a MITM proxy which intercepts TLS connections, you may need to disable +TLS verification. This can be done by setting a pair ':rejectUnauthorized :json-false' +in the proxy plist. For example: + + (:host \"127.0.0.1\" :port 80 :rejectUnauthorized :json-false) +" + :type '(plist :tag "Uncheck all to disable proxy" :key-type symbol) + :options '((:host string) (:port integer) (:username string) (:password string)) + :group 'acm-backend-copilot) + + +(defvar-local acm-backend-copilot-items nil) + +(defun acm-backend-copilot-check-node-version () + (and (locate-file acm-backend-copilot-node-path exec-path) + ;; following copilot.el to check node version only >= 16 + (>= (->> (with-output-to-string + (call-process acm-backend-copilot-node-path nil standard-output nil "--version")) + (s-trim) + (s-chop-prefix "v") + (string-to-number)) 16))) + +(defun acm-backend-copilot-candidates (keyword) + acm-backend-copilot-items) + + +(defun acm-backend-copilot-candidate-expand (candidate-info bound-start &optional preview) + ;; We need replace whole area with copilot label. + (let ((end-position (line-end-position))) + (forward-line (- (plist-get candidate-info :line) (count-lines (point-min) (line-beginning-position)))) + (if preview + (acm-preview-create-overlay (point) end-position (plist-get candidate-info :label)) + (delete-region (point) end-position) + (insert (plist-get candidate-info :label)) + (when acm-backend-copilot-accept + (lsp-bridge-call-async + "copilot_completion_accept" (plist-get candidate-info :id)))))) + +(defun acm-backend-copilot-candidate-doc (candidate) + (plist-get candidate :documentation)) + +(provide 'acm-backend-copilot) +;;; acm-backend-copilot.el ends here diff --git a/acm/acm.el b/acm/acm.el index 276c6d9c0e..217983bea9 100644 --- a/acm/acm.el +++ b/acm/acm.el @@ -104,6 +104,7 @@ (require 'acm-backend-tailwind) (require 'acm-backend-citre) (require 'acm-backend-codeium) +(require 'acm-backend-copilot) (require 'acm-quick-access) ;;; Code: @@ -180,6 +181,7 @@ (defcustom acm-completion-backend-merge-order '("mode-first-part-candidates" "template-first-part-candidates" "tabnine-candidates" + "copilot-candidates" "codeium-candidates" "template-second-part-candidates" "mode-second-part-candidates") @@ -370,6 +372,7 @@ Only calculate template candidate when type last character." yas-candidates tabnine-candidates codeium-candidates + copilot-candidates tempel-candidates mode-candidates mode-first-part-candidates @@ -385,6 +388,9 @@ Only calculate template candidate when type last character." (when acm-enable-codeium (setq codeium-candidates (acm-backend-codeium-candidates keyword))) + (when acm-enable-copilot + (setq copilot-candidates (acm-backend-copilot-candidates keyword))) + (if acm-enable-search-sdcv-words ;; Completion SDCV if option `acm-enable-search-sdcv-words' is enable. (setq candidates (acm-backend-search-sdcv-words-candidates keyword)) @@ -462,6 +468,7 @@ Only calculate template candidate when type last character." ("template-first-part-candidates" template-first-part-candidates) ("tabnine-candidates" tabnine-candidates) ("codeium-candidates" codeium-candidates) + ("copilot-candidates" copilot-candidates) ("template-second-part-candidates" template-second-part-candidates) ("mode-second-part-candidates" mode-second-part-candidates) )) @@ -694,7 +701,10 @@ The key of candidate will change between two LSP results." (when acm-preview-overlay (delete-overlay acm-preview-overlay)) (if (and (fboundp candidate-expand) ;; check if candidate-expand support preview. - (string-match " PREVIEW" (documentation candidate-expand t))) + (let ((doc (documentation candidate-expand t))) + (if doc + (string-match " PREVIEW" doc) + (member 'preview (help-function-arglist candidate-expand))))) (save-excursion (setq acm-preview-overlay (funcall candidate-expand candidate-info beg t))) (setq acm-preview-overlay (acm-preview-create-overlay beg (point) cand))) @@ -854,7 +864,7 @@ The key of candidate will change between two LSP results." (visual-line-mode 1)) ;; Only render markdown styling when idle 200ms, because markdown render is expensive. - (when (member backend '("lsp" "codeium")) + (when (member backend '("lsp" "codeium" "copilot")) (acm-cancel-timer acm-markdown-render-timer) (cl-case acm-enable-doc-markdown-render (async (setq acm-markdown-render-timer diff --git a/core/copilot.py b/core/copilot.py new file mode 100644 index 0000000000..d9bab7c03c --- /dev/null +++ b/core/copilot.py @@ -0,0 +1,236 @@ +import time +import subprocess +from typing_extensions import Required +from core.utils import * +from subprocess import PIPE +from core.lspserver import LspServerSender, LspServerReceiver +import threading +import traceback + +COPILOT_MAJOR_MODES_MAP = { + "rustic": "rust", + "cperl": "perl", + "c++": "cpp", + "objc": "objective-c", + "cuda": "cuda-cpp", + "docker-compose": "dockercompose", + "coffee": "coffeescript", + "js": "javascript", + "js2": "javascript", + "js2-jsx": "javascriptreact", + "typescript-tsx": "typescriptreact", + "rjsx": "typescriptreact", + "less-css": "less", + "text": "plaintext", + "ess-r": "r", + "enh-ruby": "ruby", + "shell-script": "shellscript", + "sh": "shellscript", + "visual-basic": "vb", + "nxml": "xml", +} + +class Copilot: + def __init__(self): + self.is_run = False + self.is_get_info = False + + (self.node_path, ) = get_emacs_vars(["acm-backend-copilot-node-path"]) + + npm_prefix = subprocess.check_output(['npm', 'config', 'get', 'prefix'], universal_newlines=True).strip() + self.agent_path = os.path.join(f'{npm_prefix}/lib/node_modules', "copilot-node-server", "copilot/dist/agent.js") + + self.try_completion_timer = None + self.file_versions = {} + self.counter = 1 + self.wait_request = [] + self.is_get_info = False + self.wait_id = None + + def start_copilot(self): + self.get_info() + + if self.is_run: + return + + self.is_run = True + + self.copilot_subprocess = subprocess.Popen([self.node_path, self.agent_path], + stdin=PIPE, + stdout=PIPE, + stderr=None) + + self.receiver = LspServerReceiver(self.copilot_subprocess, 'copilot') + self.receiver.start() + + self.sender = LspServerSender(self.copilot_subprocess, 'copilot', 'copilot_backend') + self.sender.start() + + self.dispatcher = threading.Thread(target=self.message_dispatcher) + self.dispatcher.start() + + self.sender.send_request('initialize', {'capabilities': {'workspace': {'workspaceFolders': True}}}, generate_request_id(), init=True) + + self.sender.initialized.set() + + editor_info = {'editorInfo': {'name': 'Emacs', 'version': '28.0'}, + 'editorPluginInfo': {'name': 'lsp-bridge', 'version': '0.0.1'}, + 'networkProxy': epc_arg_transformer(self.proxy)} + self.sender.send_request('setEditorInfo', editor_info, generate_request_id()) + + def get_language_id(self, editor_mode): + language_id = editor_mode.replace('-mode', '') + return COPILOT_MAJOR_MODES_MAP[language_id] if language_id in COPILOT_MAJOR_MODES_MAP else language_id + + def accpet(self, id): + self.sender.send_request('notifyAccepted', [{'id': id}, ], generate_request_id()) + + def message_dispatcher(self): + try: + while True: + message = self.receiver.get_message() + message = message['content'] + + if 'id' in message and message['id'] == self.wait_id: + self.wait_response = message + self.wait_id = None + elif 'result' in message and 'completions' in message['result']: + for completion in message['result']['completions']: + label = completion['text'] + labels = label.strip().split("\n") + first_line = labels[0] + + document = f"```{self.current_language_id}\n{label}\n```" + + display_label = first_line + if len(first_line) > self.display_label_max_length: + display_label = "... " + display_label[len(first_line) - self.display_label_max_length:] + + if len(labels) <= 1 and len(first_line) <= self.display_label_max_length: + document = "" + + line = completion['position']['line'] + candidate = { + "key": label, + "icon": "copilot", + "label": label, + "display-label": first_line, + "annotation": "Copilot", + "backend": "copilot", + "documentation": document, + "id": completion['uuid'], + "line": line, + } + + completion_candidates.append(candidate) + + eval_in_emacs( + "lsp-bridge-search-backend--record-items", "copilot", completion_candidates + ) + + completion_candidates = [] + except: + logger.error(traceback.format_exc()) + + def sync_file(self, text, file_path, language_id): + if file_path in self.file_versions: + self.sender.send_notification( + method='textDocument/didChange', + params={ + 'textDocument': { + 'uri': path_to_uri(file_path), + 'version': self.file_versions[file_path] + }, + 'contentChanges': [{'text': text}] + }) + else: + self.file_versions[file_path] = 0 + self.sender.send_notification( + method='textDocument/didOpen', + params={ + 'textDocument': { + 'uri': path_to_uri(file_path), + 'version': self.file_versions[file_path], + 'languageId': language_id, + 'text': text + } + }) + + def complete(self, position, editor_mode, file_path, relative_path, tab_size, text, insert_spaces): + if len(file_path) == 0: + return + + self.start_copilot() + + if self.try_completion_timer is not None and self.try_completion_timer.is_alive(): + self.try_completion_timer.cancel() + + if file_path in self.file_versions: + self.file_versions[file_path] += 1 + + + self.sync_file(text, file_path, self.get_language_id(editor_mode)) + self.current_language_id = self.get_language_id(editor_mode) + self.message = { + "doc": + { + "version": self.file_versions[file_path], + "tabSize": tab_size, + "indentSize": tab_size, + "insertSpaces": insert_spaces, + "path": file_path, + "uri": path_to_uri(file_path), + "relativePath": relative_path, + "languageId": self.current_language_id, + "position": {"line": position[1], "character": position[3]}, + } + } + self.file_versions[file_path] += 1 + + self.try_completion_timer = threading.Timer(0.0, self.do_complete) + self.try_completion_timer.start() + + def do_complete(self): + request_id = generate_request_id() + self.sender.send_request( + method='getCompletions', + params=self.message, + request_id=request_id + ) + + + def get_info(self): + if self.is_get_info: + return + ( + EMACS_VERSION, + self.display_label_max_length, + self.proxy, + ) = get_emacs_vars( + [ + "emacs-version", + "acm-backend-codeium-candidate-max-length", + "acm-backend-copilot-network-proxy", + ] + ) + + self.is_get_info = True + + + def login(self): + self.start_copilot() + self.wait_id = generate_request_id() + self.sender.send_request('signInInitiate', {'dummy': "signInInitiate"}, self.wait_id) + while self.wait_id is not None: + time.sleep(0.1) + result = self.wait_response['result'] + if result['status'] == 'AlreadySignedIn': + message_emacs(f'Already signed in as {result["user"]}') + return + message_emacs(f'Please enter user-code {result["userCode"]}') + eval_in_emacs("browse-url", result['verificationUri']) + + def logout(self): + self.start_copilot() + self.sender.send_request('signOut', {"dummy": "signOut"}, generate_request_id()) + message_emacs('Logged out') diff --git a/lsp-bridge-lsp-installer.el b/lsp-bridge-lsp-installer.el index 3250e7c589..4d282c8d4e 100644 --- a/lsp-bridge-lsp-installer.el +++ b/lsp-bridge-lsp-installer.el @@ -291,6 +291,15 @@ Only useful on GNU/Linux. Automatically set if NixOS is detected." (setq codeium-bridge-binary-version version) (message "Done.")))) +(defun lsp-bridge-copilot-login () + (interactive) + (lsp-bridge-call-async "copilot_login")) + +(defun lsp-bridge-copilot-logout () + (interactive) + (lsp-bridge-call-async "copilot_logout")) + + (provide 'lsp-bridge-lsp-installer) ;;; lsp-bridge-lsp-installer.el ends here diff --git a/lsp-bridge.el b/lsp-bridge.el index 12a9006046..ad7cac9bcc 100644 --- a/lsp-bridge.el +++ b/lsp-bridge.el @@ -1341,6 +1341,17 @@ So we build this macro to restore postion after code format." (when acm-enable-tabnine (lsp-bridge-tabnine-complete)) + ;; Copilot search. + (when (and acm-enable-copilot + ;; Copilot backend not support remote file now, disable it temporary. + (not (lsp-bridge-is-remote-file)) + ;; Don't enable copilot on Markdown mode, Org mode, ielm and minibuffer, very disruptive to writing. + (not (or (derived-mode-p 'markdown-mode) + (eq major-mode 'org-mode) + (derived-mode-p 'inferior-emacs-lisp-mode) + (minibufferp)))) + (lsp-bridge-copilot-complete)) + ;; Codeium search. (when (and acm-enable-codeium ;; Codeium backend not support remote file now, disable it temporary. @@ -1352,6 +1363,7 @@ So we build this macro to restore postion after code format." (minibufferp)))) (lsp-bridge-codeium-complete)) + ;; Search sdcv dictionary. (when acm-enable-search-sdcv-words ;; Search words if current prefix is not empty. @@ -2201,9 +2213,44 @@ We need exclude `markdown-code-fontification:*' buffer in `lsp-bridge-monitor-be (acm-get-input-prefix) language)))) +(defun lsp-bridge-copilot-complete () + (interactive) + (setq-local acm-backend-lsp-fetch-completion-item-ticker nil) + (let ((all-text (buffer-substring-no-properties (point-min) (point-max))) + (relative-path + ;; from copilot.el + (cond + ((not buffer-file-name) + "") + ((fboundp 'projectile-project-root) + (file-relative-name buffer-file-name (projectile-project-root))) + ((boundp 'vc-root-dir) + (file-relative-name buffer-file-name (vc-root-dir))) + (t + (file-name-nondirectory buffer-file-name))))) + (if (lsp-bridge-is-remote-file) + (lsp-bridge-remote-send-func-request "copilot_complete" + (list + (lsp-bridge--position) + (symbol-name major-mode) + (buffer-file-name) + relative-path + tab-width + all-text + (not indent-tabs-mode))) + (lsp-bridge-call-async "copilot_complete" + (lsp-bridge--position) + (symbol-name major-mode) + (buffer-file-name) + relative-path + tab-width + all-text + (not indent-tabs-mode))))) + (defun lsp-bridge-search-backend--record-items (backend-name items) (pcase backend-name ("codeium" (setq-local acm-backend-codeium-items items)) + ("copilot" (setq-local acm-backend-copilot-items items)) ("file-words" (setq-local acm-backend-search-file-words-items items)) ("sdcv-words" (setq-local acm-backend-search-sdcv-words-items items)) ("tabnine" (setq-local acm-backend-tabnine-items items)) diff --git a/lsp_bridge.py b/lsp_bridge.py index 5c2cb2fe29..f07aa5be72 100755 --- a/lsp_bridge.py +++ b/lsp_bridge.py @@ -41,6 +41,7 @@ from core.search_paths import SearchPaths from core.tabnine import TabNine from core.codeium import Codeium +from core.copilot import Copilot from core.utils import * from core.handler import * from core.remote_file import RemoteFileClient, RemoteFileServer, save_ip @@ -104,6 +105,9 @@ def init_search_backends(self): # Init codeium self.codeium = Codeium() + # Init copilot + self.copilot = Copilot() + # Init search backends. self.search_file_words = SearchFileWords() self.search_sdcv_words = SearchSdcvWords() @@ -711,6 +715,9 @@ def _do(*args, **kwargs): def tabnine_complete(self, before, after, filename, region_includes_beginning, region_includes_end, max_num_results): self.tabnine.complete(before, after, filename, region_includes_beginning, region_includes_end, max_num_results) + def copilot_complete(self, position, editor_mode, file_path, relative_path, tab_size, text, insert_spaces): + self.copilot.complete(position, editor_mode, file_path, relative_path, tab_size, text, insert_spaces) + def codeium_complete(self, cursor_offset, editor_language, tab_size, text, insert_spaces, prefix, language): self.codeium.complete(cursor_offset, editor_language, tab_size, text, insert_spaces, prefix, language) @@ -720,6 +727,15 @@ def codeium_completion_accept(self, id): def codeium_auth(self): self.codeium.auth() + def copilot_login(self): + self.copilot.login() + + def copilot_logout(self): + self.copilot.logout() + + def copilot_completion_accept(self, id): + self.copilot.accept(id) + def codeium_get_api_key(self, auth_token): self.codeium.get_api_key(auth_token)