Ghost text support in editor
This is a feature request to add support for ghost text to the editor. This would allow us to have Agentic AI Tab completions like the cool kids in VSCode.
E.g. User types some code (or any text), pauses, once debounced we can send text + relevant context to an AI Agent, it can offer a completion, we can display this as ghost text, and hitting tab will then insert the code suggestion. Similar to copilot tab complete.
I have a rough idea of where to get started. But maybe there are better ideas out there.
Adding Ghost text to the input state...
crates/ui/src/input/state.rs
pub struct InputState {
_subscriptions: Vec<Subscription>,
pub(super) _context_menu_task: Task<Result<()>>,
+
+ /// Ghost text to display as inline completion suggestion
+ pub(super) ghost_text: Option<SharedString>,
}
impl EventEmitter<InputEvent> for InputState {}
impl InputState {
_subscriptions,
_context_menu_task: Task::ready(Ok(())),
_pending_update: false,
+ ghost_text: None,
+ }
+ }
+
+ pub fn ghost_text_exists(&mut self) -> bool {
+ return self.ghost_text.is_some();
+ }
+
+ /// Set ghost text to display as an inline completion suggestion.
+ /// The ghost text will be displayed after the cursor in a muted color.
+ pub fn set_ghost_text(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
+ self.ghost_text = Some(text.into());
+ cx.notify();
+ }
+
+ /// Clear the ghost text.
+ pub fn clear_ghost_text(&mut self, cx: &mut Context<Self>) {
+ self.ghost_text = None;
+ cx.notify();
+ }
+
+ /// Accept the ghost text, inserting it at the cursor position.
+ /// Returns true if ghost text was accepted, false if there was none.
+ pub fn accept_ghost_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
+ if let Some(ghost_text) = self.ghost_text.take() {
+ let cursor = self.cursor();
+ let range_utf16 = self.range_to_utf16(&(cursor..cursor));
+ self.replace_text_in_range_silent(Some(range_utf16), &ghost_text, window, cx);
+ // cx.notify();
+ true
+ } else {
+ false
}
}
We would need to update the input element paint with something like this..
crates/ui/src/input/element.rs
impl Element for TextElement {:
// Paint blinking cursor
if focused && show_cursor {
- if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() {
+ if let Some(cursor_bounds) = prepaint.cursor_bounds.as_ref() {
+ let mut cursor_bounds = *cursor_bounds;
cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y;
window.paint_quad(fill(cursor_bounds, cx.theme().caret));
}
}
+ // Paint ghost text after cursor (don't blink with cursor)
+ if focused {
+ if let Some(ghost_text) = self.state.read(cx).ghost_text.as_ref() {
+ if let Some(cursor_bounds) = prepaint.cursor_bounds.as_ref() {
+ let mut cursor_bounds = *cursor_bounds;
+ cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y;
+
+ let style = window.text_style();
+ let font_size = style.font_size.to_pixels(window.rem_size());
+ let ghost_color = cx.theme().muted_foreground.opacity(0.5);
+
+ let ghost_run = TextRun {
+ len: ghost_text.len(),
+ font: style.font(),
+ color: ghost_color,
+ background_color: None,
+ underline: None,
+ strikethrough: None,
+ };
+
+ let shaped_ghost = window.text_system().shape_line(
+ ghost_text.clone(),
+ font_size,
+ &[ghost_run],
+ None,
+ );
+
+ let ghost_origin = point(
+ cursor_bounds.origin.x + cursor_bounds.size.width,
+ cursor_bounds.origin.y,
+ );
+ shaped_ghost
+ .paint(ghost_origin, prepaint.last_layout.line_height, window, cx)
+ .ok();
+ }
+ }
+ }
+
// Paint line numbers
let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
Then try to intercept the Tab event if we have ghost text:
crates/ui/src/input/indent.rs
impl InputState {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ // First, try to accept ghost text if present
+ if self.accept_ghost_text(window, cx) {
+ return;
+ }
self.indent(false, window, cx);
}
Then in the editor example, we could subscribe for input events and that's where we send the call to the agent. I have a dumb demo example instead, but hope this makes sesne:
let _subscriptions = vec![cx.subscribe(&editor, |this, _, event: &InputEvent, cx| {
match event {
InputEvent::Change => {
this.update_ghost_text(cx);
}
_ => {}
}
this.lint_document(cx);
})];
// This might have race condition bugs with the `is_completion_trigger`
fn update_ghost_text(&mut self, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
// Don't suggest if there's already ghost text (avoid re-triggering)
if editor.ghost_text_exists() {
return;
}
// Get the current line text before cursor for context
let cursor = editor.cursor();
let text = editor.text().to_string();
// Get the current line
let line_start = text[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0);
let current_line = &text[line_start..cursor];
// Simple pattern matching for demo
let suggestion = if current_line.trim_start().starts_with("fn ")
&& !current_line.contains('{')
{
Some("() {\n \n}")
} else if current_line.trim_start().starts_with("let ") && !current_line.contains('=') {
Some(" = ")
} else if current_line.trim() == "//" {
Some(" TODO: ")
} else if current_line.ends_with("pub ") {
Some("fn ")
} else {
None
};
if let Some(suggestion) = suggestion {
editor.set_ghost_text(suggestion, cx);
}
});
}
Maybe it should be delegated to the completions provider to avoid the racing? So the provider can decide whether or not to show ghost text or the completions in the CompletionResponse
Something like this? @zanmato
pub trait CompletionProvider
/// Default debounce duration for inline completions.
const DEFAULT_INLINE_COMPLETION_DEBOUNCE: Duration = Duration::from_millis(300);
/// Fetches inline completion (ghost text) 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 ghost text suggestion to show.
///
/// Default implementation returns `None` (no ghost text).
///
/// # Arguments
/// * `text` - The current text content
/// * `offset` - The cursor position in bytes
fn inline_completion(
&self,
_text: &Rope,
_offset: usize,
_window: &mut Window,
_cx: &mut Context<InputState>,
) -> Task<Result<Option<SharedString>>> {
Task::ready(Ok(None))
}
/// Returns the debounce duration for inline completions.
///
/// Default: 300ms
fn inline_completion_debounce(&self) -> Duration {
DEFAULT_INLINE_COMPLETION_DEBOUNCE
}
impl InputState
/// Schedule an inline completion request after debouncing.
pub(crate) fn schedule_inline_completion(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Clear existing ghost text on any change
self.clear_ghost_text(cx);
let Some(provider) = self.lsp.completion_provider.clone() else {
return;
};
// Cancel any pending inline completion task
self._inline_completion_task = Task::ready(Ok(()));
let offset = self.cursor();
let text = self.text.clone();
let debounce = provider.inline_completion_debounce();
let task = provider.inline_completion(&text, offset, window, cx);
self._inline_completion_task = cx.spawn_in(window, async move |editor, cx| {
// Debounce delay
smol::Timer::after(debounce).await;
let response = task.await?;
editor.update_in(cx, |editor, _window, cx| {
// Only apply if cursor hasn't moved
if editor.cursor() != offset {
return;
}
// Don't show ghost text if completion menu is open
if editor.is_context_menu_open(cx) {
return;
}
if let Some(text) = response {
editor.ghost_text = Some(text);
cx.notify();
}
})?;
Ok(())
});
}
/// Check if ghost text exists.
pub fn ghost_text_exists(&self) -> bool {
self.ghost_text.is_some()
}
/// Set ghost text to display as an inline completion suggestion.
pub fn set_ghost_text(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
self.ghost_text = Some(text.into());
cx.notify();
}
/// Clear the ghost text.
pub fn clear_ghost_text(&mut self, cx: &mut Context<Self>) {
if self.ghost_text.is_some() {
self.ghost_text = None;
cx.notify();
}
}
/// Accept the ghost text, inserting it at the cursor position.
/// Returns true if ghost text was accepted, false if there was none.
pub fn accept_ghost_text(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if let Some(ghost_text) = self.ghost_text.take() {
let cursor = self.cursor();
let range_utf16 = self.range_to_utf16(&(cursor..cursor));
self.replace_text_in_range_silent(Some(range_utf16), &ghost_text, window, cx);
cx.notify();
true
} else {
false
}
}
Then in the editor.rs:
fn inline_completion(
&self,
text: &Rope,
offset: usize,
_window: &mut Window,
cx: &mut Context<InputState>,
) -> Task<Result<Option<SharedString>>> {
let text_string = text.to_string();
cx.background_spawn(async move {
// Get the current line text before cursor
let line_start = text_string[..offset]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let current_line = &text_string[line_start..offset];
// Simple pattern matching for demo (replace with AI call)
let suggestion = if current_line.trim_start().starts_with("fn ")
&& !current_line.contains('{')
{
Some("() {\n \n}".into())
} else if current_line.trim_start().starts_with("let ") && !current_line.contains('=') {
Some(" = ".into())
} else if current_line.trim() == "//" {
Some(" TODO: ".into())
} else if current_line.ends_with("pub ") {
Some("fn ".into())
} else {
None
};
Ok(suggestion)
})
}