Visitar URL original
feat: inline completion (textDocument/inlineCompletion) as ghost text by joaodinissf · Pull Request #1645 · eclipse-lsp4e/lsp4e · GitHub
Skip to content

feat: inline completion (textDocument/inlineCompletion) as ghost text - #1645

Draft
joaodinissf wants to merge 21 commits into
eclipse-lsp4e:mainfrom
joaodinissf:inline-completion-merged
Draft

joaodinissf wants to merge 21 commits into
eclipse-lsp4e:mainfrom
joaodinissf:inline-completion-merged

Conversation

@joaodinissf

Copy link
Copy Markdown
Contributor

Note

This is a first draft and still needs work. I am opening it to get feedback on the direction and on the open questions below, not for merging as it stands. Manual testing has not been done yet.

What this does

Adds LSP 3.18 inline completions (textDocument/inlineCompletion) to LSP4E. They appear as ghost text in the editor, drawn with JFace code minings.

  • Requests are sent automatically while typing, after a configurable delay, or explicitly.
  • A suggestion can be accepted whole, by next word or by next line, and dismissed. When there are several, you can cycle through them.
  • Snippets enter linked mode on accept, and a suggestion's command runs.
  • Typing through a suggestion, or deleting back within its range, updates it without a new request. Other edits, moving the caret and losing focus dismiss it.
  • A status bar widget shows the state and switches between automatic and manual requests.
  • An inlineCompletion extension point and listener API let other plugins provide enablement and word detection, and observe events.
