From a73564034ff64f75fe3f34df47dc9b0c152d6a47 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 3 Feb 2026 14:46:58 +0900 Subject: [PATCH 1/3] impl again --- Cargo.toml | 2 +- crates/fresh-editor/src/app/input.rs | 1 + crates/fresh-editor/src/app/lsp_requests.rs | 287 +++++++++++++++++- crates/fresh-editor/src/app/mod.rs | 14 + crates/fresh-editor/src/app/render.rs | 5 + crates/fresh-editor/src/app/toggle_actions.rs | 4 +- crates/fresh-editor/src/config.rs | 9 + crates/fresh-editor/src/partial_config.rs | 4 + .../fresh-editor/src/services/async_bridge.rs | 20 +- .../src/services/lsp/async_handler.rs | 153 +++++++++- .../fresh-editor/src/services/lsp/manager.rs | 18 ++ .../src/view/ui/split_rendering.rs | 16 +- crates/fresh-editor/src/view/virtual_text.rs | 44 +++ crates/fresh-editor/tests/common/fake_lsp.rs | 5 +- crates/fresh-editor/tests/e2e/lsp.rs | 57 ++++ 15 files changed, 624 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9ce6eaa32..482f0cfe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ oxc_diagnostics = "0.111.0" tree-sitter = "0.26.5" tree-sitter-highlight = "0.26.3" crossterm = "0.29" -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 6e31b7f81..8251e891e 100644 --- a/crates/fresh-editor/src/app/input.rs +++ b/crates/fresh-editor/src/app/input.rs @@ -521,6 +521,7 @@ impl Editor { } Action::LspCompletion => { self.request_completion()?; + let _ = self.request_inline_completion_invoked(); } Action::LspGotoDefinition => { self.request_goto_definition()?; diff --git a/crates/fresh-editor/src/app/lsp_requests.rs b/crates/fresh-editor/src/app/lsp_requests.rs index e64c23ffc..46912d9d2 100644 --- a/crates/fresh-editor/src/app/lsp_requests.rs +++ b/crates/fresh-editor/src/app/lsp_requests.rs @@ -26,8 +26,119 @@ 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; +const GHOST_TEXT_ID: &str = "ghost-text"; impl Editor { + /// Clear any active ghost text (inline completion) from the buffer. + pub(crate) fn clear_ghost_text(&mut self) { + if let Some(buffer_id) = self.ghost_text_buffer_id.take() { + if let Some(state) = self.buffers.get_mut(&buffer_id) { + state + .virtual_texts + .remove_by_id(&mut state.marker_list, GHOST_TEXT_ID); + } + } + } + + /// Request inline completion (ghost text) with a specific trigger kind. + pub(crate) fn request_inline_completion_automatic(&mut self) -> AnyhowResult<()> { + self.request_inline_completion_with_trigger( + lsp_types::InlineCompletionTriggerKind::Automatic, + ) + } + + /// Request inline completion (ghost text) explicitly invoked by the user. + pub(crate) fn request_inline_completion_invoked(&mut self) -> AnyhowResult<()> { + self.request_inline_completion_with_trigger(lsp_types::InlineCompletionTriggerKind::Invoked) + } + + fn request_inline_completion_with_trigger( + &mut self, + trigger_kind: lsp_types::InlineCompletionTriggerKind, + ) -> AnyhowResult<()> { + if !self.config.editor.enable_ghost_text { + self.clear_ghost_text(); + return Ok(()); + } + + self.clear_ghost_text(); + + let (buffer_id, line, character, language) = { + let state = self.active_state(); + if state.cursors.count() != 1 || !state.cursors.primary().collapsed() { + self.clear_ghost_text(); + return Ok(()); + } + + let path = match 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 cursor_pos = state.cursors.primary().position; + let (line, character) = state.buffer.position_to_lsp_position(cursor_pos); + (self.active_buffer(), line, character, language) + }; + + let inline_supported = self + .lsp + .as_ref() + .map(|lsp| lsp.inline_completion_supported(&language)) + .unwrap_or(false); + + if !inline_supported { + self.clear_ghost_text(); + return Ok(()); + } + + let request_id = self.next_lsp_request_id; + + if let Some(pending_id) = self.pending_inline_completion_request.take() { + self.send_lsp_cancel_request(pending_id); + } + + let sent = self + .with_lsp_for_buffer(buffer_id, |handle, uri, _language| { + let trigger_label = + if trigger_kind == lsp_types::InlineCompletionTriggerKind::Invoked { + "Invoked" + } else { + "Automatic" + }; + let result = handle.inline_completion( + request_id, + uri.clone(), + line as u32, + character as u32, + trigger_kind, + None, + ); + if result.is_ok() { + tracing::info!( + "Requested inline completion at {}:{}:{} ({})", + uri.as_str(), + line, + character, + trigger_label + ); + } + result.is_ok() + }) + .unwrap_or(false); + + if sent { + self.next_lsp_request_id += 1; + self.pending_inline_completion_request = Some(request_id); + } + + Ok(()) + } + /// Handle LSP completion response pub(crate) fn handle_completion_response( &mut self, @@ -169,6 +280,151 @@ impl Editor { Ok(()) } + /// Handle LSP inline completion response (textDocument/inlineCompletion) + pub(crate) fn handle_inline_completion_response( + &mut self, + request_id: u64, + items: Vec, + ) { + use crate::primitives::snippet::expand_snippet; + use crate::primitives::word_navigation::find_completion_word_start; + use crate::view::virtual_text::VirtualTextPosition; + use ratatui::style::{Modifier, Style}; + + if self.pending_inline_completion_request != Some(request_id) { + tracing::debug!( + "Ignoring inline completion response for outdated request {}", + request_id + ); + return; + } + + self.pending_inline_completion_request = None; + + if !self.config.editor.enable_ghost_text { + self.clear_ghost_text(); + return; + } + + let (buffer_id, cursor_pos, cursor_count, cursor_collapsed, buffer_len, suggestion_item) = { + let state = self.active_state(); + let cursor = state.cursors.primary(); + ( + self.active_buffer(), + cursor.position, + state.cursors.count(), + cursor.collapsed(), + state.buffer.len(), + items.into_iter().next(), + ) + }; + + if cursor_count != 1 || !cursor_collapsed { + self.clear_ghost_text(); + return; + } + + let Some(item) = suggestion_item else { + self.clear_ghost_text(); + return; + }; + + let lsp_types::InlineCompletionItem { + insert_text, + insert_text_format, + range, + .. + } = item; + + let mut suggestion = insert_text; + if insert_text_format == Some(lsp_types::InsertTextFormat::SNIPPET) { + suggestion = expand_snippet(&suggestion).text; + } + + let suggestion = suggestion.lines().next().unwrap_or("").to_string(); + if suggestion.is_empty() { + self.clear_ghost_text(); + return; + } + + let prefix = { + let state = self.active_state_mut(); + if let Some(range) = range { + let start = state.buffer.line_col_to_position( + range.start.line as usize, + range.start.character as usize, + ); + let end = state + .buffer + .line_col_to_position(range.end.line as usize, range.end.character as usize); + if cursor_pos >= start && cursor_pos <= end && start < cursor_pos { + state.get_text_range(start, cursor_pos) + } else { + let word_start = find_completion_word_start(&state.buffer, cursor_pos); + if word_start < cursor_pos { + state.get_text_range(word_start, cursor_pos) + } else { + String::new() + } + } + } else { + let word_start = find_completion_word_start(&state.buffer, cursor_pos); + if word_start < cursor_pos { + state.get_text_range(word_start, cursor_pos) + } else { + String::new() + } + } + }; + + let prefix_lower = prefix.to_lowercase(); + let suggestion_lower = suggestion.to_lowercase(); + let prefix_char_count = prefix.chars().count(); + + let suffix = if prefix.is_empty() { + suggestion + } else if suggestion.starts_with(&prefix) || suggestion_lower.starts_with(&prefix_lower) { + let byte_idx = suggestion + .char_indices() + .nth(prefix_char_count) + .map(|(idx, _)| idx) + .unwrap_or(suggestion.len()); + suggestion[byte_idx..].to_string() + } else { + String::new() + }; + + if suffix.is_empty() || buffer_len == 0 { + self.clear_ghost_text(); + return; + } + + let (anchor_pos, position) = if cursor_pos >= buffer_len { + (buffer_len.saturating_sub(1), VirtualTextPosition::AfterChar) + } else { + (cursor_pos, VirtualTextPosition::BeforeChar) + }; + + let style = Style::default() + .fg(self.theme.line_number_fg) + .add_modifier(Modifier::DIM); + + self.clear_ghost_text(); + if let Some(state) = self.buffers.get_mut(&buffer_id) { + state.virtual_texts.add_with_id_and_padding( + &mut state.marker_list, + anchor_pos, + suffix, + style, + position, + 100, + GHOST_TEXT_ID.to_string(), + false, + ); + self.ghost_text_buffer_id = Some(buffer_id); + } + } + /// Handle LSP go-to-definition response pub(crate) fn handle_goto_definition_response( &mut self, @@ -277,7 +533,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 @@ -290,6 +548,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 {}", @@ -299,6 +564,8 @@ impl Editor { self.send_lsp_cancel_request(request_id); self.lsp_status.clear(); } + + self.clear_ghost_text(); } /// Send a cancel request to the LSP server for a specific request ID @@ -467,6 +734,7 @@ impl Editor { // Cancel any pending scheduled trigger self.scheduled_completion_trigger = None; let _ = self.request_completion(); + let _ = self.request_inline_completion_automatic(); return; } @@ -766,8 +1034,11 @@ 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 (preserve other virtual text like ghost text) + const INLAY_HINT_PREFIX: &str = "lsp-inlay:"; + state + .virtual_texts + .remove_by_prefix(&mut state.marker_list, INLAY_HINT_PREFIX); if hints.is_empty() { return; @@ -776,7 +1047,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, @@ -813,13 +1084,19 @@ impl Editor { // Use the hint text as-is - spacing is handled during rendering let display_text = text; - state.virtual_texts.add( + let string_id = format!( + "{}{}:{}:{}", + INLAY_HINT_PREFIX, hint.position.line, hint.position.character, idx + ); + + state.virtual_texts.add_with_id( &mut state.marker_list, byte_offset, display_text, hint_style, position, 0, // Default priority + string_id, ); } diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index ab9ba585f..d349b0713 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -378,10 +378,16 @@ pub struct Editor { /// Pending LSP completion request ID (if any) pending_completion_request: Option, + /// Pending LSP inline completion request ID (if any) + pending_inline_completion_request: Option, + /// Original LSP completion items (for type-to-filter) /// Stored when completion popup is shown, used for re-filtering as user types completion_items: Option>, + /// Buffer currently showing inline ghost text for completion + ghost_text_buffer_id: Option, + /// Scheduled completion trigger time (for debounced quick suggestions) /// When Some, completion will be triggered when this instant is reached scheduled_completion_trigger: Option, @@ -1143,7 +1149,9 @@ impl Editor { in_navigation: false, next_lsp_request_id: 0, pending_completion_request: None, + pending_inline_completion_request: None, completion_items: None, + ghost_text_buffer_id: None, scheduled_completion_trigger: None, pending_goto_definition_request: None, pending_hover_request: None, @@ -1768,6 +1776,7 @@ impl Editor { tracing::debug!("Failed to trigger debounced completion: {}", e); return false; } + let _ = self.request_inline_completion_automatic(); true } @@ -3739,6 +3748,7 @@ impl Editor { AsyncMessage::LspInitialized { language, completion_trigger_characters, + inline_completion_support, semantic_tokens_legend, semantic_tokens_full, semantic_tokens_full_delta, @@ -3758,6 +3768,7 @@ impl Editor { &language, completion_trigger_characters, ); + lsp.set_inline_completion_support(&language, inline_completion_support); lsp.set_semantic_tokens_capabilities( &language, semantic_tokens_legend, @@ -3843,6 +3854,9 @@ impl Editor { tracing::error!("Error handling completion response: {}", e); } } + AsyncMessage::LspInlineCompletion { request_id, items } => { + self.handle_inline_completion_response(request_id, items); + } AsyncMessage::LspGotoDefinition { request_id, locations, diff --git a/crates/fresh-editor/src/app/render.rs b/crates/fresh-editor/src/app/render.rs index 55cbb9578..3edd21a08 100644 --- a/crates/fresh-editor/src/app/render.rs +++ b/crates/fresh-editor/src/app/render.rs @@ -1352,6 +1352,9 @@ impl Editor { self.active_event_log_mut().append(event.clone()); self.apply_event_to_active_buffer(&event); + // Clear any inline ghost text + self.clear_ghost_text(); + // Clear hover symbol highlight if present if let Some(handle) = self.hover_symbol_overlay.take() { let remove_overlay_event = crate::model::event::Event::RemoveOverlay { handle }; @@ -1417,6 +1420,8 @@ impl Editor { let event = Event::ClearPopups; self.active_event_log_mut().append(event.clone()); self.apply_event_to_active_buffer(&event); + + self.clear_ghost_text(); } // === LSP Confirmation Popup === diff --git a/crates/fresh-editor/src/app/toggle_actions.rs b/crates/fresh-editor/src/app/toggle_actions.rs index 9a23f8e3e..e486eb935 100644 --- a/crates/fresh-editor/src/app/toggle_actions.rs +++ b/crates/fresh-editor/src/app/toggle_actions.rs @@ -167,7 +167,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, "lsp-inlay:"); } self.set_status_message(t!("toggle.inlay_hints_disabled").to_string()); } diff --git a/crates/fresh-editor/src/config.rs b/crates/fresh-editor/src/config.rs index 37841a656..7516a2630 100644 --- a/crates/fresh-editor/src/config.rs +++ b/crates/fresh-editor/src/config.rs @@ -559,6 +559,14 @@ pub struct EditorConfig { #[schemars(extend("x-section" = "Completion"))] pub quick_suggestions_delay_ms: u64, + /// Show inline ghost text for the currently selected completion item. + /// This renders a dimmed suggestion directly in the buffer while the + /// completion popup is open. + /// Default: true + #[serde(default = "default_true")] + #[schemars(extend("x-section" = "Completion"))] + pub enable_ghost_text: bool, + /// Whether trigger characters (like `.`, `::`, `->`) immediately show completions. /// When true, typing a trigger character bypasses quick_suggestions_delay_ms. /// Default: true @@ -818,6 +826,7 @@ impl Default for EditorConfig { keyboard_report_all_keys_as_escape_codes: false, quick_suggestions: true, quick_suggestions_delay_ms: default_quick_suggestions_delay(), + enable_ghost_text: true, suggest_on_trigger_characters: true, accept_suggestion_on_enter: default_accept_suggestion_on_enter(), show_menu_bar: true, diff --git a/crates/fresh-editor/src/partial_config.rs b/crates/fresh-editor/src/partial_config.rs index 862e77a5f..038df1d6e 100644 --- a/crates/fresh-editor/src/partial_config.rs +++ b/crates/fresh-editor/src/partial_config.rs @@ -166,6 +166,7 @@ pub struct PartialEditorConfig { pub keyboard_report_all_keys_as_escape_codes: Option, pub quick_suggestions: Option, pub quick_suggestions_delay_ms: Option, + pub enable_ghost_text: Option, pub suggest_on_trigger_characters: Option, pub accept_suggestion_on_enter: Option, pub show_menu_bar: Option, @@ -231,6 +232,7 @@ impl Merge for PartialEditorConfig { self.quick_suggestions.merge_from(&other.quick_suggestions); self.quick_suggestions_delay_ms .merge_from(&other.quick_suggestions_delay_ms); + self.enable_ghost_text.merge_from(&other.enable_ghost_text); self.suggest_on_trigger_characters .merge_from(&other.suggest_on_trigger_characters); self.accept_suggestion_on_enter @@ -431,6 +433,7 @@ impl From<&crate::config::EditorConfig> for PartialEditorConfig { ), quick_suggestions: Some(cfg.quick_suggestions), quick_suggestions_delay_ms: Some(cfg.quick_suggestions_delay_ms), + enable_ghost_text: Some(cfg.enable_ghost_text), suggest_on_trigger_characters: Some(cfg.suggest_on_trigger_characters), accept_suggestion_on_enter: Some(cfg.accept_suggestion_on_enter), show_menu_bar: Some(cfg.show_menu_bar), @@ -523,6 +526,7 @@ impl PartialEditorConfig { quick_suggestions_delay_ms: self .quick_suggestions_delay_ms .unwrap_or(defaults.quick_suggestions_delay_ms), + enable_ghost_text: self.enable_ghost_text.unwrap_or(defaults.enable_ghost_text), suggest_on_trigger_characters: self .suggest_on_trigger_characters .unwrap_or(defaults.suggest_on_trigger_characters), diff --git a/crates/fresh-editor/src/services/async_bridge.rs b/crates/fresh-editor/src/services/async_bridge.rs index dca917f55..8eedb6555 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 completion (ghost text) + inline_completion_support: 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 (textDocument/inlineCompletion) + LspInlineCompletion { + request_id: u64, + items: Vec, + }, + /// LSP go-to-definition response LspGotoDefinition { request_id: u64, @@ -352,6 +360,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![".".to_string()], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -386,6 +395,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -396,6 +406,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "typescript".to_string(), completion_trigger_characters: vec![], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -428,6 +439,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -438,6 +450,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "typescript".to_string(), completion_trigger_characters: vec![], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -540,6 +553,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -561,6 +575,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -587,6 +602,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "rust".to_string(), completion_trigger_characters: vec![], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -597,6 +613,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "typescript".to_string(), completion_trigger_characters: vec![], + inline_completion_support: false, semantic_tokens_legend: None, semantic_tokens_full: false, semantic_tokens_full_delta: false, @@ -607,6 +624,7 @@ mod tests { .send(AsyncMessage::LspInitialized { language: "python".to_string(), completion_trigger_characters: vec![], + inline_completion_support: 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 0ea9859ed..9d305eac5 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(true), + }), semantic_tokens: Some(SemanticTokensClientCapabilities { dynamic_registration: Some(true), requests: SemanticTokensClientCapabilitiesRequests { @@ -360,6 +364,16 @@ enum LspCommand { character: u32, }, + /// Request inline completion at position (textDocument/inlineCompletion) + InlineCompletion { + request_id: u64, + uri: Uri, + line: u32, + character: u32, + trigger_kind: lsp_types::InlineCompletionTriggerKind, + selected_completion_info: Option, + }, + /// Request go-to-definition GotoDefinition { request_id: u64, @@ -744,6 +758,12 @@ impl LspState { .and_then(|cp| cp.trigger_characters.clone()) .unwrap_or_default(); + let inline_completion_support = match result.capabilities.inline_completion_provider { + Some(lsp_types::OneOf::Left(true)) => true, + Some(lsp_types::OneOf::Right(_)) => true, + _ => false, + }; + let ( semantic_tokens_legend, semantic_tokens_full, @@ -755,6 +775,7 @@ impl LspState { let _ = self.async_tx.send(AsyncMessage::LspInitialized { language: self.language.clone(), completion_trigger_characters, + inline_completion_support, semantic_tokens_legend, semantic_tokens_full, semantic_tokens_full_delta, @@ -946,6 +967,76 @@ impl LspState { } } + /// Handle inline completion request (textDocument/inlineCompletion) + #[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, + selected_completion_info: Option, + pending: &Arc>>>>, + ) -> Result<(), String> { + use lsp_types::{ + InlineCompletionContext, InlineCompletionParams, InlineCompletionResponse, Position, + TextDocumentIdentifier, TextDocumentPositionParams, WorkDoneProgressParams, + }; + + tracing::trace!( + "LSP: inline completion request at {}:{}:{}", + uri.as_str(), + line, + character + ); + + let params = InlineCompletionParams { + work_done_progress_params: WorkDoneProgressParams::default(), + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: Position { line, character }, + }, + context: InlineCompletionContext { + trigger_kind, + selected_completion_info, + }, + }; + + match self + .send_request_sequential_tracked::<_, Value>( + "textDocument/inlineCompletion", + Some(params), + pending, + Some(request_id), + ) + .await + { + Ok(result) => { + let items = match serde_json::from_value::>(result) + .unwrap_or(None) + { + Some(InlineCompletionResponse::Array(items)) => items, + Some(InlineCompletionResponse::List(list)) => list.items, + None => Vec::new(), + }; + + 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::new(), + }); + Err(e) + } + } + } + /// Handle go-to-definition request #[allow(clippy::type_complexity)] async fn handle_goto_definition( @@ -2265,6 +2356,42 @@ impl LspTask { }); } } + LspCommand::InlineCompletion { + request_id, + uri, + line, + character, + trigger_kind, + selected_completion_info, + } => { + if state.initialized { + tracing::info!( + "Processing InlineCompletion request for {}", + uri.as_str() + ); + let _ = state + .handle_inline_completion( + request_id, + uri, + line, + character, + trigger_kind, + selected_completion_info, + &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, @@ -3287,6 +3414,28 @@ 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, + selected_completion_info: Option, + ) -> Result<(), String> { + self.command_tx + .try_send(LspCommand::InlineCompletion { + request_id, + uri, + line, + character, + trigger_kind, + selected_completion_info, + }) + .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 95003a889..d2fdb6979 100644 --- a/crates/fresh-editor/src/services/lsp/manager.rs +++ b/crates/fresh-editor/src/services/lsp/manager.rs @@ -70,6 +70,9 @@ pub struct LspManager { /// Completion trigger characters per language (from server capabilities) completion_trigger_characters: HashMap>, + /// Whether a language supports inline completion (ghost text) + inline_completion_support: HashMap, + /// Semantic token legends per language (from server capabilities) semantic_token_legends: HashMap, @@ -99,6 +102,7 @@ impl LspManager { allowed_languages: HashSet::new(), disabled_languages: HashSet::new(), completion_trigger_characters: HashMap::new(), + inline_completion_support: HashMap::new(), semantic_token_legends: HashMap::new(), semantic_tokens_full_support: HashMap::new(), semantic_tokens_full_delta_support: HashMap::new(), @@ -133,11 +137,25 @@ impl LspManager { .insert(language.to_string(), chars); } + /// Store inline completion support for a language + pub fn set_inline_completion_support(&mut self, language: &str, supported: bool) { + self.inline_completion_support + .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_support + .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 be1e62016..54f4f82b4 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering.rs @@ -3419,9 +3419,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_with_space { + // 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, @@ -3499,7 +3503,11 @@ impl SplitRenderer { .iter() .filter(|v| v.position == VirtualTextPosition::AfterChar) { - let text_with_space = format!(" {}", vtext.text); + let text_with_space = if vtext.pad_with_space { + 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..c0e814919 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 to pad with spaces when rendering inline text + /// (default: true for inlay hints and other annotations) + pub pad_with_space: 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_with_space: true, priority, string_id: None, namespace: None, @@ -186,6 +190,7 @@ impl VirtualTextManager { text, style, position: vtext_position, + pad_with_space: true, priority, string_id: Some(string_id), namespace: None, @@ -235,6 +240,7 @@ impl VirtualTextManager { text, style, position: placement, + pad_with_space: true, priority, string_id: None, namespace: Some(namespace), @@ -244,6 +250,44 @@ impl VirtualTextManager { id } + /// Add a virtual text entry with a string identifier and custom padding behavior + /// + /// This is useful for inline features like ghost text that should not add + /// extra spacing around the inserted text. + #[allow(clippy::too_many_arguments)] + pub fn add_with_id_and_padding( + &mut self, + marker_list: &mut MarkerList, + position: usize, + text: String, + style: Style, + vtext_position: VirtualTextPosition, + priority: i32, + string_id: String, + pad_with_space: 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_with_space, + 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 diff --git a/crates/fresh-editor/tests/common/fake_lsp.rs b/crates/fresh-editor/tests/common/fake_lsp.rs index 7ec59e409..c69a023a4 100644 --- a/crates/fresh-editor/tests/common/fake_lsp.rs +++ b/crates/fresh-editor/tests/common/fake_lsp.rs @@ -72,7 +72,7 @@ while true; do case "$method" in "initialize") # Send initialize response - send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":{"capabilities":{"completionProvider":{"triggerCharacters":[".",":",":"]},"definitionProvider":true,"hoverProvider":true,"textDocumentSync":1,"semanticTokensProvider":{"legend":{"tokenTypes":["keyword","function","variable"],"tokenModifiers":["declaration","deprecated"]},"full":{"delta":true},"range":true}}}}' + send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":{"capabilities":{"completionProvider":{"triggerCharacters":[".",":",":"]},"inlineCompletionProvider":{},"definitionProvider":true,"hoverProvider":true,"textDocumentSync":1,"semanticTokensProvider":{"legend":{"tokenTypes":["keyword","function","variable"],"tokenModifiers":["declaration","deprecated"]},"full":{"delta":true},"range":true}}}}' ;; "textDocument/hover") # Send hover response with range @@ -87,6 +87,9 @@ case "$method" in # Send completion response with sample items send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":{"items":[{"label":"test_function","kind":3,"detail":"fn test_function()","insertText":"test_function"},{"label":"test_variable","kind":6,"detail":"let test_variable","insertText":"test_variable"},{"label":"test_struct","kind":22,"detail":"struct TestStruct","insertText":"test_struct"}]}}' ;; + "textDocument/inlineCompletion") + send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":[{"insertText":"hello_world"}]}' + ;; "textDocument/definition") # Send definition response (points to line 0, col 0) uri=$(echo "$msg" | grep -o '"uri":"[^"]*"' | head -1 | cut -d'"' -f4) diff --git a/crates/fresh-editor/tests/e2e/lsp.rs b/crates/fresh-editor/tests/e2e/lsp.rs index 2d10860bd..a6532ab42 100644 --- a/crates/fresh-editor/tests/e2e/lsp.rs +++ b/crates/fresh-editor/tests/e2e/lsp.rs @@ -253,6 +253,63 @@ fn test_lsp_completion_popup() -> anyhow::Result<()> { Ok(()) } +/// Test LSP inline completion renders ghost text +#[test] +#[cfg_attr( + target_os = "windows", + ignore = "FakeLspServer uses a Bash script which is not available on Windows" +)] +fn test_lsp_inline_completion_ghost_text() -> anyhow::Result<()> { + use crate::common::fake_lsp::FakeLspServer; + + let _fake_server = FakeLspServer::spawn()?; + + let temp_dir = tempfile::tempdir()?; + let test_file = temp_dir.path().join("test.rs"); + std::fs::write(&test_file, "")?; + + let mut config = fresh::config::Config::default(); + config.editor.enable_ghost_text = true; + config.editor.quick_suggestions = true; + config.editor.quick_suggestions_delay_ms = 0; + config.lsp.insert( + "rust".to_string(), + fresh::services::lsp::LspServerConfig { + command: FakeLspServer::script_path().to_string_lossy().to_string(), + args: vec![], + enabled: true, + auto_start: true, + process_limits: fresh::services::process_limits::ProcessLimits::default(), + initialization_options: None, + }, + ); + + let mut harness = EditorTestHarness::with_config_and_working_dir( + 120, + 30, + config, + temp_dir.path().to_path_buf(), + )?; + + harness.open_file(&test_file)?; + harness.render()?; + + for _ in 0..5 { + harness.process_async_and_render()?; + harness.sleep(std::time::Duration::from_millis(50)); + } + + harness.type_text("hel")?; + + let found = harness.wait_for_async(|h| h.screen_to_string().contains("hello_world"), 1000)?; + assert!(found, "Expected inline ghost text to render"); + + let buffer_content = harness.get_buffer_content().unwrap(); + assert_eq!(buffer_content, "hel"); + + Ok(()) +} + /// Test LSP diagnostics summary in status bar #[test] fn test_lsp_diagnostics_status_bar() -> anyhow::Result<()> { From 93b2d1aa1a89346dd8be2b84ac1a2255844f0e0e Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 3 Feb 2026 14:50:54 +0900 Subject: [PATCH 2/3] . --- crates/fresh-editor/plugins/config-schema.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/fresh-editor/plugins/config-schema.json b/crates/fresh-editor/plugins/config-schema.json index 7c9cceeeb..ffd6e5039 100644 --- a/crates/fresh-editor/plugins/config-schema.json +++ b/crates/fresh-editor/plugins/config-schema.json @@ -48,6 +48,7 @@ "rainbow_brackets": true, "quick_suggestions": true, "quick_suggestions_delay_ms": 10, + "enable_ghost_text": true, "suggest_on_trigger_characters": true, "accept_suggestion_on_enter": "on", "enable_inlay_hints": true, @@ -308,6 +309,12 @@ "x-section": "Completion", "default": 10 }, + "enable_ghost_text": { + "description": "Show inline ghost text for the currently selected completion item.\nThis renders a dimmed suggestion directly in the buffer while the\ncompletion popup is open.\nDefault: true", + "type": "boolean", + "x-section": "Completion", + "default": true + }, "suggest_on_trigger_characters": { "description": "Whether trigger characters (like `.`, `::`, `->`) immediately show completions.\nWhen true, typing a trigger character bypasses quick_suggestions_delay_ms.\nDefault: true", "type": "boolean", From ea2e42c55f1d2e11d76e3be6378a017896244caf Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 3 Feb 2026 15:01:02 +0900 Subject: [PATCH 3/3] fix --- crates/fresh-editor/src/app/lsp_requests.rs | 5 ++--- crates/fresh-editor/src/services/lsp/manager.rs | 5 +++++ crates/fresh-editor/tests/e2e/lsp.rs | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/fresh-editor/src/app/lsp_requests.rs b/crates/fresh-editor/src/app/lsp_requests.rs index 46912d9d2..cdce9b7e8 100644 --- a/crates/fresh-editor/src/app/lsp_requests.rs +++ b/crates/fresh-editor/src/app/lsp_requests.rs @@ -88,10 +88,9 @@ impl Editor { let inline_supported = self .lsp .as_ref() - .map(|lsp| lsp.inline_completion_supported(&language)) - .unwrap_or(false); + .and_then(|lsp| lsp.inline_completion_support(&language)); - if !inline_supported { + if inline_supported == Some(false) { self.clear_ghost_text(); return Ok(()); } diff --git a/crates/fresh-editor/src/services/lsp/manager.rs b/crates/fresh-editor/src/services/lsp/manager.rs index d2fdb6979..9356dbfaa 100644 --- a/crates/fresh-editor/src/services/lsp/manager.rs +++ b/crates/fresh-editor/src/services/lsp/manager.rs @@ -156,6 +156,11 @@ impl LspManager { .unwrap_or(&false) } + /// Get inline completion support if known for a language + pub fn inline_completion_support(&self, language: &str) -> Option { + self.inline_completion_support.get(language).copied() + } + /// Store semantic token capability information for a language pub fn set_semantic_tokens_capabilities( &mut self, diff --git a/crates/fresh-editor/tests/e2e/lsp.rs b/crates/fresh-editor/tests/e2e/lsp.rs index a6532ab42..4e28cc11c 100644 --- a/crates/fresh-editor/tests/e2e/lsp.rs +++ b/crates/fresh-editor/tests/e2e/lsp.rs @@ -301,7 +301,7 @@ fn test_lsp_inline_completion_ghost_text() -> anyhow::Result<()> { harness.type_text("hel")?; - let found = harness.wait_for_async(|h| h.screen_to_string().contains("hello_world"), 1000)?; + let found = harness.wait_for_async(|h| h.screen_to_string().contains("lo_world"), 1000)?; assert!(found, "Expected inline ghost text to render"); let buffer_content = harness.get_buffer_content().unwrap();