editor: Add InlineCompletion LSP support (#1736)

Closes [1729](https://github.com/longbridge/gpui-component/issues/1729)

## Description

Adds support for inline completions, which allows the editor to support
agentic "tab to complete" solutions. This works, (tested macOS), but not
sure of any gotchas or different approaches.

## Screenshot


https://github.com/user-attachments/assets/8e437f1e-9792-4417-b070-0a1dd8de6024

## How to Test

User implements the `inline_completion` method in CompletionsProvider.

Not implementing should have no effect. 

## Checklist

- [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) document and
followed the guidelines.
- [x] Reviewed the changes in this PR and confirmed AI generated code
(If any) is accurate.
- [x] Passed `cargo run` for story tests related to the changes.
- [ ] Tested macOS, Windows and Linux platforms performance (if the
change is platform-specific)

---------

Co-authored-by: Jason Lee <huacnlee@gmail.com>
This commit is contained in:
Duane Bester 2025-12-06 21:01:44 -06:00 committed by Jason Lee
parent 3ecd9c7606
commit 3c23ee9be4
7 changed files with 401 additions and 13 deletions

View file

@ -34,7 +34,7 @@ sum-tree = { version = "0.2.0", package = "zed-sum-tree" }
anyhow = "1" anyhow = "1"
log = "0.4" log = "0.4"
lsp-types = "0.97.0" lsp-types = { version = "0.97.0", features = ["proposed"] }
notify = "7.0.0" notify = "7.0.0"
raw-window-handle = "0.6.2" raw-window-handle = "0.6.2"
ropey = { version = "=2.0.0-beta.1", features = [ ropey = { version = "=2.0.0-beta.1", features = [

View file

@ -27,7 +27,8 @@ use gpui_component_assets::Assets;
use gpui_component_story::Open; use gpui_component_story::Open;
use lsp_types::{ use lsp_types::{
CodeAction, CodeActionKind, CompletionContext, CompletionItem, CompletionResponse, CodeAction, CodeActionKind, CompletionContext, CompletionItem, CompletionResponse,
CompletionTextEdit, InsertReplaceEdit, TextEdit, WorkspaceEdit, CompletionTextEdit, InlineCompletionContext, InlineCompletionItem, InlineCompletionResponse,
InsertReplaceEdit, InsertTextFormat, TextEdit, WorkspaceEdit,
}; };
fn init() { fn init() {
@ -187,6 +188,45 @@ impl CompletionProvider for ExampleLspStore {
}) })
} }
fn inline_completion(
&self,
rope: &Rope,
offset: usize,
_trigger: InlineCompletionContext,
_window: &mut Window,
cx: &mut Context<InputState>,
) -> Task<Result<InlineCompletionResponse>> {
let rope = rope.clone();
cx.background_spawn(async move {
// Get the current line text before cursor using RopeExt
let point = rope.offset_to_point(offset);
let line_start = rope.line_start_offset(point.row);
let current_line = rope.slice(line_start..offset).to_string();
// Simple pattern matching for demo
let suggestion =
if current_line.trim_start().starts_with("fn ") && !current_line.contains('{') {
Some("() {\n // Write your code here..\n}".into())
} else {
None
};
if let Some(insert_text) = suggestion {
Ok(InlineCompletionResponse::Array(vec![
InlineCompletionItem {
insert_text,
filter_text: None,
range: None,
command: None,
insert_text_format: Some(InsertTextFormat::SNIPPET),
},
]))
} else {
Ok(InlineCompletionResponse::Array(vec![]))
}
})
}
fn is_completion_trigger( fn is_completion_trigger(
&self, &self,
_offset: usize, _offset: usize,

View file

@ -558,6 +558,91 @@ impl TextElement {
(line_number_width, line_number_len) (line_number_width, line_number_len)
} }
/// Compute inline completion ghost lines for rendering.
///
/// Returns (first_line, ghost_lines) where:
/// - first_line: Shaped text for the first line (goes after cursor on same line)
/// - ghost_lines: Shaped lines for subsequent lines (shift content down)
fn layout_inline_completion(
state: &InputState,
visible_range: &Range<usize>,
font_size: Pixels,
window: &mut Window,
cx: &App,
) -> (Option<ShapedLine>, Vec<ShapedLine>) {
// Must be focused to show inline completion
if !state.focus_handle.is_focused(window) {
return (None, vec![]);
}
let Some(completion_item) = state.inline_completion.item.as_ref() else {
return (None, vec![]);
};
// Get cursor row from cursor position
let cursor_row = state.cursor_position().line as usize;
// Only show if cursor row is visible
if cursor_row < visible_range.start || cursor_row >= visible_range.end {
return (None, vec![]);
}
let completion_text = &completion_item.insert_text;
let completion_color = cx.theme().muted_foreground.opacity(0.5);
let text_style = window.text_style();
let font = text_style.font();
let lines: Vec<&str> = completion_text.split('\n').collect();
if lines.is_empty() {
return (None, vec![]);
}
// Shape first line (goes after cursor)
let first_text: SharedString = lines[0].to_string().into();
let first_line = if !first_text.is_empty() {
let first_run = TextRun {
len: first_text.len(),
font: font.clone(),
color: completion_color,
background_color: None,
underline: None,
strikethrough: None,
};
Some(
window
.text_system()
.shape_line(first_text, font_size, &[first_run], None),
)
} else {
None
};
// Shape ghost lines (lines 2+ that shift content down)
let ghost_lines: Vec<ShapedLine> = lines[1..]
.iter()
.map(|line_text| {
let text: SharedString = line_text.to_string().into();
let len = text.len().max(1); // Ensure at least 1 for empty lines
let run = TextRun {
len,
font: font.clone(),
color: completion_color,
background_color: None,
underline: None,
strikethrough: None,
};
// Use space for empty lines so they take up height
let shaped_text = if text.is_empty() { " ".into() } else { text };
window
.text_system()
.shape_line(shaped_text, font_size, &[run], None)
})
.collect();
(first_line, ghost_lines)
}
fn layout_lines( fn layout_lines(
state: &InputState, state: &InputState,
display_text: &Rope, display_text: &Rope,
@ -725,6 +810,22 @@ pub(super) struct PrepaintState {
hover_definition_hitbox: Option<Hitbox>, hover_definition_hitbox: Option<Hitbox>,
indent_guides_path: Option<Path<Pixels>>, indent_guides_path: Option<Path<Pixels>>,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
// Inline completion rendering data
/// Shaped ghost lines to paint after cursor row (completion lines 2+)
ghost_lines: Vec<ShapedLine>,
/// First line of inline completion (painted after cursor on same line)
ghost_first_line: Option<ShapedLine>,
ghost_lines_height: Pixels,
}
impl PrepaintState {
/// Returns cursor bounds adjusted for scroll offset, if available.
fn cursor_bounds_with_scroll(&self) -> Option<Bounds<Pixels>> {
self.cursor_bounds.map(|mut bounds| {
bounds.origin.y += self.cursor_scroll_offset.y;
bounds
})
}
} }
impl IntoElement for TextElement { impl IntoElement for TextElement {
@ -995,6 +1096,16 @@ impl Element for TextElement {
} }
last_layout.lines = Rc::new(lines); last_layout.lines = Rc::new(lines);
let (ghost_first_line, ghost_lines) = Self::layout_inline_completion(
state,
&last_layout.visible_range,
text_size,
window,
cx,
);
let ghost_line_count = ghost_lines.len();
let ghost_lines_height = ghost_line_count as f32 * line_height;
let total_wrapped_lines = state.text_wrapper.len(); let total_wrapped_lines = state.text_wrapper.len();
let empty_bottom_height = if state.mode.is_code_editor() { let empty_bottom_height = if state.mode.is_code_editor() {
bounds bounds
@ -1012,7 +1123,7 @@ impl Element for TextElement {
} else { } else {
longest_line_width longest_line_width
}, },
(total_wrapped_lines as f32 * line_height + empty_bottom_height) (total_wrapped_lines as f32 * line_height + empty_bottom_height + ghost_lines_height)
.max(bounds.size.height), .max(bounds.size.height),
); );
@ -1122,6 +1233,9 @@ impl Element for TextElement {
hover_definition_hitbox, hover_definition_hitbox,
document_color_paths, document_color_paths,
indent_guides_path, indent_guides_path,
ghost_first_line,
ghost_lines,
ghost_lines_height,
} }
} }
@ -1246,21 +1360,49 @@ impl Element for TextElement {
window.paint_path(path.clone(), *color); window.paint_path(path.clone(), *color);
} }
// Paint text // Paint text with inline completion ghost line support
let mut offset_y = mask_offset_y + invisible_top_padding; let mut offset_y = mask_offset_y + invisible_top_padding;
for line in prepaint.last_layout.lines.iter() { let ghost_lines = &prepaint.ghost_lines;
let has_ghost_lines = !ghost_lines.is_empty();
for (ix, line) in prepaint.last_layout.lines.iter().enumerate() {
let row = visible_range.start + ix;
let p = point( let p = point(
origin.x + prepaint.last_layout.line_number_width, origin.x + prepaint.last_layout.line_number_width,
origin.y + offset_y, origin.y + offset_y,
); );
// Paint the actual line
_ = line.paint(p, line_height, window, cx); _ = line.paint(p, line_height, window, cx);
offset_y += line.size(line_height).height; offset_y += line.size(line_height).height;
// After the cursor row, paint ghost lines (which shifts subsequent content down)
if has_ghost_lines && Some(row) == prepaint.current_row {
let ghost_x = origin.x + prepaint.last_layout.line_number_width;
for ghost_line in ghost_lines {
let ghost_p = point(ghost_x, origin.y + offset_y);
// Paint semi-transparent background for ghost line
let ghost_bounds = Bounds::new(
ghost_p,
size(
bounds.size.width - prepaint.last_layout.line_number_width,
line_height,
),
);
window.paint_quad(fill(ghost_bounds, cx.theme().editor_background()));
// Paint ghost line text
_ = ghost_line.paint(ghost_p, line_height, window, cx);
offset_y += line_height;
}
}
} }
// Paint blinking cursor // Paint blinking cursor
if focused && show_cursor { if focused && show_cursor {
if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() { if let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() {
cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y;
window.paint_quad(fill(cursor_bounds, cx.theme().caret)); window.paint_quad(fill(cursor_bounds, cx.theme().caret));
} }
} }
@ -1270,13 +1412,12 @@ impl Element for TextElement {
if let Some(line_numbers) = prepaint.line_numbers.as_ref() { if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding; offset_y += invisible_top_padding;
// Paint line number background
window.paint_quad(fill( window.paint_quad(fill(
Bounds { Bounds {
origin: input_bounds.origin, origin: input_bounds.origin,
size: size( size: size(
prepaint.last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN, prepaint.last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN,
input_bounds.size.height, input_bounds.size.height + prepaint.ghost_lines_height,
), ),
}, },
cx.theme().editor_background(), cx.theme().editor_background(),
@ -1304,6 +1445,11 @@ impl Element for TextElement {
_ = line.paint(p, line_height, window, cx); _ = line.paint(p, line_height, window, cx);
offset_y += line_height; offset_y += line_height;
} }
// Add ghost line height after cursor row for line numbers alignment
if !prepaint.ghost_lines.is_empty() && prepaint.current_row.is_some() {
offset_y += prepaint.ghost_lines_height;
}
} }
} }
@ -1324,6 +1470,23 @@ impl Element for TextElement {
window.set_cursor_style(gpui::CursorStyle::PointingHand, &hitbox); window.set_cursor_style(gpui::CursorStyle::PointingHand, &hitbox);
} }
// Paint inline completion first line suffix (after cursor on same line)
if focused {
if let Some(first_line) = &prepaint.ghost_first_line {
if let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() {
let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width;
let p = point(first_line_x, cursor_bounds.origin.y);
// Paint background to cover any existing text
let bg_bounds = Bounds::new(p, size(first_line.width + px(4.), line_height));
window.paint_quad(fill(bg_bounds, cx.theme().editor_background()));
// Paint first line completion text
_ = first_line.paint(p, line_height, window, cx);
}
}
}
self.paint_mouse_listeners(window, cx); self.paint_mouse_listeners(window, cx);
} }
} }