Action Key On macOS
Request Alt+\ Ctrl+\
Accept Tab Tab
Accept next word Alt+; Ctrl+;
Accept next line Alt+' Ctrl+'
Next / previous suggestion Alt+] / Alt+[ Ctrl+] / Ctrl+[
Dismiss Esc Esc
Switch automatic / manual Alt+Shift+\ Ctrl+Shift+\

Where it comes from

The series combines two pieces of work. The first commit is Ruben Porras Campo's, unchanged: the API, the extension point, the status states and the word logic. The rest is an engine I developed independently and then adapted to those building blocks. Ruben is credited as co-author on the commits where the two come together.

How to read it

It is large: 21 commits, about 9,400 added lines including tests. Each commit builds and passes the related tests on its own, so reading in order should work.

  1. Ruben's building blocks: feat: add building blocks for inline completion.
  2. Three preparatory changes that also stand on their own: CancellationSupport thread safety, the execute command guard, and computing a proposal's edit without applying it.
  3. The engine: feat: show inline completions ... as ghost text. This is the commit to read most carefully.
  4. Refinements of the rendering and requests: code mining room, the selected popup proposal, moved text, word wrap, and not asking servers for code minings for ghost text.
  5. Connecting the engine to the building blocks: request identity, enablement, the current suggestion, events, word and line accept, status, the position popup and the status bar widget.

Feedback wanted

  1. The event contract. The listener API is marked provisional in its Javadoc. Is it the right shape for something public? Current defaults: ACCEPT_* text is the inserted remainder, DISMISS text is the whole item, and VIEW fires per fetched result and restarts elapsed time after FETCH_ADDITIONAL.
  2. Replacement follows the protocol only. LSP4E honours the range a server sends. Without one, the suggestion is inserted at the caret, and nothing is guessed from the text after it.
  3. Splitting. Would you rather take this in smaller PRs? The three preparatory commits and the building blocks are natural candidates.
  4. New minimum version. The rendering needs jface.text 3.28.

Known limitations

  • Code minings can only add text, so a suggestion that would overwrite different text after the caret is not shown.
  • A multi-line suggestion in the middle of a line is previewed before the rest of the line.
  • A report action that takes the focus sees no current suggestion, because losing focus dismisses it.
  • The status bar widget leaves a blank slot while hidden.
  • Changes to enablement show only when the status next updates.
  • A server that hangs keeps the status on fetching until the next request.

Not in this PR

  • Reporting a near match as ACCEPT_EDIT; only an exact match is reported.
  • Fetching more suggestions past the last item, and a preference for it.
  • Visual replacement of different text.
  • Rendering suggestions in italics.

Testing

  • New tests cover the model, the requests, rendering decisions, keys, events, status, the widget and the word logic.
  • Locally on macOS, the full test bundle runs 827 tests, and up to three fail: testKeyword, testDocumentEditAndUndo (not every run) and testDiagnosticsOnExternalFile. I still have to check whether they also fail in CI or on main locally.
  • LanguageServersTest is occasionally flaky, on main too.
  • CI on my fork, on the 5 platforms, was still running when I opened this.
  • I have not tested this by hand yet, so the status bar widget and the position popup in particular have only run under automated tests.

AI assistance

This work was done with AI assistance from Claude, as the commit trailers show.

🤖 Generated with Claude Code

rubenporras and others added 21 commits November 6, 2026 14:00
Add the parts of an inline completion (ghost text) client that do not
depend on how suggestions are requested and rendered:

- the org.eclipse.lsp4e.inlineCompletion extension point with event
  listeners (the events of requested, shown, accepted and dismissed
  suggestions), an enablement and per content type word detectors
- read-only access to the suggestion currently shown
- the inline completion status with a spinner frames animation and a
  status menu item
- word and replace range computations, the suggestion index popup and a
  command opening the Keys preferences filtered to inline completion

The inline completion engine itself (requesting, rendering, cycling,
accepting and dismissing suggestions with its commands, key bindings,
preferences and status bar widget) is a placeholder,
InlineCompletionController.
Requests are often registered with execute() from a language server's
thread, as when it is called in a LanguageServers.collectAll() or
computeAll() lambda, while cancel() is called from the UI thread or from
the next request. The futures were kept in a plain ArrayList and the
cancelled flag was not volatile, so cancel() could miss a future
registered concurrently or fail while the list was changed, and a request
registered just after cancel() was never cancelled.

Guard the list with a lock, take the futures to cancel under it, make the
flag volatile, and cancel a future registered after cancel() right away:
a support is cancelled once. Completion therefore takes a new support for
the trigger characters of each document instead of reusing the cancelled
one, and keeps the support of a completion request before sending the
request, so that a cancellation meanwhile reaches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExecuteCommandOptions.commands is required by the specification, but a
server leaving it out made executing a completion item's command fail
with a NullPointerException instead of running the command on the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extract computeTextEdit from apply, working on a copy of the item's range
instead of mutating it, and expose getEffectiveEdit and expandSnippet so
that inline completion can tell what a proposal inserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t text

Advertise the LSP 3.18 client capability with dynamic registration.
Request inline completions automatically while typing, after a
configurable delay, or explicitly (Alt+\, Ctrl+\ on macOS), and show the
current one as ghost text. JFace code minings draw it: its first line at
the caret, further lines above the next visible line, or below the last
line at the end of the document, where the editor's bottom margin makes
room for them since JFace reserves none. This requires jface.text 3.28.

Tab accepts (snippets enter linked mode, commands run), Esc dismisses,
Alt+] and Alt+[ (Ctrl on macOS) cycle. Typing through the suggestion or
deleting back within its range updates it without a new request; other
edits, moving the caret or losing focus dismiss it. Automatic requests
stop while the editor does not have the focus.

Requests are sent one at a time, which suits servers answering in 200 ms
as well as in several seconds. While the user keeps typing at the caret
the request is kept, and its reply is shown if it still fits what was
typed meanwhile; otherwise a new request follows. Moving the caret, Esc,
losing focus and edits elsewhere cancel it. An explicit request always
replaces it, and one without a reply for 10 s no longer holds back new
ones. A failing server is logged once, and a reply that the content was
modified, usual while typing, not at all.

Code minings can only add text, so a suggestion that would overwrite
different text after the caret is not shown, and a multi-line one in the
middle of a line is previewed before the rest of the line.

The engine goes into the internal package, where its controller replaces
the placeholder. Asking it for the current suggestion still answers that
there is none.

The mock server answers inline completions from a configurable responder,
can hold back its replies so that tests control what happens during a
request, and tells which requests were cancelled.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JFace merges code minings at the same position into one annotation and
lets header minings of the same line fight over its vertical indent, so
an inlay hint at the caret or a code lens above the line the ghost text
continues on would be drawn into the ghost text. LSP4E's inlay hint, code
lens and color providers leave such minings out while ghost text is
shown; they come back when it is dismissed. Minings of other plugins are
not affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… popup

While the completion popup is open, requests carry the selected proposal
as selectedCompletionInfo and, as the specification requires, only
suggestions extending it are shown, on their first line only since the
popup covers the lines below. Accepting the proposal keeps what the
suggestions of all servers add to it; closing the popup or selecting
another proposal drops them, also while they are still being requested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accepting a multi-line suggestion in the middle of a line moves the rest
of the line after its last line, while the preview left it on the first
line. Code minings can only add text, so a text presentation listener
hides the rest of the line where it is, with zero-width glyph metrics,
and the last row of the ghost text draws it in the editor's own colors
and font styles. The first line is then drawn on the line delimiter.

JFace resets the style of the character a removed line content mining
covered, which can be part of the hidden text, so it is hidden again when
that happens, a few times at most.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With word wrap on, rows ran off the right edge. Break them where the
editor will break the lines once accepted: a first line too long for the
visual line goes on below it, which moves the rest of the line as a
further line would, and the layout is computed again when the font or the
width of the editor changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t text

JFace can only refresh all providers' minings, so showing or hiding ghost
text sent inlay hint, code lens and color requests each time, for a
document that did not change. When a refresh is made only for ghost text,
LSP4E's providers show copies of what they computed for the current
document version, unless a server asked for a refresh since. Any other
refresh asks the servers as before. Copies are needed because JFace
disposes the minings it replaces.

Resolving is unchanged: servers with a resolve provider still get their
codeLens/resolve and inlayHint/resolve requests for the copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Events are to report which request they belong to, and a session must
remember that once its request is no longer in flight. Replace the
sequence numbers of requests by an object that holds a UUID and the
document's URI, kept when more suggestions are requested for the same
position, and when a session continues after the proposal selected in the
completion popup is applied.

No change in behaviour.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ving

Ask the contributed InlineCompletionEnablement before every request, so
that a product can switch inline completion off. A disabled client still
advertises the capability, since servers are told once and the enablement
can change at any time.

Do not request inline completions for edits made while the document is
being saved, such as formatting on save: the user did not type them.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Make InlineCompletions.getCurrentCompletionInfo() answer with the request
and the whole text of the suggestion shown in the active editor, instead
of always answering that there is none.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tell the contributed listeners about requests for a new position or for
more suggestions, the first time the result of a request is drawn, and
when what is shown is accepted, dismissed by the user, or goes away
because the editor loses the focus or closes. Nothing is reported when a
suggestion just stops applying, such as after moving the caret.

Since ending what is shown was also how accepting, losing the focus and
other cases went about their work, only dismissing by the user reports a
dismissal now. Only requests that reach a server with the capability are
reported.

The events are declared provisional, as they follow what one consumer
needed so far and may still change.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A quoted string was searched for anywhere after the offset, so that a
string not closed made the word as long as a later string. It could also
span lines, starting at an apostrophe in a comment, and only knew escaped
quotes. Long strings could overflow the stack, and a surrogate pair could
be split.

Match at the offset only and on one line, with any backslash escape, and
treat a surrogate pair as one character.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add commands to accept a suggestion piece by piece, bound while one is
shown to Alt+; and Alt+' (Ctrl on macOS). What a word is depends on the
word detector contributed for the document's content type.

A piece is inserted as if typed, so that what follows keeps showing as it
does when typing through a suggestion. The last piece is accepted like a
whole suggestion, since a snippet is only expanded and the item's command
only run then. A filter text says what typing may match, so it no longer
hides the rest of a suggestion once a piece was accepted.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tell the listeners when the user typed exactly what was suggested, rather
than accepting it. Text after the caret that the suggestion would keep,
such as an auto-closed parenthesis, counts as typed.

Nearly matching text is not reported: what is typed differently ends the
session on the first character, so reporting it needs candidates to be
followed after they stop applying.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set the global status from the active editor: initializing until a server
for its document is known to have the capability, ready once one is,
fetching while a request that reached such a server is in flight, and an
error after a failed request until the next one succeeds. Cancelled and
outdated requests are no failures.

Several workbench windows can follow the status now, and listeners only
hear of actual changes.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Show which of the suggestions is shown, such as 2/3, in a short-lived
popup above the caret when cycling through them, in addition to the
status line.

There is one popup at a time, so that cycling quickly does not pile them
up, and it goes away with the suggestion. It is placed by the offset in
the widget, which differs from the offset in the document when text is
folded.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Show the inline completion status in the status bar of the workbench
window: an icon, animated while a request is pending, with the state as
its tooltip. It is hidden while inline completion is disabled or the
active editor has no server with the capability.

Clicking it switches between requesting automatically and only when
asked to, which is also a new command, bound to Alt+Shift+\ (Ctrl+Shift+\
on macOS). Its context menu shows the state and offers that switch, the
preferences and the key bindings. The tooltip and the menu tell the two
trigger modes apart.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What a suggestion replaces is the range the server sends: the protocol
leaves it to the server, so there is nothing to compute from the text
after the caret. Without a range, the suggestion is inserted at the
caret.

Remove the helper computing such a range and the unused one returning
the modification stamp of an editor's document.

Co-Authored-By: Ruben Porras Campo <ruben.porrascampo@avaloq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
public CompletableFuture<Either<List<InlineCompletionItem>, InlineCompletionList>> inlineCompletion(
InlineCompletionParams params) {
inlineCompletionRequests.add(params);
return inlineCompletionGate.thenCompose(released -> futureFactory(inlineCompletionResponder.apply(params)));
return new CompletionSnippetParser(document, snippet, insertionOffset, this::getVariableValue).parse();
}

protected void apply(IDocument document, char trigger, int stateMask, int offset) {
return new CompletionSnippetParser(document, snippet, insertionOffset, this::getVariableValue).parse();
}

protected void apply(IDocument document, char trigger, int stateMask, int offset) {
* 'textDocument/completion' when completion is retriggered
*/
private CancellationSupport completionCancellationSupport;
private volatile CancellationSupport completionCancellationSupport;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there was a small bug in this file, so have commit it myself in a new PR of my own.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

therefore it can be removed from this commit

private final List<CompletableFuture<?>> futuresToCancel;

private boolean cancelled;
private volatile boolean cancelled;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this changes. Could you move it to a different PR, as it is very unrelated to the inlineCompletion topic?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants