diff --git a/Cargo.lock b/Cargo.lock
index 4969ef46..96496125 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1165,6 +1165,7 @@ version = "23.10.0"
 dependencies = [
  "dunce",
  "etcetera",
+ "ropey",
  "tempfile",
 ]
 
diff --git a/book/src/configuration.md b/book/src/configuration.md
index 36e2fee2..a43ede76 100644
--- a/book/src/configuration.md
+++ b/book/src/configuration.md
@@ -51,7 +51,8 @@ Its settings will be merged with the configuration directory `config.toml` and t
 | `auto-completion` | Enable automatic pop up of auto-completion | `true` |
 | `auto-format` | Enable automatic formatting on save | `true` |
 | `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` |
-| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `250` |
+| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` |
+| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant.  | `250` |
 | `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` |
 | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
 | `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` |
diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs
index 1af27c1d..7eef2bf7 100644
--- a/helix-lsp/src/client.rs
+++ b/helix-lsp/src/client.rs
@@ -9,7 +9,7 @@ use helix_loader::{self, VERSION_AND_GIT_HASH};
 use helix_stdx::path;
 use lsp::{
     notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport,
-    DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, WorkspaceFolder,
+    DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, SignatureHelp, WorkspaceFolder,
     WorkspaceFoldersChangeEvent,
 };
 use lsp_types as lsp;
@@ -999,6 +999,7 @@ impl Client {
         text_document: lsp::TextDocumentIdentifier,
         position: lsp::Position,
         work_done_token: Option<lsp::ProgressToken>,
+        context: lsp::CompletionContext,
     ) -> Option<impl Future<Output = Result<Value>>> {
         let capabilities = self.capabilities.get().unwrap();
 
@@ -1010,13 +1011,12 @@ impl Client {
                 text_document,
                 position,
             },
+            context: Some(context),
             // TODO: support these tokens by async receiving and updating the choice list
             work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
             partial_result_params: lsp::PartialResultParams {
                 partial_result_token: None,
             },
-            context: None,
-            // lsp::CompletionContext { trigger_kind: , trigger_character: Some(), }
         };
 
         Some(self.call::<lsp::request::Completion>(params))
@@ -1063,7 +1063,7 @@ impl Client {
         text_document: lsp::TextDocumentIdentifier,
         position: lsp::Position,
         work_done_token: Option<lsp::ProgressToken>,
-    ) -> Option<impl Future<Output = Result<Value>>> {
+    ) -> Option<impl Future<Output = Result<Option<SignatureHelp>>>> {
         let capabilities = self.capabilities.get().unwrap();
 
         // Return early if the server does not support signature help.
@@ -1079,7 +1079,8 @@ impl Client {
             // lsp::SignatureHelpContext
         };
 
-        Some(self.call::<lsp::request::SignatureHelpRequest>(params))
+        let res = self.call::<lsp::request::SignatureHelpRequest>(params);
+        Some(async move { Ok(serde_json::from_value(res.await?)?) })
     }
 
     pub fn text_document_range_inlay_hints(
diff --git a/helix-stdx/Cargo.toml b/helix-stdx/Cargo.toml
index 216a3b40..9b4de9fe 100644
--- a/helix-stdx/Cargo.toml
+++ b/helix-stdx/Cargo.toml
@@ -14,6 +14,7 @@ homepage.workspace = true
 [dependencies]
 dunce = "1.0"
 etcetera = "0.8"
+ropey = { version = "1.6.1", default-features = false }
 
 [dev-dependencies]
 tempfile = "3.9"
diff --git a/helix-stdx/src/lib.rs b/helix-stdx/src/lib.rs
index ae3c3a98..68fe3ec3 100644
--- a/helix-stdx/src/lib.rs
+++ b/helix-stdx/src/lib.rs
@@ -1,2 +1,3 @@
 pub mod env;
 pub mod path;
+pub mod rope;
diff --git a/helix-stdx/src/rope.rs b/helix-stdx/src/rope.rs
new file mode 100644
index 00000000..4ee39d4a
--- /dev/null
+++ b/helix-stdx/src/rope.rs
@@ -0,0 +1,26 @@
+use ropey::RopeSlice;
+
+pub trait RopeSliceExt: Sized {
+    fn ends_with(self, text: &str) -> bool;
+    fn starts_with(self, text: &str) -> bool;
+}
+
+impl RopeSliceExt for RopeSlice<'_> {
+    fn ends_with(self, text: &str) -> bool {
+        let len = self.len_bytes();
+        if len < text.len() {
+            return false;
+        }
+        self.get_byte_slice(len - text.len()..)
+            .map_or(false, |end| end == text)
+    }
+
+    fn starts_with(self, text: &str) -> bool {
+        let len = self.len_bytes();
+        if len < text.len() {
+            return false;
+        }
+        self.get_byte_slice(..len - text.len())
+            .map_or(false, |start| start == text)
+    }
+}
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs
index 8215eeaa..3f3e59c6 100644
--- a/helix-term/src/application.rs
+++ b/helix-term/src/application.rs
@@ -1,10 +1,6 @@
 use arc_swap::{access::Map, ArcSwap};
 use futures_util::Stream;
-use helix_core::{
-    chars::char_is_word,
-    diagnostic::{DiagnosticTag, NumberOrString},
-    pos_at_coords, syntax, Selection,
-};
+use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Selection};
 use helix_lsp::{
     lsp::{self, notification::Notification},
     util::lsp_range_to_range,
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 48ceb23b..4df3278b 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -5,7 +5,6 @@ pub(crate) mod typed;
 pub use dap::*;
 use helix_vcs::Hunk;
 pub use lsp::*;
-use tokio::sync::oneshot;
 use tui::widgets::Row;
 pub use typed::*;
 
@@ -33,7 +32,7 @@ use helix_core::{
 };
 use helix_view::{
     document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
-    editor::{Action, CompleteAction},
+    editor::Action,
     info::Info,
     input::KeyEvent,
     keyboard::KeyCode,
@@ -52,14 +51,10 @@ use crate::{
     filter_picker_entry,
     job::Callback,
     keymap::ReverseKeymap,
-    ui::{
-        self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, Picker,
-        Popup, Prompt, PromptEvent,
-    },
+    ui::{self, overlay::overlaid, Picker, Popup, Prompt, PromptEvent},
 };
 
 use crate::job::{self, Jobs};
-use futures_util::{stream::FuturesUnordered, TryStreamExt};
 use std::{
     collections::{HashMap, HashSet},
     fmt,
@@ -2593,7 +2588,6 @@ fn delete_by_selection_insert_mode(
         );
     }
     doc.apply(&transaction, view.id);
-    lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
 }
 
 fn delete_selection(cx: &mut Context) {
@@ -2667,10 +2661,6 @@ fn insert_mode(cx: &mut Context) {
         .transform(|range| Range::new(range.to(), range.from()));
 
     doc.set_selection(view.id, selection);
-
-    // [TODO] temporary workaround until we're not using the idle timer to
-    //        trigger auto completions any more
-    cx.editor.clear_idle_timer();
 }
 
 // inserts at the end of each selection
@@ -3497,9 +3487,9 @@ fn hunk_range(hunk: Hunk, text: RopeSlice) -> Range {
 
 pub mod insert {
     use crate::events::PostInsertChar;
+
     use super::*;
     pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
-    pub type PostHook = fn(&mut Context, char);
 
     /// Exclude the cursor in range.
     fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range {
@@ -3513,88 +3503,6 @@ pub mod insert {
         }
     }
 
-    // It trigger completion when idle timer reaches deadline
-    // Only trigger completion if the word under cursor is longer than n characters
-    pub fn idle_completion(cx: &mut Context) {
-        let config = cx.editor.config();
-        let (view, doc) = current!(cx.editor);
-        let text = doc.text().slice(..);
-        let cursor = doc.selection(view.id).primary().cursor(text);
-
-        use helix_core::chars::char_is_word;
-        let mut iter = text.chars_at(cursor);
-        iter.reverse();
-        for _ in 0..config.completion_trigger_len {
-            match iter.next() {
-                Some(c) if char_is_word(c) => {}
-                _ => return,
-            }
-        }
-        super::completion(cx);
-    }
-
-    fn language_server_completion(cx: &mut Context, ch: char) {
-        let config = cx.editor.config();
-        if !config.auto_completion {
-            return;
-        }
-
-        use helix_lsp::lsp;
-        // if ch matches completion char, trigger completion
-        let doc = doc_mut!(cx.editor);
-        let trigger_completion = doc
-            .language_servers_with_feature(LanguageServerFeature::Completion)
-            .any(|ls| {
-                // TODO: what if trigger is multiple chars long
-                matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
-                    trigger_characters: Some(triggers),
-                    ..
-                }) if triggers.iter().any(|trigger| trigger.contains(ch)))
-            });
-
-        if trigger_completion {
-            cx.editor.clear_idle_timer();
-            super::completion(cx);
-        }
-    }
-
-    fn signature_help(cx: &mut Context, ch: char) {
-        use helix_lsp::lsp;
-        // if ch matches signature_help char, trigger
-        let doc = doc_mut!(cx.editor);
-        // TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
-        let Some(language_server) = doc
-            .language_servers_with_feature(LanguageServerFeature::SignatureHelp)
-            .next()
-        else {
-            return;
-        };
-
-        let capabilities = language_server.capabilities();
-
-        if let lsp::ServerCapabilities {
-            signature_help_provider:
-                Some(lsp::SignatureHelpOptions {
-                    trigger_characters: Some(triggers),
-                    // TODO: retrigger_characters
-                    ..
-                }),
-            ..
-        } = capabilities
-        {
-            // TODO: what if trigger is multiple chars long
-            let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch));
-            // lsp doesn't tell us when to close the signature help, so we request
-            // the help information again after common close triggers which should
-            // return None, which in turn closes the popup.
-            let close_triggers = &[')', ';', '.'];
-
-            if is_trigger || close_triggers.contains(&ch) {
-                super::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
-            }
-        }
-    }
-
     // The default insert hook: simply insert the character
     #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature
     fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
@@ -3624,12 +3532,6 @@ pub mod insert {
             doc.apply(&t, view.id);
         }
 
-        // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc)
-        // this could also generically look at Transaction, but it's a bit annoying to look at
-        // Operation instead of Change.
-        for hook in &[language_server_completion, signature_help] {
-            hook(cx, c);
-        }
         helix_event::dispatch(PostInsertChar { c, cx });
     }
 
@@ -3855,8 +3757,6 @@ pub mod insert {
             });
         let (view, doc) = current!(cx.editor);
         doc.apply(&transaction, view.id);
-
-        lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
     }
 
     pub fn delete_char_forward(cx: &mut Context) {
@@ -4510,151 +4410,14 @@ fn remove_primary_selection(cx: &mut Context) {
 }
 
 pub fn completion(cx: &mut Context) {
-    use helix_lsp::{lsp, util::pos_to_lsp_pos};
-
     let (view, doc) = current!(cx.editor);
+    let range = doc.selection(view.id).primary();
+    let text = doc.text().slice(..);
+    let cursor = range.cursor(text);
 
-    let savepoint = if let Some(CompleteAction::Selected { savepoint }) = &cx.editor.last_completion
-    {
-        savepoint.clone()
-    } else {
-        doc.savepoint(view)
-    };
-
-    let text = savepoint.text.clone();
-    let cursor = savepoint.cursor();
-
-    let mut seen_language_servers = HashSet::new();
-
-    let mut futures: FuturesUnordered<_> = doc
-        .language_servers_with_feature(LanguageServerFeature::Completion)
-        .filter(|ls| seen_language_servers.insert(ls.id()))
-        .map(|language_server| {
-            let language_server_id = language_server.id();
-            let offset_encoding = language_server.offset_encoding();
-            let pos = pos_to_lsp_pos(&text, cursor, offset_encoding);
-            let doc_id = doc.identifier();
-            let completion_request = language_server.completion(doc_id, pos, None).unwrap();
-
-            async move {
-                let json = completion_request.await?;
-                let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
-
-                let items = match response {
-                    Some(lsp::CompletionResponse::Array(items)) => items,
-                    // TODO: do something with is_incomplete
-                    Some(lsp::CompletionResponse::List(lsp::CompletionList {
-                        is_incomplete: _is_incomplete,
-                        items,
-                    })) => items,
-                    None => Vec::new(),
-                }
-                .into_iter()
-                .map(|item| CompletionItem {
-                    item,
-                    language_server_id,
-                    resolved: false,
-                })
-                .collect();
-
-                anyhow::Ok(items)
-            }
-        })
-        .collect();
-
-    // setup a channel that allows the request to be canceled
-    let (tx, rx) = oneshot::channel();
-    // set completion_request so that this request can be canceled
-    // by setting completion_request, the old channel stored there is dropped
-    // and the associated request is automatically dropped
-    cx.editor.completion_request_handle = Some(tx);
-    let future = async move {
-        let items_future = async move {
-            let mut items = Vec::new();
-            // TODO if one completion request errors, all other completion requests are discarded (even if they're valid)
-            while let Some(mut lsp_items) = futures.try_next().await? {
-                items.append(&mut lsp_items);
-            }
-            anyhow::Ok(items)
-        };
-        tokio::select! {
-            biased;
-            _ = rx => {
-                Ok(Vec::new())
-            }
-            res = items_future => {
-                res
-            }
-        }
-    };
-
-    let trigger_offset = cursor;
-
-    // TODO: trigger_offset should be the cursor offset but we also need a starting offset from where we want to apply
-    // completion filtering. For example logger.te| should filter the initial suggestion list with "te".
-
-    use helix_core::chars;
-    let mut iter = text.chars_at(cursor);
-    iter.reverse();
-    let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count();
-    let start_offset = cursor.saturating_sub(offset);
-
-    let trigger_doc = doc.id();
-    let trigger_view = view.id;
-
-    // FIXME: The commands Context can only have a single callback
-    // which means it gets overwritten when executing keybindings
-    // with multiple commands or macros. This would mean that completion
-    // might be incorrectly applied when repeating the insertmode action
-    //
-    // TODO: to solve this either make cx.callback a Vec of callbacks or
-    // alternatively move `last_insert` to `helix_view::Editor`
-    cx.callback = Some(Box::new(
-        move |compositor: &mut Compositor, _cx: &mut compositor::Context| {
-            let ui = compositor.find::<ui::EditorView>().unwrap();
-            ui.last_insert.1.push(InsertEvent::RequestCompletion);
-        },
-    ));
-
-    cx.jobs.callback(async move {
-        let items = future.await?;
-        let call = move |editor: &mut Editor, compositor: &mut Compositor| {
-            let (view, doc) = current_ref!(editor);
-            // check if the completion request is stale.
-            //
-            // Completions are completed asynchronously and therefore the user could
-            //switch document/view or leave insert mode. In all of thoise cases the
-            // completion should be discarded
-            if editor.mode != Mode::Insert || view.id != trigger_view || doc.id() != trigger_doc {
-                return;
-            }
-
-            if items.is_empty() {
-                // editor.set_error("No completion available");
-                return;
-            }
-            let size = compositor.size();
-            let ui = compositor.find::<ui::EditorView>().unwrap();
-            let completion_area = ui.set_completion(
-                editor,
-                savepoint,
-                items,
-                start_offset,
-                trigger_offset,
-                size,
-            );
-            let size = compositor.size();
-            let signature_help_area = compositor
-                .find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
-                .map(|signature_help| signature_help.area(size, editor));
-            // Delete the signature help popup if they intersect.
-            if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b))
-            {
-                compositor.remove(SignatureHelp::ID);
-            }
-        };
-        Ok(Callback::EditorCompositor(Box::new(call)))
-    });
+    cx.editor
+        .handlers
+        .trigger_completions(cursor, doc.id(), view.id);
 }
 
 // comments
@@ -4833,10 +4596,6 @@ fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: Movement) {
             );
 
             doc.set_selection(view.id, selection);
-
-            // [TODO] temporary workaround until we're not using the idle timer to
-            //        trigger auto completions any more
-            editor.clear_idle_timer();
         }
     };
 
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
index 051cdcd3..de2f0e5e 100644
--- a/helix-term/src/commands/lsp.rs
+++ b/helix-term/src/commands/lsp.rs
@@ -1,4 +1,4 @@
-use futures_util::{future::BoxFuture, stream::FuturesUnordered, FutureExt};
+use futures_util::{stream::FuturesUnordered, FutureExt};
 use helix_lsp::{
     block_on,
     lsp::{
@@ -8,21 +8,21 @@ use helix_lsp::{
     util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range},
     Client, OffsetEncoding,
 };
-use serde_json::Value;
 use tokio_stream::StreamExt;
 use tui::{
     text::{Span, Spans},
     widgets::Row,
 };
 
-use super::{align_view, push_jump, Align, Context, Editor, Open};
+use super::{align_view, push_jump, Align, Context, Editor};
 
 use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection};
 use helix_stdx::path;
 use helix_view::{
-    document::{DocumentInlayHints, DocumentInlayHintsId, Mode},
+    document::{DocumentInlayHints, DocumentInlayHintsId},
     editor::Action,
     graphics::Margin,
+    handlers::lsp::SignatureHelpInvoked,
     theme::Style,
     Document, View,
 };
@@ -30,10 +30,7 @@ use helix_view::{
 use crate::{
     compositor::{self, Compositor},
     job::Callback,
-    ui::{
-        self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup,
-        PromptEvent,
-    },
+    ui::{self, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup, PromptEvent},
 };
 
 use std::{
@@ -42,7 +39,6 @@ use std::{
     fmt::Write,
     future::Future,
     path::PathBuf,
-    sync::Arc,
 };
 
 /// Gets the first language server that is attached to a document which supports a specific feature.
@@ -1132,146 +1128,10 @@ pub fn goto_reference(cx: &mut Context) {
     );
 }
 
-#[derive(PartialEq, Eq, Clone, Copy)]
-pub enum SignatureHelpInvoked {
-    Manual,
-    Automatic,
-}
-
 pub fn signature_help(cx: &mut Context) {
-    signature_help_impl(cx, SignatureHelpInvoked::Manual)
-}
-
-pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
-    let (view, doc) = current!(cx.editor);
-
-    // TODO merge multiple language server signature help into one instead of just taking the first language server that supports it
-    let future = doc
-        .language_servers_with_feature(LanguageServerFeature::SignatureHelp)
-        .find_map(|language_server| {
-            let pos = doc.position(view.id, language_server.offset_encoding());
-            language_server.text_document_signature_help(doc.identifier(), pos, None)
-        });
-
-    let Some(future) = future else {
-        // Do not show the message if signature help was invoked
-        // automatically on backspace, trigger characters, etc.
-        if invoked == SignatureHelpInvoked::Manual {
-            cx.editor
-                .set_error("No configured language server supports signature-help");
-        }
-        return;
-    };
-    signature_help_impl_with_future(cx, future.boxed(), invoked);
-}
-
-pub fn signature_help_impl_with_future(
-    cx: &mut Context,
-    future: BoxFuture<'static, helix_lsp::Result<Value>>,
-    invoked: SignatureHelpInvoked,
-) {
-    cx.callback(
-        future,
-        move |editor, compositor, response: Option<lsp::SignatureHelp>| {
-            let config = &editor.config();
-
-            if !(config.lsp.auto_signature_help
-                || SignatureHelp::visible_popup(compositor).is_some()
-                || invoked == SignatureHelpInvoked::Manual)
-            {
-                return;
-            }
-
-            // If the signature help invocation is automatic, don't show it outside of Insert Mode:
-            // it very probably means the server was a little slow to respond and the user has
-            // already moved on to something else, making a signature help popup will just be an
-            // annoyance, see https://github.com/helix-editor/helix/issues/3112
-            if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert {
-                return;
-            }
-
-            let response = match response {
-                // According to the spec the response should be None if there
-                // are no signatures, but some servers don't follow this.
-                Some(s) if !s.signatures.is_empty() => s,
-                _ => {
-                    compositor.remove(SignatureHelp::ID);
-                    return;
-                }
-            };
-            let doc = doc!(editor);
-            let language = doc.language_name().unwrap_or("");
-
-            let signature = match response
-                .signatures
-                .get(response.active_signature.unwrap_or(0) as usize)
-            {
-                Some(s) => s,
-                None => return,
-            };
-            let mut contents = SignatureHelp::new(
-                signature.label.clone(),
-                language.to_string(),
-                Arc::clone(&editor.syn_loader),
-            );
-
-            let signature_doc = if config.lsp.display_signature_help_docs {
-                signature.documentation.as_ref().map(|doc| match doc {
-                    lsp::Documentation::String(s) => s.clone(),
-                    lsp::Documentation::MarkupContent(markup) => markup.value.clone(),
-                })
-            } else {
-                None
-            };
-
-            contents.set_signature_doc(signature_doc);
-
-            let active_param_range = || -> Option<(usize, usize)> {
-                let param_idx = signature
-                    .active_parameter
-                    .or(response.active_parameter)
-                    .unwrap_or(0) as usize;
-                let param = signature.parameters.as_ref()?.get(param_idx)?;
-                match &param.label {
-                    lsp::ParameterLabel::Simple(string) => {
-                        let start = signature.label.find(string.as_str())?;
-                        Some((start, start + string.len()))
-                    }
-                    lsp::ParameterLabel::LabelOffsets([start, end]) => {
-                        // LS sends offsets based on utf-16 based string representation
-                        // but highlighting in helix is done using byte offset.
-                        use helix_core::str_utils::char_to_byte_idx;
-                        let from = char_to_byte_idx(&signature.label, *start as usize);
-                        let to = char_to_byte_idx(&signature.label, *end as usize);
-                        Some((from, to))
-                    }
-                }
-            };
-            contents.set_active_param_range(active_param_range());
-
-            let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
-            let mut popup = Popup::new(SignatureHelp::ID, contents)
-                .position(old_popup.and_then(|p| p.get_position()))
-                .position_bias(Open::Above)
-                .ignore_escape_key(true);
-
-            // Don't create a popup if it intersects the auto-complete menu.
-            let size = compositor.size();
-            if compositor
-                .find::<ui::EditorView>()
-                .unwrap()
-                .completion
-                .as_mut()
-                .map(|completion| completion.area(size, editor))
-                .filter(|area| area.intersects(popup.area(size, editor)))
-                .is_some()
-            {
-                return;
-            }
-
-            compositor.replace_or_push(SignatureHelp::ID, popup);
-        },
-    );
+    cx.editor
+        .handlers
+        .trigger_signature_help(SignatureHelpInvoked::Manual, cx.editor)
 }
 
 pub fn hover(cx: &mut Context) {
diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
index ab2d724f..ef5369f8 100644
--- a/helix-term/src/handlers.rs
+++ b/helix-term/src/handlers.rs
@@ -1,15 +1,30 @@
 use std::sync::Arc;
 
 use arc_swap::ArcSwap;
+use helix_event::AsyncHook;
 
 use crate::config::Config;
 use crate::events;
+use crate::handlers::completion::CompletionHandler;
+use crate::handlers::signature_help::SignatureHelpHandler;
 
+pub use completion::trigger_auto_completion;
+pub use helix_view::handlers::lsp::SignatureHelpInvoked;
+pub use helix_view::handlers::Handlers;
+
+mod completion;
+mod signature_help;
 
-    }
 pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
     events::register();
+
+    let completions = CompletionHandler::new(config).spawn();
+    let signature_hints = SignatureHelpHandler::new().spawn();
     let handlers = Handlers {
+        completions,
+        signature_hints,
     };
+    completion::register_hooks(&handlers);
+    signature_help::register_hooks(&handlers);
     handlers
 }
diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs
new file mode 100644
index 00000000..d71fd24f
--- /dev/null
+++ b/helix-term/src/handlers/completion.rs
@@ -0,0 +1,465 @@
+use std::collections::HashSet;
+use std::sync::Arc;
+use std::time::Duration;
+
+use arc_swap::ArcSwap;
+use futures_util::stream::FuturesUnordered;
+use helix_core::chars::char_is_word;
+use helix_core::syntax::LanguageServerFeature;
+use helix_event::{
+    cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
+};
+use helix_lsp::lsp;
+use helix_lsp::util::pos_to_lsp_pos;
+use helix_stdx::rope::RopeSliceExt;
+use helix_view::document::{Mode, SavePoint};
+use helix_view::handlers::lsp::CompletionEvent;
+use helix_view::{DocumentId, Editor, ViewId};
+use tokio::sync::mpsc::Sender;
+use tokio::time::Instant;
+use tokio_stream::StreamExt;
+
+use crate::commands;
+use crate::compositor::Compositor;
+use crate::config::Config;
+use crate::events::{OnModeSwitch, PostCommand, PostInsertChar};
+use crate::job::{dispatch, dispatch_blocking};
+use crate::keymap::MappableCommand;
+use crate::ui::editor::InsertEvent;
+use crate::ui::lsp::SignatureHelp;
+use crate::ui::{self, CompletionItem, Popup};
+
+use super::Handlers;
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+enum TriggerKind {
+    Auto,
+    TriggerChar,
+    Manual,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct Trigger {
+    pos: usize,
+    view: ViewId,
+    doc: DocumentId,
+    kind: TriggerKind,
+}
+
+#[derive(Debug)]
+pub(super) struct CompletionHandler {
+    /// currently active trigger which will cause a
+    /// completion request after the timeout
+    trigger: Option<Trigger>,
+    /// A handle for currently active completion request.
+    /// This can be used to determine whether the current
+    /// request is still active (and new triggers should be
+    /// ignored) and can also be used to abort the current
+    /// request (by dropping the handle)
+    request: Option<CancelTx>,
+    config: Arc<ArcSwap<Config>>,
+}
+
+impl CompletionHandler {
+    pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
+        Self {
+            config,
+            request: None,
+            trigger: None,
+        }
+    }
+}
+
+impl helix_event::AsyncHook for CompletionHandler {
+    type Event = CompletionEvent;
+
+    fn handle_event(
+        &mut self,
+        event: Self::Event,
+        _old_timeout: Option<Instant>,
+    ) -> Option<Instant> {
+        match event {
+            CompletionEvent::AutoTrigger {
+                cursor: trigger_pos,
+                doc,
+                view,
+            } => {
+                // techically it shouldn't be possible to switch views/documents in insert mode
+                // but people may create weird keymaps/use the mouse so lets be extra careful
+                if self
+                    .trigger
+                    .as_ref()
+                    .map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
+                {
+                    self.trigger = Some(Trigger {
+                        pos: trigger_pos,
+                        view,
+                        doc,
+                        kind: TriggerKind::Auto,
+                    });
+                }
+            }
+            CompletionEvent::TriggerChar { cursor, doc, view } => {
+                // immediately request completions and drop all auto completion requests
+                self.request = None;
+                self.trigger = Some(Trigger {
+                    pos: cursor,
+                    view,
+                    doc,
+                    kind: TriggerKind::TriggerChar,
+                });
+            }
+            CompletionEvent::ManualTrigger { cursor, doc, view } => {
+                // immediately request completions and drop all auto completion requests
+                self.request = None;
+                self.trigger = Some(Trigger {
+                    pos: cursor,
+                    view,
+                    doc,
+                    kind: TriggerKind::Manual,
+                });
+                // stop debouncing immediately and request the completion
+                self.finish_debounce();
+                return None;
+            }
+            CompletionEvent::Cancel => {
+                self.trigger = None;
+                self.request = None;
+            }
+            CompletionEvent::DeleteText { cursor } => {
+                // if we deleted the original trigger, abort the completion
+                if matches!(self.trigger, Some(Trigger{ pos, .. }) if cursor < pos) {
+                    self.trigger = None;
+                    self.request = None;
+                }
+            }
+        }
+        self.trigger.map(|trigger| {
+            // if the current request was closed forget about it
+            // otherwise immediately restart the completion request
+            let cancel = self.request.take().map_or(false, |req| !req.is_closed());
+            let timeout = if trigger.kind == TriggerKind::Auto && !cancel {
+                self.config.load().editor.completion_timeout
+            } else {
+                // we want almost instant completions for trigger chars
+                // and restarting completion requests. The small timeout here mainly
+                // serves to better handle cases where the completion handler
+                // may fall behind (so multiple events in the channel) and macros
+                Duration::from_millis(5)
+            };
+            Instant::now() + timeout
+        })
+    }
+
+    fn finish_debounce(&mut self) {
+        let trigger = self.trigger.take().expect("debounce always has a trigger");
+        let (tx, rx) = cancelation();
+        self.request = Some(tx);
+        dispatch_blocking(move |editor, compositor| {
+            request_completion(trigger, rx, editor, compositor)
+        });
+    }
+}
+
+fn request_completion(
+    mut trigger: Trigger,
+    cancel: CancelRx,
+    editor: &mut Editor,
+    compositor: &mut Compositor,
+) {
+    let (view, doc) = current!(editor);
+
+    if compositor
+        .find::<ui::EditorView>()
+        .unwrap()
+        .completion
+        .is_some()
+        || editor.mode != Mode::Insert
+    {
+        return;
+    }
+
+    let text = doc.text();
+    let cursor = doc.selection(view.id).primary().cursor(text.slice(..));
+    if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
+        return;
+    }
+    // this looks odd... Why are we not using the trigger position from
+    // the `trigger` here? Won't that mean that the trigger char doesn't get
+    // send to the LS if we type fast enougn? Yes that is true but it's
+    // not actually a problem. The LSP will resolve the completion to the identifier
+    // anyway (in fact sending the later position is necessary to get the right results
+    // from LSPs that provide incomplete completion list). We rely on trigger offset
+    // and primary cursor matching for multi-cursor completions so this is definitely
+    // necessary from our side too.
+    trigger.pos = cursor;
+    let trigger_text = text.slice(..cursor);
+
+    let mut seen_language_servers = HashSet::new();
+    let mut futures: FuturesUnordered<_> = doc
+        .language_servers_with_feature(LanguageServerFeature::Completion)
+        .filter(|ls| seen_language_servers.insert(ls.id()))
+        .map(|ls| {
+            let language_server_id = ls.id();
+            let offset_encoding = ls.offset_encoding();
+            let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
+            let doc_id = doc.identifier();
+            let context = if trigger.kind == TriggerKind::Manual {
+                lsp::CompletionContext {
+                    trigger_kind: lsp::CompletionTriggerKind::INVOKED,
+                    trigger_character: None,
+                }
+            } else {
+                let trigger_char =
+                    ls.capabilities()
+                        .completion_provider
+                        .as_ref()
+                        .and_then(|provider| {
+                            provider
+                                .trigger_characters
+                                .as_deref()?
+                                .iter()
+                                .find(|&trigger| trigger_text.ends_with(trigger))
+                        });
+                lsp::CompletionContext {
+                    trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
+                    trigger_character: trigger_char.cloned(),
+                }
+            };
+
+            let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
+            async move {
+                let json = completion_response.await?;
+                let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
+                let items = match response {
+                    Some(lsp::CompletionResponse::Array(items)) => items,
+                    // TODO: do something with is_incomplete
+                    Some(lsp::CompletionResponse::List(lsp::CompletionList {
+                        is_incomplete: _is_incomplete,
+                        items,
+                    })) => items,
+                    None => Vec::new(),
+                }
+                .into_iter()
+                .map(|item| CompletionItem {
+                    item,
+                    language_server_id,
+                    resolved: false,
+                })
+                .collect();
+                anyhow::Ok(items)
+            }
+        })
+        .collect();
+
+    let future = async move {
+        let mut items = Vec::new();
+        while let Some(lsp_items) = futures.next().await {
+            match lsp_items {
+                Ok(mut lsp_items) => items.append(&mut lsp_items),
+                Err(err) => {
+                    log::debug!("completion request failed: {err:?}");
+                }
+            };
+        }
+        items
+    };
+
+    let savepoint = doc.savepoint(view);
+
+    let ui = compositor.find::<ui::EditorView>().unwrap();
+    ui.last_insert.1.push(InsertEvent::RequestCompletion);
+    tokio::spawn(async move {
+        let items = cancelable_future(future, cancel).await.unwrap_or_default();
+        if items.is_empty() {
+            return;
+        }
+        dispatch(move |editor, compositor| {
+            show_completion(editor, compositor, items, trigger, savepoint)
+        })
+        .await
+    });
+}
+
+fn show_completion(
+    editor: &mut Editor,
+    compositor: &mut Compositor,
+    items: Vec<CompletionItem>,
+    trigger: Trigger,
+    savepoint: Arc<SavePoint>,
+) {
+    let (view, doc) = current_ref!(editor);
+    // check if the completion request is stale.
+    //
+    // Completions are completed asynchronously and therefore the user could
+    //switch document/view or leave insert mode. In all of thoise cases the
+    // completion should be discarded
+    if editor.mode != Mode::Insert || view.id != trigger.view || doc.id() != trigger.doc {
+        return;
+    }
+
+    let size = compositor.size();
+    let ui = compositor.find::<ui::EditorView>().unwrap();
+    if ui.completion.is_some() {
+        return;
+    }
+
+    let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size);
+    let signature_help_area = compositor
+        .find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
+        .map(|signature_help| signature_help.area(size, editor));
+    // Delete the signature help popup if they intersect.
+    if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b)) {
+        compositor.remove(SignatureHelp::ID);
+    }
+}
+
+pub fn trigger_auto_completion(
+    tx: &Sender<CompletionEvent>,
+    editor: &Editor,
+    trigger_char_only: bool,
+) {
+    let config = editor.config.load();
+    if !config.auto_completion {
+        return;
+    }
+    let (view, doc): (&helix_view::View, &helix_view::Document) = current_ref!(editor);
+    let mut text = doc.text().slice(..);
+    let cursor = doc.selection(view.id).primary().cursor(text);
+    text = doc.text().slice(..cursor);
+
+    let is_trigger_char = doc
+        .language_servers_with_feature(LanguageServerFeature::Completion)
+        .any(|ls| {
+            matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
+                        trigger_characters: Some(triggers),
+                        ..
+                    }) if triggers.iter().any(|trigger| text.ends_with(trigger)))
+        });
+    if is_trigger_char {
+        send_blocking(
+            tx,
+            CompletionEvent::TriggerChar {
+                cursor,
+                doc: doc.id(),
+                view: view.id,
+            },
+        );
+        return;
+    }
+
+    let is_auto_trigger = !trigger_char_only
+        && doc
+            .text()
+            .chars_at(cursor)
+            .reversed()
+            .take(config.completion_trigger_len as usize)
+            .all(char_is_word);
+
+    if is_auto_trigger {
+        send_blocking(
+            tx,
+            CompletionEvent::AutoTrigger {
+                cursor,
+                doc: doc.id(),
+                view: view.id,
+            },
+        );
+    }
+}
+
+fn update_completions(cx: &mut commands::Context, c: Option<char>) {
+    cx.callback.push(Box::new(move |compositor, cx| {
+        let editor_view = compositor.find::<ui::EditorView>().unwrap();
+        if let Some(completion) = &mut editor_view.completion {
+            completion.update_filter(c);
+            if completion.is_empty() {
+                editor_view.clear_completion(cx.editor);
+                // clearing completions might mean we want to immediately rerequest them (usually
+                // this occurs if typing a trigger char)
+                if c.is_some() {
+                    trigger_auto_completion(&cx.editor.handlers.completions, cx.editor, false);
+                }
+            }
+        }
+    }))
+}
+
+fn clear_completions(cx: &mut commands::Context) {
+    cx.callback.push(Box::new(|compositor, cx| {
+        let editor_view = compositor.find::<ui::EditorView>().unwrap();
+        editor_view.clear_completion(cx.editor);
+    }))
+}
+
+fn completion_post_command_hook(
+    tx: &Sender<CompletionEvent>,
+    PostCommand { command, cx }: &mut PostCommand<'_, '_>,
+) -> anyhow::Result<()> {
+    if cx.editor.mode == Mode::Insert {
+        if cx.editor.last_completion.is_some() {
+            match command {
+                MappableCommand::Static {
+                    name: "delete_word_forward" | "delete_char_forward" | "completion",
+                    ..
+                } => (),
+                MappableCommand::Static {
+                    name: "delete_char_backward",
+                    ..
+                } => update_completions(cx, None),
+                _ => clear_completions(cx),
+            }
+        } else {
+            let event = match command {
+                MappableCommand::Static {
+                    name: "delete_char_backward" | "delete_word_forward" | "delete_char_forward",
+                    ..
+                } => {
+                    let (view, doc) = current!(cx.editor);
+                    let primary_cursor = doc
+                        .selection(view.id)
+                        .primary()
+                        .cursor(doc.text().slice(..));
+                    CompletionEvent::DeleteText {
+                        cursor: primary_cursor,
+                    }
+                }
+                // hacks: some commands are handeled elsewhere and we don't want to
+                // cancel in that case
+                MappableCommand::Static {
+                    name: "completion" | "insert_mode" | "append_mode",
+                    ..
+                } => return Ok(()),
+                _ => CompletionEvent::Cancel,
+            };
+            send_blocking(tx, event);
+        }
+    }
+    Ok(())
+}
+
+pub(super) fn register_hooks(handlers: &Handlers) {
+    let tx = handlers.completions.clone();
+    register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(&tx, event));
+
+    let tx = handlers.completions.clone();
+    register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
+        if event.old_mode == Mode::Insert {
+            send_blocking(&tx, CompletionEvent::Cancel);
+            clear_completions(event.cx);
+        } else if event.new_mode == Mode::Insert {
+            trigger_auto_completion(&tx, event.cx.editor, false)
+        }
+        Ok(())
+    });
+
+    let tx = handlers.completions.clone();
+    register_hook!(move |event: &mut PostInsertChar<'_, '_>| {
+        if event.cx.editor.last_completion.is_some() {
+            update_completions(event.cx, Some(event.c))
+        } else {
+            trigger_auto_completion(&tx, event.cx.editor, false);
+        }
+        Ok(())
+    });
+}
diff --git a/helix-term/src/handlers/signature_help.rs b/helix-term/src/handlers/signature_help.rs
new file mode 100644
index 00000000..3c746548
--- /dev/null
+++ b/helix-term/src/handlers/signature_help.rs
@@ -0,0 +1,335 @@
+use std::sync::Arc;
+use std::time::Duration;
+
+use helix_core::syntax::LanguageServerFeature;
+use helix_event::{
+    cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
+};
+use helix_lsp::lsp;
+use helix_stdx::rope::RopeSliceExt;
+use helix_view::document::Mode;
+use helix_view::events::{DocumentDidChange, SelectionDidChange};
+use helix_view::handlers::lsp::{SignatureHelpEvent, SignatureHelpInvoked};
+use helix_view::Editor;
+use tokio::sync::mpsc::Sender;
+use tokio::time::Instant;
+
+use crate::commands::Open;
+use crate::compositor::Compositor;
+use crate::events::{OnModeSwitch, PostInsertChar};
+use crate::handlers::Handlers;
+use crate::ui::lsp::SignatureHelp;
+use crate::ui::Popup;
+use crate::{job, ui};
+
+#[derive(Debug)]
+enum State {
+    Open,
+    Closed,
+    Pending { request: CancelTx },
+}
+
+/// debounce timeout in ms, value taken from VSCode
+/// TODO: make this configurable?
+const TIMEOUT: u64 = 120;
+
+#[derive(Debug)]
+pub(super) struct SignatureHelpHandler {
+    trigger: Option<SignatureHelpInvoked>,
+    state: State,
+}
+
+impl SignatureHelpHandler {
+    pub fn new() -> SignatureHelpHandler {
+        SignatureHelpHandler {
+            trigger: None,
+            state: State::Closed,
+        }
+    }
+}
+
+impl helix_event::AsyncHook for SignatureHelpHandler {
+    type Event = SignatureHelpEvent;
+
+    fn handle_event(
+        &mut self,
+        event: Self::Event,
+        timeout: Option<tokio::time::Instant>,
+    ) -> Option<Instant> {
+        match event {
+            SignatureHelpEvent::Invoked => {
+                self.trigger = Some(SignatureHelpInvoked::Manual);
+                self.state = State::Closed;
+                self.finish_debounce();
+                return None;
+            }
+            SignatureHelpEvent::Trigger => {}
+            SignatureHelpEvent::ReTrigger => {
+                // don't retrigger if we aren't open/pending yet
+                if matches!(self.state, State::Closed) {
+                    return timeout;
+                }
+            }
+            SignatureHelpEvent::Cancel => {
+                self.state = State::Closed;
+                return None;
+            }
+            SignatureHelpEvent::RequestComplete { open } => {
+                // don't cancel rerequest that was already triggered
+                if let State::Pending { request } = &self.state {
+                    if !request.is_closed() {
+                        return timeout;
+                    }
+                }
+                self.state = if open { State::Open } else { State::Closed };
+                return timeout;
+            }
+        }
+        if self.trigger.is_none() {
+            self.trigger = Some(SignatureHelpInvoked::Automatic)
+        }
+        Some(Instant::now() + Duration::from_millis(TIMEOUT))
+    }
+
+    fn finish_debounce(&mut self) {
+        let invocation = self.trigger.take().unwrap();
+        let (tx, rx) = cancelation();
+        self.state = State::Pending { request: tx };
+        job::dispatch_blocking(move |editor, _| request_signature_help(editor, invocation, rx))
+    }
+}
+
+pub fn request_signature_help(
+    editor: &mut Editor,
+    invoked: SignatureHelpInvoked,
+    cancel: CancelRx,
+) {
+    let (view, doc) = current!(editor);
+
+    // TODO merge multiple language server signature help into one instead of just taking the first language server that supports it
+    let future = doc
+        .language_servers_with_feature(LanguageServerFeature::SignatureHelp)
+        .find_map(|language_server| {
+            let pos = doc.position(view.id, language_server.offset_encoding());
+            language_server.text_document_signature_help(doc.identifier(), pos, None)
+        });
+
+    let Some(future) = future else {
+        // Do not show the message if signature help was invoked
+        // automatically on backspace, trigger characters, etc.
+        if invoked == SignatureHelpInvoked::Manual {
+            editor
+                .set_error("No configured language server supports signature-help");
+        }
+        return;
+    };
+
+    tokio::spawn(async move {
+        match cancelable_future(future, cancel).await {
+            Some(Ok(res)) => {
+                job::dispatch(move |editor, compositor| {
+                    show_signature_help(editor, compositor, invoked, res)
+                })
+                .await
+            }
+            Some(Err(err)) => log::error!("signature help request failed: {err}"),
+            None => (),
+        }
+    });
+}
+
+pub fn show_signature_help(
+    editor: &mut Editor,
+    compositor: &mut Compositor,
+    invoked: SignatureHelpInvoked,
+    response: Option<lsp::SignatureHelp>,
+) {
+    let config = &editor.config();
+
+    if !(config.lsp.auto_signature_help
+        || SignatureHelp::visible_popup(compositor).is_some()
+        || invoked == SignatureHelpInvoked::Manual)
+    {
+        return;
+    }
+
+    // If the signature help invocation is automatic, don't show it outside of Insert Mode:
+    // it very probably means the server was a little slow to respond and the user has
+    // already moved on to something else, making a signature help popup will just be an
+    // annoyance, see https://github.com/helix-editor/helix/issues/3112
+    // For the most part this should not be needed as the request gets canceled automatically now
+    // but it's technically possible for the mode change to just preempt this callback so better safe than sorry
+    if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert {
+        return;
+    }
+
+    let response = match response {
+        // According to the spec the response should be None if there
+        // are no signatures, but some servers don't follow this.
+        Some(s) if !s.signatures.is_empty() => s,
+        _ => {
+            send_blocking(
+                &editor.handlers.signature_hints,
+                SignatureHelpEvent::RequestComplete { open: false },
+            );
+            compositor.remove(SignatureHelp::ID);
+            return;
+        }
+    };
+    send_blocking(
+        &editor.handlers.signature_hints,
+        SignatureHelpEvent::RequestComplete { open: true },
+    );
+
+    let doc = doc!(editor);
+    let language = doc.language_name().unwrap_or("");
+
+    let signature = match response
+        .signatures
+        .get(response.active_signature.unwrap_or(0) as usize)
+    {
+        Some(s) => s,
+        None => return,
+    };
+    let mut contents = SignatureHelp::new(
+        signature.label.clone(),
+        language.to_string(),
+        Arc::clone(&editor.syn_loader),
+    );
+
+    let signature_doc = if config.lsp.display_signature_help_docs {
+        signature.documentation.as_ref().map(|doc| match doc {
+            lsp::Documentation::String(s) => s.clone(),
+            lsp::Documentation::MarkupContent(markup) => markup.value.clone(),
+        })
+    } else {
+        None
+    };
+
+    contents.set_signature_doc(signature_doc);
+
+    let active_param_range = || -> Option<(usize, usize)> {
+        let param_idx = signature
+            .active_parameter
+            .or(response.active_parameter)
+            .unwrap_or(0) as usize;
+        let param = signature.parameters.as_ref()?.get(param_idx)?;
+        match &param.label {
+            lsp::ParameterLabel::Simple(string) => {
+                let start = signature.label.find(string.as_str())?;
+                Some((start, start + string.len()))
+            }
+            lsp::ParameterLabel::LabelOffsets([start, end]) => {
+                // LS sends offsets based on utf-16 based string representation
+                // but highlighting in helix is done using byte offset.
+                use helix_core::str_utils::char_to_byte_idx;
+                let from = char_to_byte_idx(&signature.label, *start as usize);
+                let to = char_to_byte_idx(&signature.label, *end as usize);
+                Some((from, to))
+            }
+        }
+    };
+    contents.set_active_param_range(active_param_range());
+
+    let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
+    let mut popup = Popup::new(SignatureHelp::ID, contents)
+        .position(old_popup.and_then(|p| p.get_position()))
+        .position_bias(Open::Above)
+        .ignore_escape_key(true);
+
+    // Don't create a popup if it intersects the auto-complete menu.
+    let size = compositor.size();
+    if compositor
+        .find::<ui::EditorView>()
+        .unwrap()
+        .completion
+        .as_mut()
+        .map(|completion| completion.area(size, editor))
+        .filter(|area| area.intersects(popup.area(size, editor)))
+        .is_some()
+    {
+        return;
+    }
+
+    compositor.replace_or_push(SignatureHelp::ID, popup);
+}
+
+fn signature_help_post_insert_char_hook(
+    tx: &Sender<SignatureHelpEvent>,
+    PostInsertChar { cx, .. }: &mut PostInsertChar<'_, '_>,
+) -> anyhow::Result<()> {
+    if !cx.editor.config().lsp.auto_signature_help {
+        return Ok(());
+    }
+    let (view, doc) = current!(cx.editor);
+    // TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
+    let Some(language_server) = doc
+            .language_servers_with_feature(LanguageServerFeature::SignatureHelp)
+            .next()
+        else {
+            return Ok(());
+        };
+
+    let capabilities = language_server.capabilities();
+
+    if let lsp::ServerCapabilities {
+        signature_help_provider:
+            Some(lsp::SignatureHelpOptions {
+                trigger_characters: Some(triggers),
+                // TODO: retrigger_characters
+                ..
+            }),
+        ..
+    } = capabilities
+    {
+        let mut text = doc.text().slice(..);
+        let cursor = doc.selection(view.id).primary().cursor(text);
+        text = text.slice(..cursor);
+        if triggers.iter().any(|trigger| text.ends_with(trigger)) {
+            send_blocking(tx, SignatureHelpEvent::Trigger)
+        }
+    }
+    Ok(())
+}
+
+pub(super) fn register_hooks(handlers: &Handlers) {
+    let tx = handlers.signature_hints.clone();
+    register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
+        match (event.old_mode, event.new_mode) {
+            (Mode::Insert, _) => {
+                send_blocking(&tx, SignatureHelpEvent::Cancel);
+                event.cx.callback.push(Box::new(|compositor, _| {
+                    compositor.remove(SignatureHelp::ID);
+                }));
+            }
+            (_, Mode::Insert) => {
+                if event.cx.editor.config().lsp.auto_signature_help {
+                    send_blocking(&tx, SignatureHelpEvent::Trigger);
+                }
+            }
+            _ => (),
+        }
+        Ok(())
+    });
+
+    let tx = handlers.signature_hints.clone();
+    register_hook!(
+        move |event: &mut PostInsertChar<'_, '_>| signature_help_post_insert_char_hook(&tx, event)
+    );
+
+    let tx = handlers.signature_hints.clone();
+    register_hook!(move |event: &mut DocumentDidChange<'_>| {
+        if event.doc.config.load().lsp.auto_signature_help {
+            send_blocking(&tx, SignatureHelpEvent::ReTrigger);
+        }
+        Ok(())
+    });
+
+    let tx = handlers.signature_hints.clone();
+    register_hook!(move |event: &mut SelectionDidChange<'_>| {
+        if event.doc.config.load().lsp.auto_signature_help {
+            send_blocking(&tx, SignatureHelpEvent::ReTrigger);
+        }
+        Ok(())
+    });
+}
diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs
index 7c6a0055..48d97fbd 100644
--- a/helix-term/src/ui/completion.rs
+++ b/helix-term/src/ui/completion.rs
@@ -1,8 +1,12 @@
-use crate::compositor::{Component, Context, Event, EventResult};
+use crate::{
+    compositor::{Component, Context, Event, EventResult},
+    handlers::trigger_auto_completion,
+};
 use helix_view::{
     document::SavePoint,
     editor::CompleteAction,
     graphics::Margin,
+    handlers::lsp::SignatureHelpInvoked,
     theme::{Modifier, Style},
     ViewId,
 };
@@ -10,7 +14,7 @@ use tui::{buffer::Buffer as Surface, text::Span};
 
 use std::{borrow::Cow, sync::Arc};
 
-use helix_core::{Change, Transaction};
+use helix_core::{chars, Change, Transaction};
 use helix_view::{graphics::Rect, Document, Editor};
 
 use crate::commands;
@@ -95,10 +99,9 @@ pub struct CompletionItem {
 /// Wraps a Menu.
 pub struct Completion {
     popup: Popup<Menu<CompletionItem>>,
-    start_offset: usize,
     #[allow(dead_code)]
     trigger_offset: usize,
-    // TODO: maintain a completioncontext with trigger kind & trigger char
+    filter: String,
 }
 
 impl Completion {
@@ -108,7 +111,6 @@ impl Completion {
         editor: &Editor,
         savepoint: Arc<SavePoint>,
         mut items: Vec<CompletionItem>,
-        start_offset: usize,
         trigger_offset: usize,
     ) -> Self {
         let preview_completion_insert = editor.config().preview_completion_insert;
@@ -246,7 +248,7 @@ impl Completion {
                     // (also without sending the transaction to the LS) *before any further transaction is applied*.
                     // Otherwise incremental sync breaks (since the state of the LS doesn't match the state the transaction
                     // is applied to).
-                    if editor.last_completion.is_none() {
+                    if matches!(editor.last_completion, Some(CompleteAction::Triggered)) {
                         editor.last_completion = Some(CompleteAction::Selected {
                             savepoint: doc.savepoint(view),
                         })
@@ -324,8 +326,18 @@ impl Completion {
                             doc.apply(&transaction, view.id);
                         }
                     }
+                    // we could have just inserted a trigger char (like a `crate::` completion for rust
+                    // so we want to retrigger immediately when accepting a completion.
+                    trigger_auto_completion(&editor.handlers.completions, editor, true);
                 }
             };
+
+            // In case the popup was deleted because of an intersection w/ the auto-complete menu.
+            if event != PromptEvent::Update {
+                editor
+                    .handlers
+                    .trigger_signature_help(SignatureHelpInvoked::Automatic, editor);
+            }
         });
 
         let margin = if editor.menu_border() {
@@ -339,14 +351,30 @@ impl Completion {
             .ignore_escape_key(true)
             .margin(margin);
 
+        let (view, doc) = current_ref!(editor);
+        let text = doc.text().slice(..);
+        let cursor = doc.selection(view.id).primary().cursor(text);
+        let offset = text
+            .chars_at(cursor)
+            .reversed()
+            .take_while(|ch| chars::char_is_word(*ch))
+            .count();
+        let start_offset = cursor.saturating_sub(offset);
+
+        let fragment = doc.text().slice(start_offset..cursor);
         let mut completion = Self {
             popup,
-            start_offset,
             trigger_offset,
+            // TODO: expand nucleo api to allow moving straight to a Utf32String here
+            // and avoid allocation during matching
+            filter: String::from(fragment),
         };
 
         // need to recompute immediately in case start_offset != trigger_offset
-        completion.recompute_filter(editor);
+        completion
+            .popup
+            .contents_mut()
+            .score(&completion.filter, false);
 
         completion
     }
@@ -366,39 +394,22 @@ impl Completion {
         }
     }
 
-    pub fn recompute_filter(&mut self, editor: &Editor) {
+    /// Appends (`c: Some(c)`) or removes (`c: None`) a character to/from the filter
+    /// this should be called whenever the user types or deletes a character in insert mode.
+    pub fn update_filter(&mut self, c: Option<char>) {
         // recompute menu based on matches
         let menu = self.popup.contents_mut();
-        let (view, doc) = current_ref!(editor);
-
-        // cx.hooks()
-        // cx.add_hook(enum type,  ||)
-        // cx.trigger_hook(enum type, &str, ...) <-- there has to be enough to identify doc/view
-        // callback with editor & compositor
-        //
-        // trigger_hook sends event into channel, that's consumed in the global loop and
-        // triggers all registered callbacks
-        // TODO: hooks should get processed immediately so maybe do it after select!(), before
-        // looping?
-
-        let cursor = doc
-            .selection(view.id)
-            .primary()
-            .cursor(doc.text().slice(..));
-        if self.trigger_offset <= cursor {
-            let fragment = doc.text().slice(self.start_offset..cursor);
-            let text = Cow::from(fragment);
-            // TODO: logic is same as ui/picker
-            menu.score(&text);
-        } else {
-            // we backspaced before the start offset, clear the menu
-            // this will cause the editor to remove the completion popup
-            menu.clear();
+        match c {
+            Some(c) => self.filter.push(c),
+            None => {
+                self.filter.pop();
+                if self.filter.is_empty() {
+                    menu.clear();
+                    return;
+                }
+            }
         }
-    }
-
-    pub fn update(&mut self, cx: &mut commands::Context) {
-        self.recompute_filter(cx.editor)
+        menu.score(&self.filter, c.is_some());
     }
 
     pub fn is_empty(&self) -> bool {
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 9f186d14..fef62a29 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -1,7 +1,6 @@
 use crate::{
     commands::{self, OnKeyCallback},
     compositor::{Component, Context, Event, EventResult},
-    job::{self, Callback},
     events::{OnModeSwitch, PostCommand},
     key,
     keymap::{KeymapResult, Keymaps},
@@ -34,8 +33,8 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
 
 use tui::{buffer::Buffer as Surface, text::Span};
 
+use super::document::LineDecoration;
 use super::{completion::CompletionItem, statusline};
-use super::{document::LineDecoration, lsp::SignatureHelp};
 
 pub struct EditorView {
     pub keymaps: Keymaps,
@@ -837,11 +836,8 @@ impl EditorView {
         let mut execute_command = |command: &commands::MappableCommand| {
             command.execute(cxt);
             helix_event::dispatch(PostCommand { command, cx: cxt });
+
             let current_mode = cxt.editor.mode();
-            match (last_mode, current_mode) {
-                (Mode::Normal, Mode::Insert) => {
-                    // HAXX: if we just entered insert mode from normal, clear key buf
-                    // and record the command that got us into this mode.
             if current_mode != last_mode {
                 helix_event::dispatch(OnModeSwitch {
                     old_mode: last_mode,
@@ -849,29 +845,16 @@ impl EditorView {
                     cx: cxt,
                 });
 
+                // HAXX: if we just entered insert mode from normal, clear key buf
+                // and record the command that got us into this mode.
+                if current_mode == Mode::Insert {
                     // how we entered insert mode is important, and we should track that so
                     // we can repeat the side effect.
                     self.last_insert.0 = command.clone();
                     self.last_insert.1.clear();
-
-                    commands::signature_help_impl(cxt, commands::SignatureHelpInvoked::Automatic);
                 }
-                (Mode::Insert, Mode::Normal) => {
-                    // if exiting insert mode, remove completion
-                    self.clear_completion(cxt.editor);
-                    cxt.editor.completion_request_handle = None;
-
-                    // TODO: Use an on_mode_change hook to remove signature help
-                    cxt.jobs.callback(async {
-                        let call: job::Callback =
-                            Callback::EditorCompositor(Box::new(|_editor, compositor| {
-                                compositor.remove(SignatureHelp::ID);
-                            }));
-                        Ok(call)
-                    });
-                }
-                _ => (),
             }
+
             last_mode = current_mode;
         };
 
@@ -999,12 +982,10 @@ impl EditorView {
         editor: &mut Editor,
         savepoint: Arc<SavePoint>,
         items: Vec<CompletionItem>,
-        start_offset: usize,
         trigger_offset: usize,
         size: Rect,
     ) -> Option<Rect> {
-        let mut completion =
-            Completion::new(editor, savepoint, items, start_offset, trigger_offset);
+        let mut completion = Completion::new(editor, savepoint, items, trigger_offset);
 
         if completion.is_empty() {
             // skip if we got no completion results
@@ -1025,6 +1006,7 @@ impl EditorView {
         self.completion = None;
         if let Some(last_completion) = editor.last_completion.take() {
             match last_completion {
+                CompleteAction::Triggered => (),
                 CompleteAction::Applied {
                     trigger_offset,
                     changes,
@@ -1038,9 +1020,6 @@ impl EditorView {
                 }
             }
         }
-
-        // Clear any savepoints
-        editor.clear_idle_timer(); // don't retrigger
     }
 
     pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult {
@@ -1054,13 +1033,7 @@ impl EditorView {
             };
         }
 
-        if cx.editor.mode != Mode::Insert || !cx.editor.config().auto_completion {
-            return EventResult::Ignored(None);
-        }
-
-        crate::commands::insert::idle_completion(cx);
-
-        EventResult::Consumed(None)
+        EventResult::Ignored(None)
     }
 }
 
@@ -1346,12 +1319,6 @@ impl Component for EditorView {
                                     if callback.is_some() {
                                         // assume close_fn
                                         self.clear_completion(cx.editor);
-
-                                        // In case the popup was deleted because of an intersection w/ the auto-complete menu.
-                                        commands::signature_help_impl(
-                                            &mut cx,
-                                            commands::SignatureHelpInvoked::Automatic,
-                                        );
                                     }
                                 }
                             }
@@ -1362,14 +1329,6 @@ impl Component for EditorView {
 
                                 // record last_insert key
                                 self.last_insert.1.push(InsertEvent::Key(key));
-
-                                // lastly we recalculate completion
-                                if let Some(completion) = &mut self.completion {
-                                    completion.update(&mut cx);
-                                    if completion.is_empty() {
-                                        self.clear_completion(cx.editor);
-                                    }
-                                }
                             }
                         }
                         mode => self.command_mode(mode, &mut cx, key),
diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs
index 0ee64ce9..64127e3a 100644
--- a/helix-term/src/ui/menu.rs
+++ b/helix-term/src/ui/menu.rs
@@ -96,20 +96,34 @@ impl<T: Item> Menu<T> {
         }
     }
 
-    pub fn score(&mut self, pattern: &str) {
-        // reuse the matches allocation
-        self.matches.clear();
+    pub fn score(&mut self, pattern: &str, incremental: bool) {
         let mut matcher = MATCHER.lock();
         matcher.config = Config::DEFAULT;
         let pattern = Atom::new(pattern, CaseMatching::Ignore, AtomKind::Fuzzy, false);
         let mut buf = Vec::new();
-        let matches = self.options.iter().enumerate().filter_map(|(i, option)| {
-            let text = option.filter_text(&self.editor_data);
-            pattern
-                .score(Utf32Str::new(&text, &mut buf), &mut matcher)
-                .map(|score| (i as u32, score as u32))
-        });
-        self.matches.extend(matches);
+        if incremental {
+            self.matches.retain_mut(|(index, score)| {
+                let option = &self.options[*index as usize];
+                let text = option.filter_text(&self.editor_data);
+                let new_score = pattern.score(Utf32Str::new(&text, &mut buf), &mut matcher);
+                match new_score {
+                    Some(new_score) => {
+                        *score = new_score as u32;
+                        true
+                    }
+                    None => false,
+                }
+            })
+        } else {
+            self.matches.clear();
+            let matches = self.options.iter().enumerate().filter_map(|(i, option)| {
+                let text = option.filter_text(&self.editor_data);
+                pattern
+                    .score(Utf32Str::new(&text, &mut buf), &mut matcher)
+                    .map(|score| (i as u32, score as u32))
+            });
+            self.matches.extend(matches);
+        }
         self.matches
             .sort_unstable_by_key(|&(i, score)| (Reverse(score), i));
 
diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
index 93b83da4..388810b1 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -115,19 +115,6 @@ pub struct SavePoint {
     /// The view this savepoint is associated with
     pub view: ViewId,
     revert: Mutex<Transaction>,
-    pub text: Rope,
-}
-
-impl SavePoint {
-    pub fn cursor(&self) -> usize {
-        // we always create transactions with selections
-        self.revert
-            .lock()
-            .selection()
-            .unwrap()
-            .primary()
-            .cursor(self.text.slice(..))
-    }
 }
 
 pub struct Document {
@@ -1404,7 +1391,6 @@ impl Document {
         let savepoint = Arc::new(SavePoint {
             view: view.id,
             revert: Mutex::new(revert),
-            text: self.text.clone(),
         });
         self.savepoints.push(Arc::downgrade(&savepoint));
         savepoint
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index 44c706d7..dc10a604 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -31,10 +31,7 @@ use std::{
 };
 
 use tokio::{
-    sync::{
-        mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
-        oneshot,
-    },
+    sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
     time::{sleep, Duration, Instant, Sleep},
 };
 
@@ -244,12 +241,19 @@ pub struct Config {
     /// Set a global text_width
     pub text_width: usize,
     /// Time in milliseconds since last keypress before idle timers trigger.
-    /// Used for autocompletion, set to 0 for instant. Defaults to 250ms.
+    /// Used for various UI timeouts. Defaults to 250ms.
     #[serde(
         serialize_with = "serialize_duration_millis",
         deserialize_with = "deserialize_duration_millis"
     )]
     pub idle_timeout: Duration,
+    /// Time in milliseconds after typing a word character before auto completions
+    /// are shown, set to 5 for instant. Defaults to 250ms.
+    #[serde(
+        serialize_with = "serialize_duration_millis",
+        deserialize_with = "deserialize_duration_millis"
+    )]
+    pub completion_timeout: Duration,
     /// Whether to insert the completion suggestion on hover. Defaults to true.
     pub preview_completion_insert: bool,
     pub completion_trigger_len: u8,
@@ -829,6 +833,7 @@ impl Default for Config {
             auto_format: true,
             auto_save: false,
             idle_timeout: Duration::from_millis(250),
+            completion_timeout: Duration::from_millis(250),
             preview_completion_insert: true,
             completion_trigger_len: 2,
             auto_info: true,
@@ -953,14 +958,6 @@ pub struct Editor {
     /// avoid calculating the cursor position multiple
     /// times during rendering and should not be set by other functions.
     pub cursor_cache: Cell<Option<Option<Position>>>,
-    /// When a new completion request is sent to the server old
-    /// unfinished request must be dropped. Each completion
-    /// request is associated with a channel that cancels
-    /// when the channel is dropped. That channel is stored
-    /// here. When a new completion request is sent this
-    /// field is set and any old requests are automatically
-    /// canceled as a result
-    pub completion_request_handle: Option<oneshot::Sender<()>>,
     pub handlers: Handlers,
 }
 
@@ -989,13 +986,16 @@ enum ThemeAction {
 
 #[derive(Debug, Clone)]
 pub enum CompleteAction {
+    Triggered,
+    /// A savepoint of the currently selected completion. The savepoint
+    /// MUST be restored before sending any event to the LSP
+    Selected {
+        savepoint: Arc<SavePoint>,
+    },
     Applied {
         trigger_offset: usize,
         changes: Vec<Change>,
     },
-    /// A savepoint of the currently selected completion. The savepoint
-    /// MUST be restored before sending any event to the LSP
-    Selected { savepoint: Arc<SavePoint> },
 }
 
 #[derive(Debug, Copy, Clone)]
@@ -1029,6 +1029,7 @@ impl Editor {
         theme_loader: Arc<theme::Loader>,
         syn_loader: Arc<syntax::Loader>,
         config: Arc<dyn DynAccess<Config>>,
+        handlers: Handlers,
     ) -> Self {
         let language_servers = helix_lsp::Registry::new(syn_loader.clone());
         let conf = config.load();
@@ -1073,7 +1074,7 @@ impl Editor {
             config_events: unbounded_channel(),
             needs_redraw: false,
             cursor_cache: Cell::new(None),
-            completion_request_handle: None,
+            handlers,
         }
     }
 
diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs
index ae3eb545..724e7b19 100644
--- a/helix-view/src/handlers.rs
+++ b/helix-view/src/handlers.rs
@@ -1,12 +1,41 @@
-use std::sync::Arc;
-
 use helix_event::send_blocking;
 use tokio::sync::mpsc::Sender;
 
 use crate::handlers::lsp::SignatureHelpInvoked;
-use crate::Editor;
+use crate::{DocumentId, Editor, ViewId};
 
 pub mod dap;
 pub mod lsp;
 
-pub struct Handlers {}
+pub struct Handlers {
+    // only public because most of the actual implementation is in helix-term right now :/
+    pub completions: Sender<lsp::CompletionEvent>,
+    pub signature_hints: Sender<lsp::SignatureHelpEvent>,
+}
+
+impl Handlers {
+    /// Manually trigger completion (c-x)
+    pub fn trigger_completions(&self, trigger_pos: usize, doc: DocumentId, view: ViewId) {
+        send_blocking(
+            &self.completions,
+            lsp::CompletionEvent::ManualTrigger {
+                cursor: trigger_pos,
+                doc,
+                view,
+            },
+        );
+    }
+
+    pub fn trigger_signature_help(&self, invocation: SignatureHelpInvoked, editor: &Editor) {
+        let event = match invocation {
+            SignatureHelpInvoked::Automatic => {
+                if !editor.config().lsp.auto_signature_help {
+                    return;
+                }
+                lsp::SignatureHelpEvent::Trigger
+            }
+            SignatureHelpInvoked::Manual => lsp::SignatureHelpEvent::Invoked,
+        };
+        send_blocking(&self.signature_hints, event)
+    }
+}
diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs
index 95838564..1dae45dd 100644
--- a/helix-view/src/handlers/lsp.rs
+++ b/helix-view/src/handlers/lsp.rs
@@ -1,26 +1,27 @@
 use crate::{DocumentId, ViewId};
 
-#[derive(Debug, Clone, Copy)]
-pub struct CompletionTrigger {
-    /// The char position of the primary cursor when the
-    /// completion was triggered
-    pub trigger_pos: usize,
-    pub doc: DocumentId,
-    pub view: ViewId,
-    /// Whether the cause of the trigger was an automatic completion (any word
-    /// char for words longer than minimum word length).
-    /// This is false for trigger chars send by the LS
-    pub auto: bool,
-}
-
 pub enum CompletionEvent {
     /// Auto completion was triggered by typing a word char
-    /// or a completion trigger
-    Trigger(CompletionTrigger),
+    AutoTrigger {
+        cursor: usize,
+        doc: DocumentId,
+        view: ViewId,
+    },
+    /// Auto completion was triggered by typing a trigger char
+    /// specified by the LSP
+    TriggerChar {
+        cursor: usize,
+        doc: DocumentId,
+        view: ViewId,
+    },
     /// A completion was manually requested (c-x)
-    Manual,
+    ManualTrigger {
+        cursor: usize,
+        doc: DocumentId,
+        view: ViewId,
+    },
     /// Some text was deleted and the cursor is now at `pos`
-    DeleteText { pos: usize },
+    DeleteText { cursor: usize },
     /// Invalidate the current auto completion trigger
     Cancel,
 }