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
+ })