View file

@ -222,6 +222,10 @@ impl InputState {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
// First, try to accept inline completion if present
if self.accept_inline_completion(window, cx) {
return;
}
self.indent(false, window, cx); self.indent(false, window, cx);
} }

View file

@ -1,14 +1,21 @@
use anyhow::Result; use anyhow::Result;
use gpui::{Context, EntityInputHandler, Task, Window}; use gpui::{Context, EntityInputHandler, Task, Window};
use lsp_types::{request::Completion, CompletionContext, CompletionItem, CompletionResponse}; use lsp_types::{
CompletionContext, CompletionItem, CompletionResponse, InlineCompletionContext,
InlineCompletionItem, InlineCompletionResponse, InlineCompletionTriggerKind,
request::Completion,
};
use ropey::Rope; use ropey::Rope;
use std::{cell::RefCell, ops::Range, rc::Rc}; use std::{cell::RefCell, ops::Range, rc::Rc, time::Duration};
use crate::input::{ use crate::input::{
popovers::{CompletionMenu, ContextMenu},
InputState, InputState,
popovers::{CompletionMenu, ContextMenu},
}; };
/// Default debounce duration for inline completions.
const DEFAULT_INLINE_COMPLETION_DEBOUNCE: Duration = Duration::from_millis(300);
/// A trait for providing code completions based on the current input state and context. /// A trait for providing code completions based on the current input state and context.
pub trait CompletionProvider { pub trait CompletionProvider {
/// Fetches completions based on the given byte offset. /// Fetches completions based on the given byte offset.
@ -27,6 +34,39 @@ pub trait CompletionProvider {
cx: &mut Context<InputState>, cx: &mut Context<InputState>,
) -> Task<Result<CompletionResponse>>; ) -> Task<Result<CompletionResponse>>;
/// Fetches an inline completion suggestion for the given position.
///
/// This is called after a debounce period when the user stops typing.
/// The provider can analyze the text and cursor position to determine
/// what inline completion suggestion to show.
///
///
/// # Arguments
/// * `rope` - The current text content
/// * `offset` - The cursor position in bytes
///
/// textDocument/inlineCompletion
///
/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_inlineCompletion
fn inline_completion(
&self,
_rope: &Rope,
_offset: usize,
_trigger: InlineCompletionContext,
_window: &mut Window,
_cx: &mut Context<InputState>,
) -> Task<Result<InlineCompletionResponse>> {
Task::ready(Ok(InlineCompletionResponse::Array(vec![])))
}
/// Returns the debounce duration for inline completions.
///
/// Default: 300ms
#[inline]
fn inline_completion_debounce(&self) -> Duration {
DEFAULT_INLINE_COMPLETION_DEBOUNCE
}
fn resolve_completions( fn resolve_completions(
&self, &self,
_completion_indices: Vec<usize>, _completion_indices: Vec<usize>,
@ -47,6 +87,22 @@ pub trait CompletionProvider {
) -> bool; ) -> bool;
} }
pub(crate) struct InlineCompletion {
/// Completion item to display as an inline completion suggestion
pub(crate) item: Option<InlineCompletionItem>,
/// Task for debouncing inline completion requests
pub(crate) task: Task<Result<InlineCompletionResponse>>,
}
impl Default for InlineCompletion {
fn default() -> Self {
Self {
item: None,
task: Task::ready(Ok(InlineCompletionResponse::Array(vec![]))),
}
}
}
impl InputState { impl InputState {
pub(crate) fn handle_completion_trigger( pub(crate) fn handle_completion_trigger(
&mut self, &mut self,
@ -63,6 +119,10 @@ impl InputState {
return; return;
}; };
// Always schedule inline completion (debounced).
// It will check if menu is open before showing the suggestion.
self.schedule_inline_completion(window, cx);
let start = range.end; let start = range.end;
let new_offset = self.cursor(); let new_offset = self.cursor();
@ -145,4 +205,105 @@ impl InputState {
Ok(()) Ok(())
}); });
} }
/// Schedule an inline completion request after debouncing.
pub(crate) fn schedule_inline_completion(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Clear any existing inline completion on text change
self.clear_inline_completion(cx);
let Some(provider) = self.lsp.completion_provider.clone() else {
return;
};
let offset = self.cursor();
let text = self.text.clone();
let debounce = provider.inline_completion_debounce();
self.inline_completion.task = cx.spawn_in(window, async move |editor, cx| {
// Debounce: wait before fetching to avoid unnecessary requests while typing
smol::Timer::after(debounce).await;
// Now fetch the inline completion after the debounce period
let task = editor.update_in(cx, |editor, window, cx| {
// Check if cursor has moved during debounce
if editor.cursor() != offset {
return None;
}
// Don't fetch if completion menu is open
if editor.is_context_menu_open(cx) {
return None;
}
let trigger = InlineCompletionContext {
trigger_kind: InlineCompletionTriggerKind::Automatic,
selected_completion_info: None,
};
Some(provider.inline_completion(&text, offset, trigger, window, cx))
})?;
let Some(task) = task else {
return Ok(InlineCompletionResponse::Array(vec![]));
};
let response = task.await?;
editor.update_in(cx, |editor, _window, cx| {
// Only apply if cursor still hasn't moved
if editor.cursor() != offset {
return;
}
// Don't show if completion menu opened while we were fetching
if editor.is_context_menu_open(cx) {
return;
}
if let Some(item) = match response.clone() {
InlineCompletionResponse::Array(items) => items.into_iter().next(),
InlineCompletionResponse::List(comp_list) => comp_list.items.into_iter().next(),
} {
editor.inline_completion.item = Some(item);
cx.notify();
}
})?;
Ok(response)
});
}
/// Check if an inline completion suggestion is currently displayed.
#[inline]
pub(crate) fn has_inline_completion(&self) -> bool {
self.inline_completion.item.is_some()
}
/// Clear the inline completion suggestion.
pub(crate) fn clear_inline_completion(&mut self, cx: &mut Context<Self>) {
self.inline_completion = InlineCompletion::default();
cx.notify();
}
/// Accept the inline completion, inserting it at the cursor position.
/// Returns true if a completion was accepted, false if there was none.
pub(crate) fn accept_inline_completion(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let Some(completion_item) = self.inline_completion.item.take() else {
return false;
};
let cursor = self.cursor();
let range_utf16 = self.range_to_utf16(&(cursor..cursor));
let completion_text = completion_item.insert_text;
self.replace_text_in_range_silent(Some(range_utf16), &completion_text, window, cx);
true
}
} }

