From b1a25a8f95c27c40cfa47c648b67004f8d0d78af Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 22 Jan 2026 18:54:18 +0900 Subject: [PATCH] ghost text --- Cargo.toml | 2 +- crates/fresh-editor/src/app/input.rs | 15 + crates/fresh-editor/src/app/lsp_requests.rs | 435 +++++++++++++++++- crates/fresh-editor/src/app/mod.rs | 19 + crates/fresh-editor/src/app/toggle_actions.rs | 5 +- .../fresh-editor/src/services/async_bridge.rs | 20 +- .../src/services/lsp/async_handler.rs | 147 +++++- .../fresh-editor/src/services/lsp/manager.rs | 17 + .../src/view/ui/split_rendering.rs | 16 +- crates/fresh-editor/src/view/virtual_text.rs | 41 ++ 10 files changed, 703 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c08e9cd11..733a36648 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ oxc_diagnostics = "0.108" tree-sitter = "0.25.10" tree-sitter-highlight = "0.25.10" crossterm = "0.28" -lsp-types = "0.97" +lsp-types = { version = "0.97", features = ["proposed"] } ts-rs = { version = "11.1", features = ["serde_json"] } # Add more as needed during refactor diff --git a/crates/fresh-editor/src/app/input.rs b/crates/fresh-editor/src/app/input.rs index 7c938484d..210a050ff 100644 --- a/crates/fresh-editor/src/app/input.rs +++ b/crates/fresh-editor/src/app/input.rs @@ -207,9 +207,14 @@ impl Editor { | Action::None => { // Don't cancel for LSP actions or no-op } + Action::InsertTab => { + // Allow inline completion acceptance on Tab without clearing ghost text first + self.cancel_pending_lsp_requests(); + } _ => { // Cancel any pending LSP requests self.cancel_pending_lsp_requests(); + self.clear_inline_completion(); } } @@ -476,7 +481,10 @@ impl Editor { ); } Action::LspCompletion => { + self.clear_inline_completion(); self.request_completion()?; + let _ = + self.request_inline_completion(lsp_types::InlineCompletionTriggerKind::Invoked); } Action::LspGotoDefinition => { self.request_goto_definition()?; @@ -1132,6 +1140,12 @@ impl Editor { self.handle_insert_char_editor(c)?; } } + Action::InsertTab => { + if self.accept_inline_completion()? { + return Ok(()); + } + self.apply_action_as_events(Action::InsertTab)?; + } // Prompt clipboard actions Action::PromptCopy => { if let Some(prompt) = &self.prompt { @@ -2605,6 +2619,7 @@ impl Editor { // Cancel any pending LSP requests since the text is changing self.cancel_pending_lsp_requests(); + self.clear_inline_completion(); if let Some(events) = self.action_to_events(Action::InsertChar(c)) { if events.len() > 1 { diff --git a/crates/fresh-editor/src/app/lsp_requests.rs b/crates/fresh-editor/src/app/lsp_requests.rs index 2f08fe48f..13e77872f 100644 --- a/crates/fresh-editor/src/app/lsp_requests.rs +++ b/crates/fresh-editor/src/app/lsp_requests.rs @@ -18,6 +18,7 @@ use std::time::{Duration, Instant}; use lsp_types::TextDocumentContentChangeEvent; use crate::model::event::{BufferId, Event}; +use crate::primitives::snippet::{expand_snippet, is_snippet}; use crate::primitives::word_navigation::{find_word_end, find_word_start}; use crate::services::lsp::manager::detect_language; use crate::view::prompt::{Prompt, PromptType}; @@ -27,6 +28,9 @@ use super::{uri_to_path, Editor, SemanticTokenRangeRequest}; const SEMANTIC_TOKENS_FULL_DEBOUNCE_MS: u64 = 500; const SEMANTIC_TOKENS_RANGE_DEBOUNCE_MS: u64 = 50; const SEMANTIC_TOKENS_RANGE_PADDING_LINES: usize = 10; +pub(crate) const INLAY_HINT_VTEXT_PREFIX: &str = "lsp-inlay-hint:"; +pub(crate) const INLINE_COMPLETION_VTEXT_PREFIX: &str = "lsp-inline-completion:"; +const INLINE_COMPLETION_VTEXT_PRIORITY: i32 = 10; impl Editor { /// Handle LSP completion response @@ -170,6 +174,87 @@ impl Editor { Ok(()) } + /// Handle LSP inline completion response (ghost text) + pub(crate) fn handle_inline_completion_response( + &mut self, + request_id: u64, + items: Vec, + ) -> AnyhowResult<()> { + if self.pending_inline_completion_request != Some(request_id) { + tracing::debug!( + "Ignoring inline completion response for outdated request {}", + request_id + ); + return Ok(()); + } + + self.pending_inline_completion_request = None; + + // Clear any previous inline completion + self.clear_inline_completion(); + + if items.is_empty() { + tracing::debug!("No inline completion items received"); + return Ok(()); + } + + // Only show inline completions for a single cursor with no selection + if self.has_active_selection() || self.active_state().cursors.count() > 1 { + return Ok(()); + } + + let item = items.into_iter().next().unwrap(); + + let (display_text, cursor_pos, buffer_id) = { + let state = self.active_state_mut(); + let cursor_pos = state.cursors.primary().position; + let display_text = match Self::inline_completion_display_text(state, &item, cursor_pos) + { + Some(text) => text, + None => return Ok(()), + }; + (display_text, cursor_pos, self.active_buffer()) + }; + + if let Some(state) = self.buffers.get_mut(&buffer_id) { + if state.buffer.is_empty() { + return Ok(()); + } + + let (anchor_pos, position) = if cursor_pos >= state.buffer.len() { + ( + state.buffer.len().saturating_sub(1), + crate::view::virtual_text::VirtualTextPosition::AfterChar, + ) + } else { + ( + cursor_pos, + crate::view::virtual_text::VirtualTextPosition::BeforeChar, + ) + }; + + let hint_style = ratatui::style::Style::default() + .fg(self.theme.editor_fg) + .add_modifier(ratatui::style::Modifier::DIM); + + state.virtual_texts.add_inline_with_id( + &mut state.marker_list, + anchor_pos, + display_text, + hint_style, + position, + INLINE_COMPLETION_VTEXT_PRIORITY, + format!("{}{}", INLINE_COMPLETION_VTEXT_PREFIX, request_id), + false, + ); + } + + self.inline_completion_item = Some(item); + self.inline_completion_buffer = Some(buffer_id); + + Ok(()) + } + /// Handle LSP go-to-definition response pub(crate) fn handle_goto_definition_response( &mut self, @@ -245,7 +330,9 @@ impl Editor { /// Check if there are any pending LSP requests pub fn has_pending_lsp_requests(&self) -> bool { - self.pending_completion_request.is_some() || self.pending_goto_definition_request.is_some() + self.pending_completion_request.is_some() + || self.pending_inline_completion_request.is_some() + || self.pending_goto_definition_request.is_some() } /// Cancel any pending LSP requests @@ -258,6 +345,13 @@ impl Editor { self.send_lsp_cancel_request(request_id); self.lsp_status.clear(); } + if let Some(request_id) = self.pending_inline_completion_request.take() { + tracing::debug!( + "Canceling pending LSP inline completion request {}", + request_id + ); + self.send_lsp_cancel_request(request_id); + } if let Some(request_id) = self.pending_goto_definition_request.take() { tracing::debug!( "Canceling pending LSP goto-definition request {}", @@ -269,6 +363,23 @@ impl Editor { } } + /// Clear the current inline completion (ghost text) from the active buffer. + pub(crate) fn clear_inline_completion(&mut self) { + let buffer_id = self.inline_completion_buffer.take(); + if let Some(buffer_id) = buffer_id { + self.remove_inline_completion_vtext(buffer_id); + } + self.inline_completion_item = None; + } + + fn remove_inline_completion_vtext(&mut self, buffer_id: BufferId) { + if let Some(state) = self.buffers.get_mut(&buffer_id) { + state + .virtual_texts + .remove_by_prefix(&mut state.marker_list, INLINE_COMPLETION_VTEXT_PREFIX); + } + } + /// Send a cancel request to the LSP server for a specific request ID fn send_lsp_cancel_request(&mut self, request_id: u64) { // Get the current file path to determine language @@ -403,6 +514,213 @@ impl Editor { Ok(()) } + /// Request LSP inline completion (ghost text) at current cursor position + pub(crate) fn request_inline_completion( + &mut self, + trigger_kind: lsp_types::InlineCompletionTriggerKind, + ) -> AnyhowResult<()> { + if self.has_active_selection() || self.active_state().cursors.count() > 1 { + return Ok(()); + } + + let path = match self.active_state().buffer.file_path() { + Some(p) => p, + None => return Ok(()), + }; + + let language = match detect_language(path, &self.config.languages) { + Some(lang) => lang, + None => return Ok(()), + }; + + let inline_supported = self + .lsp + .as_ref() + .map(|lsp| lsp.inline_completion_supported(&language)) + .unwrap_or(false); + if !inline_supported { + return Ok(()); + } + + let state = self.active_state(); + let cursor_pos = state.cursors.primary().position; + let (line, character) = state.buffer.position_to_lsp_position(cursor_pos); + let buffer_id = self.active_buffer(); + let request_id = self.next_lsp_request_id; + + let sent = self + .with_lsp_for_buffer(buffer_id, |handle, uri, _language| { + let result = handle.inline_completion( + request_id, + uri.clone(), + line as u32, + character as u32, + trigger_kind, + ); + if result.is_ok() { + tracing::info!( + "Requested inline completion at {}:{}:{}", + uri.as_str(), + line, + character + ); + } + result.is_ok() + }) + .unwrap_or(false); + + if sent { + self.next_lsp_request_id += 1; + self.pending_inline_completion_request = Some(request_id); + } + + Ok(()) + } + + fn inline_completion_display_text( + state: &mut crate::state::EditorState, + item: &lsp_types::InlineCompletionItem, + cursor_pos: usize, + ) -> Option { + let (insert_text, _cursor_offset) = if matches!( + item.insert_text_format, + Some(lsp_types::InsertTextFormat::SNIPPET) + ) || is_snippet(&item.insert_text) + { + let expanded = expand_snippet(&item.insert_text); + (expanded.text, Some(expanded.cursor_offset)) + } else { + (item.insert_text.clone(), None) + }; + + let mut display_text = insert_text; + + if let Some(range) = &item.range { + let start_pos = state + .buffer + .lsp_position_to_byte(range.start.line as usize, range.start.character as usize); + if start_pos <= cursor_pos { + let prefix = state.get_text_range(start_pos, cursor_pos); + if !prefix.is_empty() && display_text.starts_with(&prefix) { + display_text = display_text[prefix.len()..].to_string(); + } + } + } + + if display_text.is_empty() { + return None; + } + + let mut first_line = display_text.split('\n').next().unwrap_or("").to_string(); + if first_line.ends_with('\r') { + first_line.pop(); + } + if first_line.is_empty() { + None + } else { + Some(first_line) + } + } + + /// Accept the current inline completion, inserting it into the buffer. + /// Returns Ok(true) if a completion was accepted. + pub(crate) fn accept_inline_completion(&mut self) -> AnyhowResult { + let Some(item) = self.inline_completion_item.take() else { + return Ok(false); + }; + + let buffer_id = self + .inline_completion_buffer + .take() + .unwrap_or_else(|| self.active_buffer()); + + self.remove_inline_completion_vtext(buffer_id); + + let (cursor_id, start_pos, end_pos, deleted_text) = { + let state = self + .buffers + .get_mut(&buffer_id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))?; + let cursor_id = state.cursors.primary_id(); + let cursor_pos = state.cursors.primary().position; + let (start_pos, end_pos) = if let Some(range) = &item.range { + let start_pos = state.buffer.lsp_position_to_byte( + range.start.line as usize, + range.start.character as usize, + ); + let end_pos = state + .buffer + .lsp_position_to_byte(range.end.line as usize, range.end.character as usize); + (start_pos, end_pos) + } else { + (cursor_pos, cursor_pos) + }; + let deleted_text = if start_pos < end_pos { + state.get_text_range(start_pos, end_pos) + } else { + String::new() + }; + (cursor_id, start_pos, end_pos, deleted_text) + }; + + let (insert_text, cursor_offset) = if matches!( + item.insert_text_format, + Some(lsp_types::InsertTextFormat::SNIPPET) + ) || is_snippet(&item.insert_text) + { + let expanded = expand_snippet(&item.insert_text); + (expanded.text, Some(expanded.cursor_offset)) + } else { + (item.insert_text.clone(), None) + }; + + let mut events = Vec::new(); + if start_pos < end_pos { + events.push(Event::Delete { + range: start_pos..end_pos, + deleted_text, + cursor_id, + }); + } + if !insert_text.is_empty() { + events.push(Event::Insert { + position: start_pos, + text: insert_text.clone(), + cursor_id, + }); + } + + if !events.is_empty() { + self.apply_events_to_buffer_as_bulk_edit( + buffer_id, + events, + "Inline completion".to_string(), + )?; + } + + if let Some(offset) = cursor_offset { + let new_cursor_pos = start_pos + offset; + if let Some(state) = self.buffers.get(&buffer_id) { + let current_pos = state.cursors.primary().position; + if current_pos != new_cursor_pos { + let move_event = Event::MoveCursor { + cursor_id, + old_position: current_pos, + new_position: new_cursor_pos, + old_anchor: None, + new_anchor: None, + old_sticky_column: 0, + new_sticky_column: 0, + }; + self.apply_event_to_active_buffer(&move_event); + self.active_event_log_mut().append(move_event); + } + } + } + + Ok(true) + } + /// Check if the inserted character should trigger completion /// and if so, request completion automatically. /// @@ -449,6 +767,8 @@ impl Editor { quick_suggestions_enabled ); let _ = self.request_completion(); + let _ = + self.request_inline_completion(lsp_types::InlineCompletionTriggerKind::Automatic); } } @@ -730,8 +1050,10 @@ impl Editor { use crate::view::virtual_text::VirtualTextPosition; use ratatui::style::{Color, Style}; - // Clear existing inlay hints - state.virtual_texts.clear(&mut state.marker_list); + // Clear existing inlay hints only + state + .virtual_texts + .remove_by_prefix(&mut state.marker_list, INLAY_HINT_VTEXT_PREFIX); if hints.is_empty() { return; @@ -740,7 +1062,7 @@ impl Editor { // Style for inlay hints - dimmed to not distract from actual code let hint_style = Style::default().fg(Color::Rgb(128, 128, 128)); - for hint in hints { + for (idx, hint) in hints.iter().enumerate() { // Convert LSP position to byte offset let byte_offset = state.buffer.lsp_position_to_byte( hint.position.line as usize, @@ -777,13 +1099,14 @@ impl Editor { // Use the hint text as-is - spacing is handled during rendering let display_text = text; - state.virtual_texts.add( + state.virtual_texts.add_with_id( &mut state.marker_list, byte_offset, display_text, hint_style, position, 0, // Default priority + format!("{}{}", INLAY_HINT_VTEXT_PREFIX, idx), ); } @@ -2183,10 +2506,42 @@ impl Editor { #[cfg(test)] mod tests { use super::Editor; + use super::INLINE_COMPLETION_VTEXT_PREFIX; + use crate::config::Config; + use crate::config_io::DirectoryContext; use crate::model::buffer::Buffer; use crate::state::EditorState; + use crate::view::color_support::ColorCapability; use crate::view::virtual_text::VirtualTextPosition; use lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, Position}; + use lsp_types::{InlineCompletionItem, InsertTextFormat, Range}; + use tempfile::TempDir; + + /// Create a test Editor with a buffer containing the provided text. + fn test_editor_with_buffer(text: &str) -> (Editor, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let dir_context = DirectoryContext::for_testing(temp_dir.path()); + let mut editor = Editor::new( + Config::default(), + 80, + 24, + dir_context, + ColorCapability::TrueColor, + ) + .unwrap(); + + let buffer_id = editor.active_buffer(); + if let Some(state) = editor.buffers.get_mut(&buffer_id) { + state.buffer = Buffer::from_str_test(text); + if !state.buffer.is_empty() { + state.marker_list.adjust_for_insert(0, state.buffer.len()); + } + state.cursors.primary_mut().position = state.buffer.len(); + state.cursors.primary_mut().anchor = None; + } + + (editor, temp_dir) + } fn make_hint(line: u32, character: u32, label: &str, kind: Option) -> InlayHint { InlayHint { @@ -2256,4 +2611,74 @@ mod tests { assert!(state.virtual_texts.is_empty()); } + + #[test] + fn test_inline_completion_strips_prefix_and_has_no_padding() { + let (mut editor, _temp) = test_editor_with_buffer("console."); + + editor.pending_inline_completion_request = Some(1); + + let item = InlineCompletionItem { + insert_text: ".log()".to_string(), + filter_text: None, + range: Some(Range { + start: Position { + line: 0, + character: 7, + }, + end: Position { + line: 0, + character: 8, + }, + }), + command: None, + insert_text_format: Some(InsertTextFormat::PLAIN_TEXT), + }; + + editor + .handle_inline_completion_response(1, vec![item]) + .unwrap(); + + let state = editor.active_state(); + let vtexts = state + .virtual_texts + .query_range(&state.marker_list, 0, state.buffer.len()); + let inline = vtexts + .iter() + .find(|(_, vt)| { + vt.string_id + .as_deref() + .is_some_and(|s| s.starts_with(INLINE_COMPLETION_VTEXT_PREFIX)) + }) + .expect("expected inline completion virtual text"); + + assert_eq!(inline.1.text, "log()"); + assert!(!inline.1.pad_inline); + assert_eq!(inline.1.position, VirtualTextPosition::AfterChar); + } + + #[test] + fn test_accept_inline_completion_inserts_text() { + let (mut editor, _temp) = test_editor_with_buffer("hello"); + + let buffer_id = editor.active_buffer(); + editor.inline_completion_item = Some(InlineCompletionItem { + insert_text: " world".to_string(), + filter_text: None, + range: None, + command: None, + insert_text_format: Some(InsertTextFormat::PLAIN_TEXT), + }); + editor.inline_completion_buffer = Some(buffer_id); + + let accepted = editor.accept_inline_completion().unwrap(); + assert!(accepted); + + let text = editor + .active_state() + .buffer + .to_string() + .expect("buffer text"); + assert_eq!(text, "hello world"); + } } diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index 406031b99..912a4785a 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -330,6 +330,15 @@ pub struct Editor { /// Stored when completion popup is shown, used for re-filtering as user types completion_items: Option>, + /// Pending LSP inline completion request ID (if any) + pending_inline_completion_request: Option, + + /// Active inline completion item (ghost text) + inline_completion_item: Option, + + /// Buffer that currently owns the inline completion + inline_completion_buffer: Option, + /// Pending LSP go-to-definition request ID (if any) pending_goto_definition_request: Option, @@ -991,6 +1000,9 @@ impl Editor { next_lsp_request_id: 0, pending_completion_request: None, completion_items: None, + pending_inline_completion_request: None, + inline_completion_item: None, + inline_completion_buffer: None, pending_goto_definition_request: None, pending_hover_request: None, pending_references_request: None, @@ -3240,6 +3252,7 @@ impl Editor { AsyncMessage::LspInitialized { language, completion_trigger_characters, + inline_completion_supported, semantic_tokens_legend, semantic_tokens_full, semantic_tokens_full_delta, @@ -3259,6 +3272,7 @@ impl Editor { &language, completion_trigger_characters, ); + lsp.set_inline_completion_supported(&language, inline_completion_supported); lsp.set_semantic_tokens_capabilities( &language, semantic_tokens_legend, @@ -3344,6 +3358,11 @@ impl Editor { tracing::error!("Error handling completion response: {}", e); } } + AsyncMessage::LspInlineCompletion { request_id, items } => { + if let Err(e) = self.handle_inline_completion_response(request_id, items) { + tracing::error!("Error handling inline completion response: {}", e); + } + } AsyncMessage::LspGotoDefinition { request_id, locations, diff --git a/crates/fresh-editor/src/app/toggle_actions.rs b/crates/fresh-editor/src/app/toggle_actions.rs index 524ec90b8..98e2f3359 100644 --- a/crates/fresh-editor/src/app/toggle_actions.rs +++ b/crates/fresh-editor/src/app/toggle_actions.rs @@ -13,6 +13,7 @@ use crate::config_io::{ConfigLayer, ConfigResolver}; use crate::input::keybindings::KeybindingResolver; use crate::services::lsp::manager::detect_language; +use super::lsp_requests::INLAY_HINT_VTEXT_PREFIX; use super::Editor; impl Editor { @@ -176,7 +177,9 @@ impl Editor { } else { // Clear inlay hints from all buffers for state in self.buffers.values_mut() { - state.virtual_texts.clear(&mut state.marker_list); + state + .virtual_texts + .remove_by_prefix(&mut state.marker_list, INLAY_HINT_VTEXT_PREFIX); } self.set_status_message(t!("toggle.inlay_hints_disabled").to_string()); } diff --git a/crates/fresh-editor/src/services/async_bridge.rs b/crates/fresh-editor/src/services/async_bridge.rs index bd68bdaf2..1b697fa3f 100644 --- a/crates/fresh-editor/src/services/async_bridge.rs +++ b/crates/fresh-editor/src/services/async_bridge.rs @@ -13,7 +13,7 @@ use crate::services::terminal::TerminalId; use crate::view::file_tree::{FileTreeView, NodeId}; use lsp_types::{ - CodeActionOrCommand, CompletionItem, Diagnostic, InlayHint, Location, + CodeActionOrCommand, CompletionItem, Diagnostic, InlayHint, InlineCompletionItem, Location, SemanticTokensFullDeltaResult, SemanticTokensLegend, SemanticTokensRangeResult, SemanticTokensResult, SignatureHelp, }; @@ -42,6 +42,8 @@ pub enum AsyncMessage { language: String, /// Completion trigger characters from server capabilities completion_trigger_characters: Vec, + /// Whether the server supports inline completions (ghost text) + inline_completion_supported: bool, /// Legend describing semantic token types supported by the server semantic_tokens_legend: Option, /// Whether the server supports full document semantic tokens @@ -66,6 +68,12 @@ pub enum AsyncMessage { items: Vec, }, + /// LSP inline completion response (ghost text) + LspInlineCompletion { + request_id: u64, + items: Vec, + }, + /// LSP go-to-definition response LspGotoDefinition { request_id: u64, @@ -349,6 +357,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![".".to_string()], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -383,6 +392,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -393,6 +403,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "typescript".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -425,6 +436,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -435,6 +447,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "typescript".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -537,6 +550,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -558,6 +572,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -584,6 +599,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -594,6 +610,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "typescript".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -604,6 +621,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "python".to_string(), completion_trigger_characters: vec![], + inline_completion_supported: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, diff --git a/crates/fresh-editor/src/services/lsp/async_handler.rs b/crates/fresh-editor/src/services/lsp/async_handler.rs index e13730a32..b5936a97b 100644 --- a/crates/fresh-editor/src/services/lsp/async_handler.rs +++ b/crates/fresh-editor/src/services/lsp/async_handler.rs @@ -200,8 +200,9 @@ impl LspClientState { /// Create common LSP client capabilities with workDoneProgress support fn create_client_capabilities() -> ClientCapabilities { use lsp_types::{ - GeneralClientCapabilities, RenameClientCapabilities, TextDocumentClientCapabilities, - WorkspaceClientCapabilities, WorkspaceEditClientCapabilities, + GeneralClientCapabilities, InlineCompletionClientCapabilities, RenameClientCapabilities, + TextDocumentClientCapabilities, WorkspaceClientCapabilities, + WorkspaceEditClientCapabilities, }; ClientCapabilities { @@ -224,6 +225,9 @@ fn create_client_capabilities() -> ClientCapabilities { honors_change_annotations: Some(true), ..Default::default() }), + inline_completion: Some(InlineCompletionClientCapabilities { + dynamic_registration: Some(false), + }), semantic_tokens: Some(SemanticTokensClientCapabilities { dynamic_registration: Some(true), requests: SemanticTokensClientCapabilitiesRequests { @@ -360,6 +364,15 @@ enum LspCommand { character: u32, }, + /// Request inline completion (ghost text) at position + InlineCompletion { + request_id: u64, + uri: Uri, + line: u32, + character: u32, + trigger_kind: lsp_types::InlineCompletionTriggerKind, + }, + /// Request go-to-definition GotoDefinition { request_id: u64, @@ -739,6 +752,16 @@ impl LspState { .and_then(|cp| cp.trigger_characters.clone()) .unwrap_or_default(); + let inline_completion_supported = result + .capabilities + .inline_completion_provider + .as_ref() + .map(|provider| match provider { + lsp_types::OneOf::Left(value) => *value, + lsp_types::OneOf::Right(_) => true, + }) + .unwrap_or(false); + let ( semantic_tokens_legend, semantic_tokens_full, @@ -750,6 +773,7 @@ impl LspState { let _ = self.async_tx.send(AsyncMessage::LspInitialized { language: self.language.clone(), completion_trigger_characters, + inline_completion_supported, semantic_tokens_legend, semantic_tokens_full, semantic_tokens_full_delta, @@ -941,6 +965,75 @@ impl LspState { } } + /// Handle inline completion request (ghost text) + #[allow(clippy::type_complexity)] + async fn handle_inline_completion( + &mut self, + request_id: u64, + uri: Uri, + line: u32, + character: u32, + trigger_kind: lsp_types::InlineCompletionTriggerKind, + pending: &Arc>>>>, + ) -> Result<(), String> { + use lsp_types::{ + InlineCompletionContext, InlineCompletionParams, InlineCompletionResponse, Position, + TextDocumentIdentifier, TextDocumentPositionParams, WorkDoneProgressParams, + }; + + tracing::trace!( + "LSP: inlineCompletion request at {}:{}:{}", + uri.as_str(), + line, + character + ); + + let params = InlineCompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: Position { line, character }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + context: InlineCompletionContext { + trigger_kind, + selected_completion_info: None, + }, + }; + + match self + .send_request_sequential_tracked::<_, Value>( + "textDocument/inlineCompletion", + Some(params), + pending, + Some(request_id), + ) + .await + { + Ok(result) => { + let response = serde_json::from_value::>(result) + .unwrap_or(None); + let items = match response { + Some(InlineCompletionResponse::Array(items)) => items, + Some(InlineCompletionResponse::List(list)) => list.items, + None => vec![], + }; + + let _ = self + .async_tx + .send(AsyncMessage::LspInlineCompletion { request_id, items }); + Ok(()) + } + Err(e) => { + tracing::error!("Inline completion request failed: {}", e); + let _ = self.async_tx.send(AsyncMessage::LspInlineCompletion { + request_id, + items: vec![], + }); + Err(e) + } + } + } + /// Handle go-to-definition request #[allow(clippy::type_complexity)] async fn handle_goto_definition( @@ -2264,6 +2357,36 @@ impl LspTask { }); } } + LspCommand::InlineCompletion { + request_id, + uri, + line, + character, + trigger_kind, + } => { + if state.initialized { + tracing::info!( + "Processing InlineCompletion request for {}", + uri.as_str() + ); + let _ = state + .handle_inline_completion( + request_id, + uri, + line, + character, + trigger_kind, + &pending, + ) + .await; + } else { + tracing::trace!("LSP not initialized, sending empty inline completion"); + let _ = state.async_tx.send(AsyncMessage::LspInlineCompletion { + request_id, + items: vec![], + }); + } + } LspCommand::GotoDefinition { request_id, uri, @@ -3248,6 +3371,26 @@ impl LspHandle { .map_err(|_| "Failed to send completion command".to_string()) } + /// Request inline completion at position + pub fn inline_completion( + &self, + request_id: u64, + uri: Uri, + line: u32, + character: u32, + trigger_kind: lsp_types::InlineCompletionTriggerKind, + ) -> Result<(), String> { + self.command_tx + .try_send(LspCommand::InlineCompletion { + request_id, + uri, + line, + character, + trigger_kind, + }) + .map_err(|_| "Failed to send inline completion command".to_string()) + } + /// Request go-to-definition pub fn goto_definition( &self, diff --git a/crates/fresh-editor/src/services/lsp/manager.rs b/crates/fresh-editor/src/services/lsp/manager.rs index c48fc6814..eba886a39 100644 --- a/crates/fresh-editor/src/services/lsp/manager.rs +++ b/crates/fresh-editor/src/services/lsp/manager.rs @@ -66,6 +66,8 @@ pub struct LspManager { /// Completion trigger characters per language (from server capabilities) completion_trigger_characters: HashMap>, + /// Whether inline completion (ghost text) is supported per language + inline_completion_supported: HashMap, /// Semantic token legends per language (from server capabilities) semantic_token_legends: HashMap, @@ -95,6 +97,7 @@ impl LspManager { allowed_languages: HashSet::new(), disabled_languages: HashSet::new(), completion_trigger_characters: HashMap::new(), + inline_completion_supported: HashMap::new(), semantic_token_legends: HashMap::new(), semantic_tokens_full_support: HashMap::new(), semantic_tokens_full_delta_support: HashMap::new(), @@ -129,11 +132,25 @@ impl LspManager { .insert(language.to_string(), chars); } + /// Set inline completion support for a language + pub fn set_inline_completion_supported(&mut self, language: &str, supported: bool) { + self.inline_completion_supported + .insert(language.to_string(), supported); + } + /// Get completion trigger characters for a language pub fn get_completion_trigger_characters(&self, language: &str) -> Option<&Vec> { self.completion_trigger_characters.get(language) } + /// Check if a language supports inline completion + pub fn inline_completion_supported(&self, language: &str) -> bool { + *self + .inline_completion_supported + .get(language) + .unwrap_or(&false) + } + /// Store semantic token capability information for a language pub fn set_semantic_tokens_capabilities( &mut self, diff --git a/crates/fresh-editor/src/view/ui/split_rendering.rs b/crates/fresh-editor/src/view/ui/split_rendering.rs index 31b0f3601..aecd1e7c3 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering.rs @@ -3299,9 +3299,13 @@ impl SplitRenderer { { // Flush accumulated text before inserting virtual text span_acc.flush(&mut line_spans, &mut line_view_map); - // Add extra space if at end of line (before newline) - let extra_space = if ch == '\n' { " " } else { "" }; - let text_with_space = format!("{}{} ", extra_space, vtext.text); + let text_with_space = if vtext.pad_inline { + // Add extra space if at end of line (before newline) + let extra_space = if ch == '\n' { " " } else { "" }; + format!("{}{} ", extra_space, vtext.text) + } else { + vtext.text.clone() + }; push_span_with_map( &mut line_spans, &mut line_view_map, @@ -3379,7 +3383,11 @@ impl SplitRenderer { .iter() .filter(|v| v.position == VirtualTextPosition::AfterChar) { - let text_with_space = format!(" {}", vtext.text); + let text_with_space = if vtext.pad_inline { + format!(" {}", vtext.text) + } else { + vtext.text.clone() + }; push_span_with_map( &mut line_spans, &mut line_view_map, diff --git a/crates/fresh-editor/src/view/virtual_text.rs b/crates/fresh-editor/src/view/virtual_text.rs index 11f5f78c1..0293f1107 100644 --- a/crates/fresh-editor/src/view/virtual_text.rs +++ b/crates/fresh-editor/src/view/virtual_text.rs @@ -84,6 +84,9 @@ pub struct VirtualText { pub style: Style, /// Where to render relative to the marker position pub position: VirtualTextPosition, + /// Whether inline virtual text should be padded with spaces when rendered + /// (ignored for line-level virtual text). + pub pad_inline: bool, /// Priority for ordering multiple items at same position (higher = later) pub priority: i32, /// Optional string identifier for this virtual text (for plugin use) @@ -151,6 +154,7 @@ impl VirtualTextManager { text, style, position: vtext_position, + pad_inline: true, priority, string_id: None, namespace: None, @@ -186,6 +190,7 @@ impl VirtualTextManager { text, style, position: vtext_position, + pad_inline: true, priority, string_id: Some(string_id), namespace: None, @@ -235,6 +240,7 @@ impl VirtualTextManager { text, style, position: placement, + pad_inline: true, priority, string_id: None, namespace: Some(namespace), @@ -244,6 +250,41 @@ impl VirtualTextManager { id } + /// Add a virtual text entry with a string identifier and inline padding control + #[allow(clippy::too_many_arguments)] + pub fn add_inline_with_id( + &mut self, + marker_list: &mut MarkerList, + position: usize, + text: String, + style: Style, + vtext_position: VirtualTextPosition, + priority: i32, + string_id: String, + pad_inline: bool, + ) -> VirtualTextId { + let marker_id = marker_list.create(position, false); + + let id = VirtualTextId(self.next_id); + self.next_id += 1; + + self.texts.insert( + id, + VirtualText { + marker_id, + text, + style, + position: vtext_position, + pad_inline, + priority, + string_id: Some(string_id), + namespace: None, + }, + ); + + id + } + /// Remove a virtual text entry by its string identifier pub fn remove_by_id(&mut self, marker_list: &mut MarkerList, string_id: &str) -> bool { // Find the entry with matching string_id