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..24ffcc36e 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -9,13 +9,19 @@ // "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"}] }, // Save all open files that have a language server attached with lsp_save // { diff --git a/boot.py b/boot.py index 56ae078db..395f55cab 100644 --- a/boot.py +++ b/boot.py @@ -59,6 +59,8 @@ 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.panels import LspClearLogPanelCommand from .plugin.panels import LspClearPanelCommand from .plugin.panels import LspShowDiagnosticsPanelCommand @@ -99,6 +101,7 @@ "LspCollapseTreeItemCommand", "LspColorPresentationCommand", "LspCommitCompletionWithOppositeInsertMode", + "LspCommitInlineCompletionCommand", "LspCopyToClipboardFromBase64Command", "LspDisableLanguageServerGloballyCommand", "LspDisableLanguageServerInProjectCommand", @@ -120,6 +123,7 @@ "LspHierarchyToggleCommand", "LspHoverCommand", "LspInlayHintClickCommand", + "LspInlineCompletionCommand", "LspNextDiagnosticCommand", "LspOnDoubleClickCommand", "LspOpenLinkCommand", 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..b7f556173 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -170,6 +170,12 @@ 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"). +Currently inline completions are only requested when you manually trigger auto-completions (Ctrl + Space). + ## 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/language_servers.md b/docs/src/language_servers.md index 4026f2151..564909eb8 100644 --- a/docs/src/language_servers.md +++ b/docs/src/language_servers.md @@ -12,6 +12,69 @@ 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 PC with compatible Nvidia GPU on Windows. 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 https://developer.nvidia.com/cuda-downloads + +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. Install the `tabby-agent` language server via npm (requires NodeJS): + + ```sh + npm install -g tabby-agent + ``` + +4. 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 + } + }, + } + } + ``` + +5. Download a completion model (see https://tabby.tabbyml.com/docs/models/ for available model files and GPU requirements): + + ```sh + tabby download --model StarCoder-1B + ``` + +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. 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. + +8. 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..d80d131dd --- /dev/null +++ b/plugin/inline_completion.py @@ -0,0 +1,212 @@ +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 +import html +import sublime + + +PHANTOM_HTML = """ + + +
{content}
{suffix} +""" + + +class InlineCompletionData: + + def __init__(self, view: sublime.View, key: str) -> None: + self.visible = False + self.region = sublime.Region(0, 0) + self.text = '' + self.command: Command | None = None + self.session_name = '' + self._view = view + self._phantom_set = sublime.PhantomSet(view, key) + + def render_async(self, location: int, text: str) -> 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(location) + is_at_eol = self._view.line(location).b == location + first_line, *more_lines = text.splitlines() + suffix = '
Alt + Enter to complete
' if is_at_eol or \ + more_lines else '' + phantoms = [sublime.Phantom( + region, + PHANTOM_HTML.format( + color=color, + font_style=font_style, + font_weight=font_weight, + content=self._normalize_html(first_line), + suffix=suffix + ), + 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), + suffix='' + ), + sublime.PhantomLayout.BLOCK + ) + ) + sublime.set_timeout(lambda: self._render(phantoms)) + self.visible = True + + def _render(self, phantoms: list[sublime.Phantom]) -> None: + 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 + item = items[0] + insert_text = item['insertText'] + if not insert_text: + return + if isinstance(insert_text, dict): # StringValue + debug('Snippet completions from the 3.18 specs not yet supported') + return + if view_version != self.view.change_count(): + return + listener = self.get_listener() + if not listener: + return + 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): + return + listener.inline_completion.region = region + listener.inline_completion.text = insert_text + listener.inline_completion.command = item.get('command') + listener.inline_completion.session_name = session_name + listener.inline_completion.render_async(position, insert_text[region_length:]) + + # listener.inline_completion.text = lines[0] + '\n' + # listener.inline_completion.render_async(position, lines[0]) + + # filter_text = item.get('filterText', insert_text) # ignored for now + + +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 + self.view.replace(edit, listener.inline_completion.region, listener.inline_completion.text) + selection = self.view.sel() + pt = selection[0].b + selection.clear() + selection.add(pt) + command = listener.inline_completion.command + if command: + self.view.run_command('lsp_execute', { + "command_name": command['command'], + "command_args": command.get('arguments'), + "session_name": listener.inline_completion.session_name + })