View file

@ -51,6 +51,7 @@ impl InputState {
self.pause_blink_cursor(cx); self.pause_blink_cursor(cx);
self.update_preferred_column(); self.update_preferred_column();
self.hide_context_menu(cx); self.hide_context_menu(cx);
self.clear_inline_completion(cx);
cx.notify() cx.notify()
} }

View file

@ -31,7 +31,7 @@ use crate::input::{
search::{self, SearchPanel}, search::{self, SearchPanel},
text_wrapper::LineLayout, text_wrapper::LineLayout,
}; };
use crate::input::{RopeExt as _, Selection}; use crate::input::{InlineCompletion, RopeExt as _, Selection};
use crate::{Root, history::History}; use crate::{Root, history::History};
use crate::{highlighter::DiagnosticSet, input::text_wrapper::LineItem}; use crate::{highlighter::DiagnosticSet, input::text_wrapper::LineItem};
@ -331,6 +331,7 @@ pub struct InputState {
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
pub(super) _context_menu_task: Task<Result<()>>, pub(super) _context_menu_task: Task<Result<()>>,
pub(super) inline_completion: InlineCompletion,
} }
impl EventEmitter<InputEvent> for InputState {} impl EventEmitter<InputEvent> for InputState {}
@ -409,6 +410,7 @@ impl InputState {
_subscriptions, _subscriptions,
_context_menu_task: Task::ready(Ok(())), _context_menu_task: Task::ready(Ok(())),
_pending_update: false, _pending_update: false,
inline_completion: InlineCompletion::default(),
} }
} }
@ -1142,6 +1144,11 @@ impl InputState {
return; return;
} }
// Clear inline completion on enter (user chose not to accept it)
if self.has_inline_completion() {
self.clear_inline_completion(cx);
}
if self.mode.is_multi_line() { if self.mode.is_multi_line() {
// Get current line indent // Get current line indent
let indent = if self.mode.is_code_editor() { let indent = if self.mode.is_code_editor() {
@ -1175,6 +1182,12 @@ impl InputState {
return; return;
} }
// Clear inline completion on escape
if self.has_inline_completion() {
self.clear_inline_completion(cx);
return; // Consume the escape, don't propagate
}
if self.ime_marked_range.is_some() { if self.ime_marked_range.is_some() {
self.unmark_text(window, cx); self.unmark_text(window, cx);
} }
@ -1192,6 +1205,9 @@ impl InputState {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
// Clear inline completion on any mouse interaction
self.clear_inline_completion(cx);
// If there have IME marked range and is empty (Means pressed Esc to abort IME typing) // If there have IME marked range and is empty (Means pressed Esc to abort IME typing)
// Clear the marked range. // Clear the marked range.
if let Some(ime_marked_range) = &self.ime_marked_range { if let Some(ime_marked_range) = &self.ime_marked_range {
@ -1596,6 +1612,8 @@ impl InputState {
/// ///
/// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset.
pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) { pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.clear_inline_completion(cx);
let offset = offset.clamp(0, self.text.len()); let offset = offset.clamp(0, self.text.len());
if self.selection_reversed { if self.selection_reversed {
self.selected_range.start = offset self.selected_range.start = offset
@ -1697,6 +1715,7 @@ impl InputState {
self.hover_popover = None; self.hover_popover = None;
self.diagnostic_popover = None; self.diagnostic_popover = None;
self.context_menu = None; self.context_menu = None;
self.clear_inline_completion(cx);
self.blink_cursor.update(cx, |cursor, cx| { self.blink_cursor.update(cx, |cursor, cx| {
cursor.stop(cx); cursor.stop(cx);
}); });