editor: Completions receive an empty or document-wide `trigger_character`

Author: li-yangbinCreated Sep 15, 2026Updated Sep 15, 2026

Description

Two defects in how the editor measures the completion prefix (InputBaseState::<EditorMode>::handle_completion_trigger) both land in the same place: the string handed to CompletionProvider::completions as trigger_character is wrong.

  1. A replacement is measured from range.end. The code uses let start = range.end; as "where the text just typed begins". That only holds for a collapsed caret. When a keystroke replaces a span — typing over a selection, or an IME commit — the new text begins at range.start, so an equally long replacement measures an empty prefix (start == new_offset), while a longer one is dropped before the provider is called at all (new_offset < start takes the guard below).
  2. trigger_start_offset is a latch that is never invalidated. Once written it is reused by every later keystroke. Nothing clears it — not a deletion, not hide_context_menu() (which is what the arrow-key / click / dismiss paths call), not a document switch. So typing a single character while the caret sits at offset 0 latches it to Some(0), and every subsequent keystroke anywhere in the document measures text[0..caret].

Both are easy to hit in practice: a placeholder typed over a selection on Windows, and the first character any IME user types.

Environment

  • GPUI: gpui-pre v0.3.5
  • GPUI Component: v0.6.1 (9031c5e1)
  • Platform: Windows 11, core.autocrlf=true

Steps to Reproduce

First make the prefix visible — the example silently gives up on an empty prefix, so print what the provider actually receives in examples/editor/src/main.rs:

rust
let trigger_character = trigger.trigger_character.unwrap_or_default();
if dbg!(&trigger_character).is_empty() {
    return Task::ready(Ok(CompletionResponse::Array(vec![])));
}

A. Stale latch — the whole document becomes the prefix

  1. cargo run -p example-editor
  2. Click at the very start of the document (offset 0).
  3. Type x, then Backspace. The document is byte-identical to how it started.
  4. Double-click an empty line and type a.
  5. The provider is handed everything from offset 0 up to the caret, and the menu never appears:
&trigger_character = "use serde::{Deserialize, Serialize};\r\nuse std::collections::HashMap;\r\nuse std::time::Duration;\r\nuse tokio::time;\r\na"

B. Replacement measured from range.end — the prefix comes out empty

  1. cargo run -p example-editor
  2. Double-click an empty line in the fixture (double-click selects the line break).
  3. Type a.
  4. The provider is handed "". Deleting and retyping works, because by then the caret is collapsed — which is why this reads as "the first keystroke after a click never completes, the second one does".

This one is line-ending sensitive: examples/editor/fixtures/test.rs is LF in the repo, but a core.autocrlf=true checkout turns it into CRLF, and the "word" select_word picks on a blank line is then the single \r — exactly 1 byte, the same length as the typed character, so the query collapses to empty. With LF the same click selects 3 bytes instead and the request is silently dropped by the guard.

C. Same root cause, every IME commit on Windows

gpui-pre-windows delivers a composition commit as replace_text_in_range(None, text) while ime_marked_range is still set, so range degenerates to the marked span. Every committed character is therefore an equal-length replacement:

[F_ime_commit_a] trigger_character = ""
[F_ime_commit_b] trigger_character = ""   (and the previous prefix is gone)

Screenshots

N/A — the printed output above is the complete evidence.

Expected

trigger_character is the completion prefix under the caret: the run of text being typed, starting where that run starts (or where the completion in progress was anchored), on the current line only.

Actual

Either "", or everything from offset 0 up to the caret — providers either bail out, or receive the whole document as a trigger character.

Code

crates/base/src/input/editor/lsp/completions.rs at v0.6.1 / 9031c5e1:

Line Code Problem
141 let start = range.end; wrong for a replacement, right only for a collapsed caret
152-153 .trigger_start_offset.unwrap_or(start) trusts the latch blindly and ignores this edit's own start
154-156 if new_offset < start_offset { return; } returns without clearing the latch
167-170 ...trigger_start_offset = Some(start_offset); writes the latch, from the very first keystroke on
226-231 hide_context_menu() sets open = false but keeps both the latch and query

The field itself is crates/base/src/input/editor/lsp/overlay.rs:10; its only other reader is crates/component/src/input/overlay.rs (completion_start).

Suggested fix

The prefix start has to come from this edit, and the latch may only survive while the edit stays inside the span it recorded:

  • start the measured prefix at range.start, not range.end;
  • store the whole span rather than a lone start (Option<Range<usize>>), so "still typing the same word" becomes decidable: an edit whose range.start falls inside the span continues it, anything else re-anchors at the edit;
  • clear the span when the caret leaves the word (hide_context_menu(), which covers arrow keys, clicks and dismissal), on a non-trigger, on an unanchored deletion, and when the measured query contains a newline.

Source: longbridge/gpui-component