diff --git a/ColorSchemes/Breakers.sublime-color-scheme b/ColorSchemes/Breakers.sublime-color-scheme index 2205fda4b..82018c25b 100644 --- a/ColorSchemes/Breakers.sublime-color-scheme +++ b/ColorSchemes/Breakers.sublime-color-scheme @@ -11,6 +11,11 @@ { "scope": "meta.semantic-token", "background": "#00000001" - } + }, + { + "scope": "meta.inline-completion", + "foreground": "color(var(grey3) alpha(0.6))", + "font_style": "italic" + }, ] } diff --git a/ColorSchemes/Celeste.sublime-color-scheme b/ColorSchemes/Celeste.sublime-color-scheme index 169e486b9..ded575b63 100644 --- a/ColorSchemes/Celeste.sublime-color-scheme +++ b/ColorSchemes/Celeste.sublime-color-scheme @@ -11,6 +11,11 @@ { "scope": "meta.semantic-token", "background": "#00000001" - } + }, + { + "scope": "meta.inline-completion", + "foreground": "color(var(black) alpha(0.6))", + "font_style": "italic" + }, ] } diff --git a/ColorSchemes/Mariana.sublime-color-scheme b/ColorSchemes/Mariana.sublime-color-scheme index 8aed2ae52..034fa9b1d 100644 --- a/ColorSchemes/Mariana.sublime-color-scheme +++ b/ColorSchemes/Mariana.sublime-color-scheme @@ -11,6 +11,11 @@ { "scope": "meta.semantic-token", "background": "#00000001" - } + }, + { + "scope": "meta.inline-completion", + "foreground": "color(var(white3) alpha(0.6))", + "font_style": "italic" + }, ] } diff --git a/ColorSchemes/Monokai.sublime-color-scheme b/ColorSchemes/Monokai.sublime-color-scheme index 995afb972..77e08e7da 100644 --- a/ColorSchemes/Monokai.sublime-color-scheme +++ b/ColorSchemes/Monokai.sublime-color-scheme @@ -11,6 +11,11 @@ { "scope": "meta.semantic-token", "background": "#00000001" - } + }, + { + "scope": "meta.inline-completion", + "foreground": "color(var(white3) alpha(0.6))", + "font_style": "italic" + }, ] } diff --git a/ColorSchemes/Sixteen.sublime-color-scheme b/ColorSchemes/Sixteen.sublime-color-scheme index 2205fda4b..77a6fe9d5 100644 --- a/ColorSchemes/Sixteen.sublime-color-scheme +++ b/ColorSchemes/Sixteen.sublime-color-scheme @@ -11,6 +11,11 @@ { "scope": "meta.semantic-token", "background": "#00000001" - } + }, + { + "scope": "meta.inline-completion", + "foreground": "color(var(grey5) alpha(0.6))", + "font_style": "italic" + }, ] } diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 325077470..74c689493 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -9,14 +9,26 @@ // "args": {"overlay": "command_palette", "text": "LSP: "} // }, // Insert/Replace Completions + // { + // "keys": ["UNBOUND"], + // "command": "lsp_commit_completion_with_opposite_insert_mode", + // "context": [ + // {"key": "lsp.session_with_capability", "operand": "completionProvider"}, + // {"key": "auto_complete_visible"} + // ] + // }, + // Insert Inline Completion { "keys": ["alt+enter"], - "command": "lsp_commit_completion_with_opposite_insert_mode", - "context": [ - {"key": "lsp.session_with_capability", "operand": "completionProvider"}, - {"key": "auto_complete_visible"} - ] + "command": "lsp_commit_inline_completion", + "context": [{"key": "lsp.inline_completion_visible"}] }, + // Show next Inline Completion + // { + // "keys": ["UNBOUND"], + // "command": "lsp_next_inline_completion", + // "context": [{"key": "lsp.inline_completion_visible"}] + // }, // Save all open files that have a language server attached with lsp_save // { // "keys": ["UNBOUND"], diff --git a/boot.py b/boot.py index 56ae078db..774abd9cd 100644 --- a/boot.py +++ b/boot.py @@ -59,6 +59,9 @@ from .plugin.hover import LspToggleHoverPopupsCommand from .plugin.inlay_hint import LspInlayHintClickCommand from .plugin.inlay_hint import LspToggleInlayHintsCommand +from .plugin.inline_completion import LspCommitInlineCompletionCommand +from .plugin.inline_completion import LspInlineCompletionCommand +from .plugin.inline_completion import LspNextInlineCompletionCommand from .plugin.panels import LspClearLogPanelCommand from .plugin.panels import LspClearPanelCommand from .plugin.panels import LspShowDiagnosticsPanelCommand @@ -99,6 +102,7 @@ "LspCollapseTreeItemCommand", "LspColorPresentationCommand", "LspCommitCompletionWithOppositeInsertMode", + "LspCommitInlineCompletionCommand", "LspCopyToClipboardFromBase64Command", "LspDisableLanguageServerGloballyCommand", "LspDisableLanguageServerInProjectCommand", @@ -120,7 +124,9 @@ "LspHierarchyToggleCommand", "LspHoverCommand", "LspInlayHintClickCommand", + "LspInlineCompletionCommand", "LspNextDiagnosticCommand", + "LspNextInlineCompletionCommand", "LspOnDoubleClickCommand", "LspOpenLinkCommand", "LspOpenLocationCommand", diff --git a/docs/src/customization.md b/docs/src/customization.md index ba133ea5c..c10738889 100644 --- a/docs/src/customization.md +++ b/docs/src/customization.md @@ -227,3 +227,9 @@ The color scheme rule only works if the "background" color is (marginally) diffe | ----- | ----------- | | `markup.accent.codelens.lsp` | Accent color for code lens annotations | | `markup.accent.codeaction.lsp` | Accent color for code action annotations | + +### Inline Completions + +| scope | description | +| ----- | ----------- | +| `meta.inline-completion.lsp` | Style for inline completions | diff --git a/docs/src/features.md b/docs/src/features.md index 67d156aca..a73da87a3 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -170,6 +170,15 @@ Inlay hints are disabled by default and can be enabled with the `"show_inlay_hin !!! info "Some servers require additional settings to be enabled in order to show inlay hints." +## Inline Completions + +Inline completions are typically provided by an AI code assistant. +They can span multiple lines and are rendered directly in the source code as grayed out text ("ghost text"). + +!!! note + Currently inline completions are only requested when you manually trigger auto-completions (Ctrl + Space). + Inline completions are disabled if you have enabled `"mini_auto_complete"`. + ## Server Commands In Sublime Text you can bind any runnable command to a key or add it to various UI elements. Commands in Sublime Text are normally supplied by plugins or packages written in Python. A language server may provide a runnable command as well. These kinds of commands are wrapped in an `lsp_execute` Sublime command that you can bind to a key. diff --git a/docs/src/keyboard_shortcuts.md b/docs/src/keyboard_shortcuts.md index d5ee364ae..e3ebf4ba1 100644 --- a/docs/src/keyboard_shortcuts.md +++ b/docs/src/keyboard_shortcuts.md @@ -8,6 +8,7 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | Feature | Shortcut | Command | | ------- | -------- | ------- | | Auto Complete | ctrl space (also on macOS) | `auto_complete` +| Commit Inline Completion | alt enter | `lsp_commit_inline_completion` | Expand Selection | unbound | `lsp_expand_selection` | Find References | shift f12 | `lsp_symbol_references`
Supports optional args: `{"include_declaration": true | false, "output_mode": "output_panel" | "quick_panel"}`.
Triggering from context menus while holding ctrl opens in "side by side" mode. Holding shift triggers opposite behavior relative to what `show_references_in_quick_panel` is set to. | Fold | unbound | `lsp_fold`
Supports optional args: `{"strict": true/false}` - to configure whether to fold only when the caret is contained within the folded region (`true`), or even when it is anywhere on the starting line (`false`). @@ -28,6 +29,7 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | Next Diagnostic | unbound | `lsp_next_diagnostic` | Previous Diagnostic | unbound | `lsp_prev_diagnostic` | Rename | unbound | `lsp_symbol_rename` +| Request Inline Completions | unbound | `lsp_inline_completion` | Restart Server | unbound | `lsp_restart_server` | Run Code Action | unbound | `lsp_code_actions` | Run Code Lens | unbound | `lsp_code_lens` @@ -35,6 +37,7 @@ Refer to the [Customization section](customization.md#keyboard-shortcuts-key-bin | Run Source Action | unbound | `lsp_code_actions`
With args: `{"only_kinds": ["source"]}`. | Save All | unbound | `lsp_save_all`
Supports optional args `{"only_files": true | false}` - whether to ignore buffers which have no associated file on disk. | Show Call Hierarchy | unbound | `lsp_call_hierarchy` +| Show next Inline Completion | unbound | `lsp_next_inline_completion` | Show Type Hierarchy | unbound | `lsp_type_hierarchy` | Signature Help | ctrl alt space | `lsp_signature_help_show` | Toggle Diagnostics Panel | ctrl alt m | `lsp_show_diagnostics_panel` diff --git a/docs/src/language_servers.md b/docs/src/language_servers.md index 4026f2151..3cc843b0d 100644 --- a/docs/src/language_servers.md +++ b/docs/src/language_servers.md @@ -12,6 +12,79 @@ If there are no setup steps for a language server on this page, but a [language !!! info "For legacy ST3 docs, see [lsp.readthedocs.io](https://lsp.readthedocs.io)." +## Universal + +### Tabby + +[Tabby](https://tabby.tabbyml.com/) is a self-hosted AI coding assistant which can provide inline completions for [various programming languages](https://tabby.tabbyml.com/docs/references/programming-languages/). + +In order to use Tabby you need a sufficiently fast GPU; the CPU version which can also be downloaded from the GitHub releases page is much too slow and it will result in timeouts for the completion requests. +Alternatively, Tabby can be setup on a separate server with capable hardware; see the [Configuration docs](https://tabby.tabbyml.com/docs/extensions/configurations/) for the required configuration details. +The following steps describe a local installation on a Windows PC with compatible Nvidia GPU. More installation methods and the steps for other operation systems are listed in the [Tabby docs](https://tabby.tabbyml.com/docs/quick-start/installation/docker/). + +1. Download and install the CUDA Toolkit from + +2. Download and extract a CUDA version of Tabby from the [GitHub releases page](https://github.com/TabbyML/tabby/releases) (click on "Assets"); e.g. `tabby_x86_64-windows-msvc-cuda122.zip` + +3. On macOS and Linux it might be necessary to change the access permissions of `lama-server` and `tabby` to be executable: + + ```sh + $ chmod +x llama-server + $ chmod +x tabby + ``` + + !!! note "On macOS you might get an error that “tabby” cannot be opened because it is from an unidentified developer." + After changing the permission to executable, right click on `tabby` and select "Open", that will get rid of the error. + +4. Download a completion model (see for available model files and GPU requirements): + + ```sh + tabby download --model StarCoder-1B + ``` + +5. Install the `tabby-agent` language server via npm (requires NodeJS): + + ```sh + npm install -g tabby-agent + ``` + +6. If necessary, edit the configuration file under `~/.tabby-client/agent/config.toml`, which is generated automatically on the first start of tabby-agent. + For example, to disable anonymous usage tracking add + + ```toml + [anonymousUsageTracking] + disable = true + ``` + +7. Open `Preferences > Package Settings > LSP > Settings` and add the `"tabby"` client configuration to the `"clients"`: + + ```jsonc + { + "clients": { + "tabby": { + "enabled": true, + "command": ["tabby-agent", "--stdio"], + "selector": "source.js | source.python | source.rust", // replace with your relevant filetype(s) + "disabled_capabilities": { + "completionProvider": true + } + }, + } + } + ``` + +8. Manually start the Tabby backend: + + ```sh + tabby serve --model StarCoder-1B --no-webserver + ``` + + The language server communicates with this backend, i.e. it needs to be running in order for `tabby-agent` to work. + +9. Now you can open a file in Sublime Text and start coding. + Inline completions are requested when you manually trigger auto-complete via Ctrl + Space. + + ## Angular Follow installation instructions on [LSP-angular](https://github.com/sublimelsp/LSP-angular). diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index b4392348d..0c15447de 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -114,6 +114,7 @@ from enum import IntEnum, IntFlag from typing import Any, Callable, Generator, List, Protocol, TypeVar from typing import cast +from typing import TYPE_CHECKING from typing_extensions import TypeAlias, TypeGuard from weakref import WeakSet import functools @@ -122,6 +123,11 @@ import sublime import weakref + +if TYPE_CHECKING: + from ..inline_completion import InlineCompletionData + + InitCallback: TypeAlias = Callable[['Session', bool], None] T = TypeVar('T') @@ -325,6 +331,9 @@ def get_initialize_params(variables: dict[str, str], workspace_folders: list[Wor "itemDefaults": ["editRange", "insertTextFormat", "data"] } }, + "inlineCompletion": { + "dynamicRegistration": True + }, "signatureHelp": { "dynamicRegistration": True, "contextSupport": True, @@ -701,6 +710,7 @@ class AbstractViewListener(metaclass=ABCMeta): view = cast(sublime.View, None) hover_provider_count = 0 + inline_completion = cast('InlineCompletionData', None) @abstractmethod def session_async(self, capability: str, point: int | None = None) -> Session | None: diff --git a/plugin/documents.py b/plugin/documents.py index 9dbf5d366..65eb64e2d 100644 --- a/plugin/documents.py +++ b/plugin/documents.py @@ -52,6 +52,7 @@ from .core.windows import WindowManager from .folding_range import folding_range_to_range from .hover import code_actions_content +from .inline_completion import InlineCompletionData from .session_buffer import SessionBuffer from .session_view import SessionView from functools import partial @@ -197,6 +198,7 @@ def on_change() -> None: self._stored_selection: list[sublime.Region] = [] self._should_format_on_paste = False self.hover_provider_count = 0 + self.inline_completion = InlineCompletionData(self.view, 'lsp_inline_completion') self._setup() def __del__(self) -> None: @@ -226,6 +228,7 @@ def _cleanup(self) -> None: self._stored_selection = [] self.view.erase_status(AbstractViewListener.TOTAL_ERRORS_AND_WARNINGS_STATUS_KEY) self._clear_highlight_regions() + self.inline_completion.clear_async() self._clear_session_views_async() def _reset(self) -> None: @@ -418,6 +421,7 @@ def on_selection_modified_async(self) -> None: if not self._is_in_higlighted_region(first_region.b): self._clear_highlight_regions() self._clear_code_actions_annotation() + self.inline_completion.clear_async() if userprefs().document_highlight_style or userprefs().show_code_actions: self._when_selection_remains_stable_async( self._on_selection_modified_debounced_async, first_region, after_ms=self.debounce_time) @@ -511,6 +515,8 @@ def on_query_context(self, key: str, operator: int, operand: Any, match_all: boo if not session_view: return not operand return operand == bool(session_view.session_buffer.get_document_link_at_point(self.view, position)) + elif key == 'lsp.inline_completion_visible' and operator == sublime.QueryOperator.EQUAL: + return operand == self.inline_completion.visible return None @requires_session @@ -560,6 +566,7 @@ def _on_hover_gutter_async(self, point: int) -> None: def on_text_command(self, command_name: str, args: dict | None) -> tuple[str, dict] | None: if command_name == "auto_complete": self._auto_complete_triggered_manually = True + self.view.run_command('lsp_inline_completion') elif command_name == "show_scope_name" and userprefs().semantic_highlighting: session = self.session_async("semanticTokensProvider") if session: @@ -990,6 +997,7 @@ def _on_view_updated_async(self) -> None: if first_region is None: return self._clear_highlight_regions() + self.inline_completion.clear_async() if userprefs().document_highlight_style: self._when_selection_remains_stable_async( self._do_highlights_async, first_region, after_ms=self.debounce_time) diff --git a/plugin/inline_completion.py b/plugin/inline_completion.py new file mode 100644 index 000000000..df361db14 --- /dev/null +++ b/plugin/inline_completion.py @@ -0,0 +1,215 @@ +from __future__ import annotations +from .core.logging import debug +from .core.protocol import Command +from .core.protocol import InlineCompletionItem +from .core.protocol import InlineCompletionList +from .core.protocol import InlineCompletionParams +from .core.protocol import InlineCompletionTriggerKind +from .core.protocol import Request +from .core.registry import get_position +from .core.registry import LspTextCommand +from .core.views import range_to_region +from .core.views import text_document_position_params +from functools import partial +from typing import Optional +import html +import sublime + + +PHANTOM_HTML = """ + + +
{content}
+""" + + +class InlineCompletionData: + + def __init__(self, view: sublime.View, key: str) -> None: + self.visible = False + self.index = 0 + self.position = 0 + self.items: list[tuple[str, sublime.Region, str, Optional[Command]]] = [] + self._view = view + self._phantom_set = sublime.PhantomSet(view, key) + + def render_async(self, index: int) -> None: + style = self._view.style_for_scope('comment meta.inline-completion.lsp') + color = style['foreground'] + font_style = 'italic' if style['italic'] else 'normal' + font_weight = 'bold' if style['bold'] else 'normal' + region = sublime.Region(self.position) + item = self.items[index] + first_line, *more_lines = item[2][len(item[1]):].splitlines() + phantoms = [sublime.Phantom( + region, + PHANTOM_HTML.format( + color=color, + font_style=font_style, + font_weight=font_weight, + content=self._normalize_html(first_line) + ), + sublime.PhantomLayout.INLINE + )] + if more_lines: + phantoms.append( + sublime.Phantom( + region, + PHANTOM_HTML.format( + color=color, + font_style=font_style, + font_weight=font_weight, + content='
'.join(self._normalize_html(line) for line in more_lines) + ), + sublime.PhantomLayout.BLOCK + ) + ) + sublime.set_timeout(lambda: self._render(phantoms, index)) + self.visible = True + + def _render(self, phantoms: list[sublime.Phantom], index: int) -> None: + self.index = index + self._phantom_set.update(phantoms) + + def clear_async(self) -> None: + if self.visible: + sublime.set_timeout(self._clear) + self.visible = False + + def _clear(self) -> None: + self._phantom_set.update([]) + + def _normalize_html(self, content: str) -> str: + return html.escape(content).replace(' ', ' ') + + +class LspInlineCompletionCommand(LspTextCommand): + + capability = 'inlineCompletionProvider' + + def run(self, edit: sublime.Edit, event: dict | None = None, point: int | None = None) -> None: + sublime.set_timeout_async(partial(self._run_async, event, point)) + + def _run_async(self, event: dict | None = None, point: int | None = None) -> None: + position = get_position(self.view, event, point) + if position is None: + return + session = self.best_session(self.capability, point) + if not session: + return + if self.view.settings().get('mini_auto_complete', False): + return + position_params = text_document_position_params(self.view, position) + params: InlineCompletionParams = { + 'textDocument': position_params['textDocument'], + 'position': position_params['position'], + 'context': { + 'triggerKind': InlineCompletionTriggerKind.Invoked + } + } + session.send_request_async( + Request('textDocument/inlineCompletion', params), + partial(self._handle_response_async, session.config.name, self.view.change_count(), position) + ) + + def _handle_response_async( + self, + session_name: str, + view_version: int, + position: int, + response: list[InlineCompletionItem] | InlineCompletionList | None + ) -> None: + if response is None: + return + items = response['items'] if isinstance(response, dict) else response + if not items: + return + if view_version != self.view.change_count(): + return + listener = self.get_listener() + if not listener: + return + listener.inline_completion.items.clear() + for item in items: + insert_text = item['insertText'] + if not insert_text: + continue + if isinstance(insert_text, dict): # StringValue + debug('Snippet completions from the 3.18 specs not yet supported') + continue + range_ = item.get('range') + region = range_to_region(range_, self.view) if range_ else sublime.Region(position) + region_length = len(region) + if region_length > len(insert_text): + continue + listener.inline_completion.items.append((session_name, region, insert_text, item.get('command'))) + listener.inline_completion.position = position + listener.inline_completion.render_async(0) + + # filter_text = item.get('filterText', insert_text) # ignored for now + + +class LspNextInlineCompletionCommand(LspTextCommand): + + capability = 'inlineCompletionProvider' + + def is_enabled(self, event: dict | None = None, point: int | None = None, **kwargs) -> bool: + if not super().is_enabled(event, point): + return False + listener = self.get_listener() + if not listener: + return False + return listener.inline_completion.visible + + def run( + self, edit: sublime.Edit, event: dict | None = None, point: int | None = None, forward: bool = True + ) -> None: + listener = self.get_listener() + if not listener: + return + item_count = len(listener.inline_completion.items) + if item_count < 2: + return + new_index = (listener.inline_completion.index - 1 + 2 * forward) % item_count + listener.inline_completion.render_async(new_index) + + +class LspCommitInlineCompletionCommand(LspTextCommand): + + capability = 'inlineCompletionProvider' + + def is_enabled(self, event: dict | None = None, point: int | None = None) -> bool: + if not super().is_enabled(event, point): + return False + listener = self.get_listener() + if not listener: + return False + return listener.inline_completion.visible + + def run(self, edit: sublime.Edit, event: dict | None = None, point: int | None = None) -> None: + listener = self.get_listener() + if not listener: + return + session_name, region, text, command = listener.inline_completion.items[listener.inline_completion.index] + self.view.replace(edit, region, text) + selection = self.view.sel() + pt = selection[0].b + selection.clear() + selection.add(pt) + if command: + self.view.run_command('lsp_execute', { + "command_name": command['command'], + "command_args": command.get('arguments'), + "session_name": session_name + })