diff --git a/helix-dap/src/client.rs b/helix-dap/src/client.rs
index 9498c64c..371cf303 100644
--- a/helix-dap/src/client.rs
+++ b/helix-dap/src/client.rs
@@ -34,7 +34,7 @@ pub struct Client {
     pub caps: Option<DebuggerCapabilities>,
     // thread_id -> frames
     pub stack_frames: HashMap<ThreadId, Vec<StackFrame>>,
-    pub thread_states: HashMap<ThreadId, String>,
+    pub thread_states: ThreadStates,
     pub thread_id: Option<ThreadId>,
     /// Currently active frame for the current thread.
     pub active_frame: Option<usize>,
diff --git a/helix-dap/src/types.rs b/helix-dap/src/types.rs
index 2c3df9c3..fd8456a4 100644
--- a/helix-dap/src/types.rs
+++ b/helix-dap/src/types.rs
@@ -14,6 +14,8 @@ impl std::fmt::Display for ThreadId {
     }
 }
 
+pub type ThreadStates = HashMap<ThreadId, String>;
+
 pub trait Request {
     type Arguments: serde::de::DeserializeOwned + serde::Serialize;
     type Result: serde::de::DeserializeOwned + serde::Serialize;
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 59ca2e3b..df4867fc 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -45,6 +45,7 @@ use movement::Movement;
 use crate::{
     args,
     compositor::{self, Component, Compositor},
+    keymap::ReverseKeymap,
     ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent},
 };
 
@@ -1744,8 +1745,42 @@ fn search_selection(cx: &mut Context) {
 }
 
 fn global_search(cx: &mut Context) {
-    let (all_matches_sx, all_matches_rx) =
-        tokio::sync::mpsc::unbounded_channel::<(usize, PathBuf)>();
+    #[derive(Debug)]
+    struct FileResult {
+        path: PathBuf,
+        /// 0 indexed lines
+        line_num: usize,
+    }
+
+    impl FileResult {
+        fn new(path: &Path, line_num: usize) -> Self {
+            Self {
+                path: path.to_path_buf(),
+                line_num,
+            }
+        }
+    }
+
+    impl ui::menu::Item for FileResult {
+        type Data = Option<PathBuf>;
+
+        fn label(&self, current_path: &Self::Data) -> Spans {
+            let relative_path = helix_core::path::get_relative_path(&self.path)
+                .to_string_lossy()
+                .into_owned();
+            if current_path
+                .as_ref()
+                .map(|p| p == &self.path)
+                .unwrap_or(false)
+            {
+                format!("{} (*)", relative_path).into()
+            } else {
+                relative_path.into()
+            }
+        }
+    }
+
+    let (all_matches_sx, all_matches_rx) = tokio::sync::mpsc::unbounded_channel::<FileResult>();
     let config = cx.editor.config();
     let smart_case = config.search.smart_case;
     let file_picker_config = config.file_picker.clone();
@@ -1809,7 +1844,7 @@ fn global_search(cx: &mut Context) {
                                 entry.path(),
                                 sinks::UTF8(|line_num, _| {
                                     all_matches_sx
-                                        .send((line_num as usize - 1, entry.path().to_path_buf()))
+                                        .send(FileResult::new(entry.path(), line_num as usize - 1))
                                         .unwrap();
 
                                     Ok(true)
@@ -1836,7 +1871,7 @@ fn global_search(cx: &mut Context) {
     let current_path = doc_mut!(cx.editor).path().cloned();
 
     let show_picker = async move {
-        let all_matches: Vec<(usize, PathBuf)> =
+        let all_matches: Vec<FileResult> =
             UnboundedReceiverStream::new(all_matches_rx).collect().await;
         let call: job::Callback =
             Box::new(move |editor: &mut Editor, compositor: &mut Compositor| {
@@ -1847,17 +1882,8 @@ fn global_search(cx: &mut Context) {
 
                 let picker = FilePicker::new(
                     all_matches,
-                    move |(_line_num, path)| {
-                        let relative_path = helix_core::path::get_relative_path(path)
-                            .to_string_lossy()
-                            .into_owned();
-                        if current_path.as_ref().map(|p| p == path).unwrap_or(false) {
-                            format!("{} (*)", relative_path).into()
-                        } else {
-                            relative_path.into()
-                        }
-                    },
-                    move |cx, (line_num, path), action| {
+                    current_path,
+                    move |cx, FileResult { path, line_num }, action| {
                         match cx.editor.open(path, action) {
                             Ok(_) => {}
                             Err(e) => {
@@ -1879,7 +1905,9 @@ fn global_search(cx: &mut Context) {
                         doc.set_selection(view.id, Selection::single(start, end));
                         align_view(doc, view, Align::Center);
                     },
-                    |_editor, (line_num, path)| Some((path.clone(), Some((*line_num, *line_num)))),
+                    |_editor, FileResult { path, line_num }| {
+                        Some((path.clone(), Some((*line_num, *line_num))))
+                    },
                 );
                 compositor.push(Box::new(overlayed(picker)));
             });
@@ -2172,8 +2200,10 @@ fn buffer_picker(cx: &mut Context) {
         is_current: bool,
     }
 
-    impl BufferMeta {
-        fn format(&self) -> Spans {
+    impl ui::menu::Item for BufferMeta {
+        type Data = ();
+
+        fn label(&self, _data: &Self::Data) -> Spans {
             let path = self
                 .path
                 .as_deref()
@@ -2213,7 +2243,7 @@ fn buffer_picker(cx: &mut Context) {
             .iter()
             .map(|(_, doc)| new_meta(doc))
             .collect(),
-        BufferMeta::format,
+        (),
         |cx, meta, action| {
             cx.editor.switch(meta.id, action);
         },
@@ -2230,6 +2260,38 @@ fn buffer_picker(cx: &mut Context) {
     cx.push_layer(Box::new(overlayed(picker)));
 }
 
+impl ui::menu::Item for MappableCommand {
+    type Data = ReverseKeymap;
+
+    fn label(&self, keymap: &Self::Data) -> Spans {
+        // formats key bindings, multiple bindings are comma separated,
+        // individual key presses are joined with `+`
+        let fmt_binding = |bindings: &Vec<Vec<KeyEvent>>| -> String {
+            bindings
+                .iter()
+                .map(|bind| {
+                    bind.iter()
+                        .map(|key| key.to_string())
+                        .collect::<Vec<String>>()
+                        .join("+")
+                })
+                .collect::<Vec<String>>()
+                .join(", ")
+        };
+
+        match self {
+            MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) {
+                Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(),
+                None => doc.as_str().into(),
+            },
+            MappableCommand::Static { doc, name, .. } => match keymap.get(*name) {
+                Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(),
+                None => (*doc).into(),
+            },
+        }
+    }
+}
+
 pub fn command_palette(cx: &mut Context) {
     cx.callback = Some(Box::new(
         move |compositor: &mut Compositor, cx: &mut compositor::Context| {
@@ -2246,44 +2308,17 @@ pub fn command_palette(cx: &mut Context) {
                 }
             }));
 
-            // formats key bindings, multiple bindings are comma separated,
-            // individual key presses are joined with `+`
-            let fmt_binding = |bindings: &Vec<Vec<KeyEvent>>| -> String {
-                bindings
-                    .iter()
-                    .map(|bind| {
-                        bind.iter()
-                            .map(|key| key.key_sequence_format())
-                            .collect::<String>()
-                    })
-                    .collect::<Vec<String>>()
-                    .join(", ")
-            };
-
-            let picker = Picker::new(
-                commands,
-                move |command| match command {
-                    MappableCommand::Typable { doc, name, .. } => match keymap.get(name) {
-                        Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(),
-                        None => doc.as_str().into(),
-                    },
-                    MappableCommand::Static { doc, name, .. } => match keymap.get(*name) {
-                        Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(),
-                        None => (*doc).into(),
-                    },
-                },
-                move |cx, command, _action| {
-                    let mut ctx = Context {
-                        register: None,
-                        count: std::num::NonZeroUsize::new(1),
-                        editor: cx.editor,
-                        callback: None,
-                        on_next_key_callback: None,
-                        jobs: cx.jobs,
-                    };
-                    command.execute(&mut ctx);
-                },
-            );
+            let picker = Picker::new(commands, keymap, move |cx, command, _action| {
+                let mut ctx = Context {
+                    register: None,
+                    count: std::num::NonZeroUsize::new(1),
+                    editor: cx.editor,
+                    callback: None,
+                    on_next_key_callback: None,
+                    jobs: cx.jobs,
+                };
+                command.execute(&mut ctx);
+            });
             compositor.push(Box::new(overlayed(picker)));
         },
     ));
diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs
index b897b2d5..9f6f4c15 100644
--- a/helix-term/src/commands/dap.rs
+++ b/helix-term/src/commands/dap.rs
@@ -4,13 +4,15 @@ use crate::{
     job::{Callback, Jobs},
     ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent, Text},
 };
-use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion};
+use dap::{StackFrame, Thread, ThreadStates};
+use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate};
 use helix_dap::{self as dap, Client};
 use helix_lsp::block_on;
 use helix_view::editor::Breakpoint;
 
 use serde_json::{to_value, Value};
 use tokio_stream::wrappers::UnboundedReceiverStream;
+use tui::text::Spans;
 
 use std::collections::HashMap;
 use std::future::Future;
@@ -20,6 +22,38 @@ use anyhow::{anyhow, bail};
 
 use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select_thread_id};
 
+impl ui::menu::Item for StackFrame {
+    type Data = ();
+
+    fn label(&self, _data: &Self::Data) -> Spans {
+        self.name.as_str().into() // TODO: include thread_states in the label
+    }
+}
+
+impl ui::menu::Item for DebugTemplate {
+    type Data = ();
+
+    fn label(&self, _data: &Self::Data) -> Spans {
+        self.name.as_str().into()
+    }
+}
+
+impl ui::menu::Item for Thread {
+    type Data = ThreadStates;
+
+    fn label(&self, thread_states: &Self::Data) -> Spans {
+        format!(
+            "{} ({})",
+            self.name,
+            thread_states
+                .get(&self.id)
+                .map(|state| state.as_str())
+                .unwrap_or("unknown")
+        )
+        .into()
+    }
+}
+
 fn thread_picker(
     cx: &mut Context,
     callback_fn: impl Fn(&mut Editor, &dap::Thread) + Send + 'static,
@@ -41,17 +75,7 @@ fn thread_picker(
             let thread_states = debugger.thread_states.clone();
             let picker = FilePicker::new(
                 threads,
-                move |thread| {
-                    format!(
-                        "{} ({})",
-                        thread.name,
-                        thread_states
-                            .get(&thread.id)
-                            .map(|state| state.as_str())
-                            .unwrap_or("unknown")
-                    )
-                    .into()
-                },
+                thread_states,
                 move |cx, thread, _action| callback_fn(cx.editor, thread),
                 move |editor, thread| {
                     let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?;
@@ -243,7 +267,7 @@ pub fn dap_launch(cx: &mut Context) {
 
     cx.push_layer(Box::new(overlayed(Picker::new(
         templates,
-        |template| template.name.as_str().into(),
+        (),
         |cx, template, _action| {
             let completions = template.completion.clone();
             let name = template.name.clone();
@@ -475,7 +499,7 @@ pub fn dap_variables(cx: &mut Context) {
 
     for scope in scopes.iter() {
         // use helix_view::graphics::Style;
-        use tui::text::{Span, Spans};
+        use tui::text::Span;
         let response = block_on(debugger.variables(scope.variables_reference));
 
         variables.push(Spans::from(Span::styled(
@@ -652,7 +676,7 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
 
     let picker = FilePicker::new(
         frames,
-        |frame| frame.name.as_str().into(), // TODO: include thread_states in the label
+        (),
         move |cx, frame, _action| {
             let debugger = debugger!(cx.editor);
             // TODO: this should be simpler to find
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
index d11c44cd..7f82394a 100644
--- a/helix-term/src/commands/lsp.rs
+++ b/helix-term/src/commands/lsp.rs
@@ -19,7 +19,8 @@ use crate::{
     ui::{self, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent},
 };
 
-use std::{borrow::Cow, collections::BTreeMap};
+use std::collections::BTreeMap;
+use std::{borrow::Cow, path::PathBuf};
 
 /// Gets the language server that is attached to a document, and
 /// if it's not active displays a status message. Using this macro
@@ -39,6 +40,112 @@ macro_rules! language_server {
     };
 }
 
+impl ui::menu::Item for lsp::Location {
+    /// Current working directory.
+    type Data = PathBuf;
+
+    fn label(&self, cwdir: &Self::Data) -> Spans {
+        let file: Cow<'_, str> = (self.uri.scheme() == "file")
+            .then(|| {
+                self.uri
+                    .to_file_path()
+                    .map(|path| {
+                        // strip root prefix
+                        path.strip_prefix(&cwdir)
+                            .map(|path| path.to_path_buf())
+                            .unwrap_or(path)
+                    })
+                    .map(|path| Cow::from(path.to_string_lossy().into_owned()))
+                    .ok()
+            })
+            .flatten()
+            .unwrap_or_else(|| self.uri.as_str().into());
+        let line = self.range.start.line;
+        format!("{}:{}", file, line).into()
+    }
+}
+
+impl ui::menu::Item for lsp::SymbolInformation {
+    /// Path to currently focussed document
+    type Data = Option<lsp::Url>;
+
+    fn label(&self, current_doc_path: &Self::Data) -> Spans {
+        if current_doc_path.as_ref() == Some(&self.location.uri) {
+            self.name.as_str().into()
+        } else {
+            match self.location.uri.to_file_path() {
+                Ok(path) => {
+                    let relative_path = helix_core::path::get_relative_path(path.as_path())
+                        .to_string_lossy()
+                        .into_owned();
+                    format!("{} ({})", &self.name, relative_path).into()
+                }
+                Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(),
+            }
+        }
+    }
+}
+
+struct DiagnosticStyles {
+    hint: Style,
+    info: Style,
+    warning: Style,
+    error: Style,
+}
+
+struct PickerDiagnostic {
+    url: lsp::Url,
+    diag: lsp::Diagnostic,
+}
+
+impl ui::menu::Item for PickerDiagnostic {
+    type Data = DiagnosticStyles;
+
+    fn label(&self, styles: &Self::Data) -> Spans {
+        let mut style = self
+            .diag
+            .severity
+            .map(|s| match s {
+                DiagnosticSeverity::HINT => styles.hint,
+                DiagnosticSeverity::INFORMATION => styles.info,
+                DiagnosticSeverity::WARNING => styles.warning,
+                DiagnosticSeverity::ERROR => styles.error,
+                _ => Style::default(),
+            })
+            .unwrap_or_default();
+
+        // remove background as it is distracting in the picker list
+        style.bg = None;
+
+        let code = self
+            .diag
+            .code
+            .as_ref()
+            .map(|c| match c {
+                NumberOrString::Number(n) => n.to_string(),
+                NumberOrString::String(s) => s.to_string(),
+            })
+            .unwrap_or_default();
+
+        let truncated_path = path::get_truncated_path(self.url.path())
+            .to_string_lossy()
+            .into_owned();
+
+        Spans::from(vec![
+            Span::styled(
+                self.diag.source.clone().unwrap_or_default(),
+                style.add_modifier(Modifier::BOLD),
+            ),
+            Span::raw(": "),
+            Span::styled(truncated_path, style),
+            Span::raw(" - "),
+            Span::styled(code, style.add_modifier(Modifier::BOLD)),
+            Span::raw(": "),
+            Span::styled(&self.diag.message, style),
+        ])
+    }
+}
+
 fn location_to_file_location(location: &lsp::Location) -> FileLocation {
     let path = location.uri.to_file_path().unwrap();
     let line = Some((
@@ -93,29 +200,14 @@ fn sym_picker(
     offset_encoding: OffsetEncoding,
 ) -> FilePicker<lsp::SymbolInformation> {
     // TODO: drop current_path comparison and instead use workspace: bool flag?
-    let current_path2 = current_path.clone();
     FilePicker::new(
         symbols,
-        move |symbol| {
-            if current_path.as_ref() == Some(&symbol.location.uri) {
-                symbol.name.as_str().into()
-            } else {
-                match symbol.location.uri.to_file_path() {
-                    Ok(path) => {
-                        let relative_path = helix_core::path::get_relative_path(path.as_path())
-                            .to_string_lossy()
-                            .into_owned();
-                        format!("{} ({})", &symbol.name, relative_path).into()
-                    }
-                    Err(_) => format!("{} ({})", &symbol.name, &symbol.location.uri).into(),
-                }
-            }
-        },
+        current_path.clone(),
         move |cx, symbol, action| {
             let (view, doc) = current!(cx.editor);
             push_jump(view, doc);
 
-            if current_path2.as_ref() != Some(&symbol.location.uri) {
+            if current_path.as_ref() != Some(&symbol.location.uri) {
                 let uri = &symbol.location.uri;
                 let path = match uri.to_file_path() {
                     Ok(path) => path,
@@ -155,7 +247,7 @@ fn diag_picker(
     diagnostics: BTreeMap<lsp::Url, Vec<lsp::Diagnostic>>,
     current_path: Option<lsp::Url>,
     offset_encoding: OffsetEncoding,
-) -> FilePicker<(lsp::Url, lsp::Diagnostic)> {
+) -> FilePicker<PickerDiagnostic> {
     // TODO: drop current_path comparison and instead use workspace: bool flag?
 
     // flatten the map to a vec of (url, diag) pairs
@@ -163,59 +255,24 @@ fn diag_picker(
     for (url, diags) in diagnostics {
         flat_diag.reserve(diags.len());
         for diag in diags {
-            flat_diag.push((url.clone(), diag));
+            flat_diag.push(PickerDiagnostic {
+                url: url.clone(),
+                diag,
+            });
         }
     }
 
-    let hint = cx.editor.theme.get("hint");
-    let info = cx.editor.theme.get("info");
-    let warning = cx.editor.theme.get("warning");
-    let error = cx.editor.theme.get("error");
+    let styles = DiagnosticStyles {
+        hint: cx.editor.theme.get("hint"),
+        info: cx.editor.theme.get("info"),
+        warning: cx.editor.theme.get("warning"),
+        error: cx.editor.theme.get("error"),
+    };
 
     FilePicker::new(
         flat_diag,
-        move |(url, diag)| {
-            let mut style = diag
-                .severity
-                .map(|s| match s {
-                    DiagnosticSeverity::HINT => hint,
-                    DiagnosticSeverity::INFORMATION => info,
-                    DiagnosticSeverity::WARNING => warning,
-                    DiagnosticSeverity::ERROR => error,
-                    _ => Style::default(),
-                })
-                .unwrap_or_default();
-
-            // remove background as it is distracting in the picker list
-            style.bg = None;
-
-            let code = diag
-                .code
-                .as_ref()
-                .map(|c| match c {
-                    NumberOrString::Number(n) => n.to_string(),
-                    NumberOrString::String(s) => s.to_string(),
-                })
-                .unwrap_or_default();
-
-            let truncated_path = path::get_truncated_path(url.path())
-                .to_string_lossy()
-                .into_owned();
-
-            Spans::from(vec![
-                Span::styled(
-                    diag.source.clone().unwrap_or_default(),
-                    style.add_modifier(Modifier::BOLD),
-                ),
-                Span::raw(": "),
-                Span::styled(truncated_path, style),
-                Span::raw(" - "),
-                Span::styled(code, style.add_modifier(Modifier::BOLD)),
-                Span::raw(": "),
-                Span::styled(&diag.message, style),
-            ])
-        },
-        move |cx, (url, diag), action| {
+        styles,
+        move |cx, PickerDiagnostic { url, diag }, action| {
             if current_path.as_ref() == Some(url) {
                 let (view, doc) = current!(cx.editor);
                 push_jump(view, doc);
@@ -233,7 +290,7 @@ fn diag_picker(
                 align_view(doc, view, Align::Center);
             }
         },
-        move |_editor, (url, diag)| {
+        move |_editor, PickerDiagnostic { url, diag }| {
             let location = lsp::Location::new(url.clone(), diag.range);
             Some(location_to_file_location(&location))
         },
@@ -343,10 +400,11 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) {
 }
 
 impl ui::menu::Item for lsp::CodeActionOrCommand {
-    fn label(&self) -> &str {
+    type Data = ();
+    fn label(&self, _data: &Self::Data) -> Spans {
         match self {
-            lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str(),
-            lsp::CodeActionOrCommand::Command(command) => command.title.as_str(),
+            lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(),
+            lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(),
         }
     }
 }
@@ -391,7 +449,7 @@ pub fn code_action(cx: &mut Context) {
                 return;
             }
 
-            let mut picker = ui::Menu::new(actions, move |editor, code_action, event| {
+            let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| {
                 if event != PromptEvent::Validate {
                     return;
                 }
@@ -619,6 +677,7 @@ pub fn apply_workspace_edit(
         }
     }
 }
+
 fn goto_impl(
     editor: &mut Editor,
     compositor: &mut Compositor,
@@ -637,26 +696,7 @@ fn goto_impl(
         _locations => {
             let picker = FilePicker::new(
                 locations,
-                move |location| {
-                    let file: Cow<'_, str> = (location.uri.scheme() == "file")
-                        .then(|| {
-                            location
-                                .uri
-                                .to_file_path()
-                                .map(|path| {
-                                    // strip root prefix
-                                    path.strip_prefix(&cwdir)
-                                        .map(|path| path.to_path_buf())
-                                        .unwrap_or(path)
-                                })
-                                .map(|path| Cow::from(path.to_string_lossy().into_owned()))
-                                .ok()
-                        })
-                        .flatten()
-                        .unwrap_or_else(|| location.uri.as_str().into());
-                    let line = location.range.start.line;
-                    format!("{}:{}", file, line).into()
-                },
+                cwdir,
                 move |cx, location, action| {
                     jump_to_location(cx.editor, location, offset_encoding, action)
                 },
diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs
index db958833..59204889 100644
--- a/helix-term/src/keymap.rs
+++ b/helix-term/src/keymap.rs
@@ -208,18 +208,17 @@ pub struct Keymap {
     root: KeyTrie,
 }
 
+/// A map of command names to keybinds that will execute the command.
+pub type ReverseKeymap = HashMap<String, Vec<Vec<KeyEvent>>>;
+
 impl Keymap {
     pub fn new(root: KeyTrie) -> Self {
         Keymap { root }
     }
 
-    pub fn reverse_map(&self) -> HashMap<String, Vec<Vec<KeyEvent>>> {
+    pub fn reverse_map(&self) -> ReverseKeymap {
         // recursively visit all nodes in keymap
-        fn map_node(
-            cmd_map: &mut HashMap<String, Vec<Vec<KeyEvent>>>,
-            node: &KeyTrie,
-            keys: &mut Vec<KeyEvent>,
-        ) {
+        fn map_node(cmd_map: &mut ReverseKeymap, node: &KeyTrie, keys: &mut Vec<KeyEvent>) {
             match node {
                 KeyTrie::Leaf(cmd) => match cmd {
                     MappableCommand::Typable { name, .. } => {
diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs
index 38005aad..a3637415 100644
--- a/helix-term/src/ui/completion.rs
+++ b/helix-term/src/ui/completion.rs
@@ -2,6 +2,7 @@ use crate::compositor::{Component, Context, EventResult};
 use crossterm::event::{Event, KeyCode, KeyEvent};
 use helix_view::editor::CompleteAction;
 use tui::buffer::Buffer as Surface;
+use tui::text::Spans;
 
 use std::borrow::Cow;
 
@@ -15,19 +16,25 @@ use helix_lsp::{lsp, util};
 use lsp::CompletionItem;
 
 impl menu::Item for CompletionItem {
-    fn sort_text(&self) -> &str {
-        self.filter_text.as_ref().unwrap_or(&self.label).as_str()
+    type Data = ();
+    fn sort_text(&self, data: &Self::Data) -> Cow<str> {
+        self.filter_text(data)
     }
 
-    fn filter_text(&self) -> &str {
-        self.filter_text.as_ref().unwrap_or(&self.label).as_str()
+    #[inline]
+    fn filter_text(&self, _data: &Self::Data) -> Cow<str> {
+        self.filter_text
+            .as_ref()
+            .unwrap_or(&self.label)
+            .as_str()
+            .into()
     }
 
-    fn label(&self) -> &str {
-        self.label.as_str()
+    fn label(&self, _data: &Self::Data) -> Spans {
+        self.label.as_str().into()
     }
 
-    fn row(&self) -> menu::Row {
+    fn row(&self, _data: &Self::Data) -> menu::Row {
         menu::Row::new(vec![
             menu::Cell::from(self.label.as_str()),
             menu::Cell::from(match self.kind {
@@ -85,7 +92,7 @@ impl Completion {
         start_offset: usize,
         trigger_offset: usize,
     ) -> Self {
-        let menu = Menu::new(items, move |editor: &mut Editor, item, event| {
+        let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
             fn item_to_transaction(
                 doc: &Document,
                 item: &CompletionItem,
diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs
index 0519374a..6bb64139 100644
--- a/helix-term/src/ui/menu.rs
+++ b/helix-term/src/ui/menu.rs
@@ -1,9 +1,11 @@
+use std::{borrow::Cow, path::PathBuf};
+
 use crate::{
     compositor::{Callback, Component, Compositor, Context, EventResult},
     ctrl, key, shift,
 };
 use crossterm::event::Event;
-use tui::{buffer::Buffer as Surface, widgets::Table};
+use tui::{buffer::Buffer as Surface, text::Spans, widgets::Table};
 
 pub use tui::widgets::{Cell, Row};
 
@@ -14,22 +16,41 @@ use helix_view::{graphics::Rect, Editor};
 use tui::layout::Constraint;
 
 pub trait Item {
-    fn label(&self) -> &str;
+    /// Additional editor state that is used for label calculation.
+    type Data;
 
-    fn sort_text(&self) -> &str {
-        self.label()
-    }
-    fn filter_text(&self) -> &str {
-        self.label()
+    fn label(&self, data: &Self::Data) -> Spans;
+
+    fn sort_text(&self, data: &Self::Data) -> Cow<str> {
+        let label: String = self.label(data).into();
+        label.into()
     }
 
-    fn row(&self) -> Row {
-        Row::new(vec![Cell::from(self.label())])
+    fn filter_text(&self, data: &Self::Data) -> Cow<str> {
+        let label: String = self.label(data).into();
+        label.into()
+    }
+
+    fn row(&self, data: &Self::Data) -> Row {
+        Row::new(vec![Cell::from(self.label(data))])
+    }
+}
+
+impl Item for PathBuf {
+    /// Root prefix to strip.
+    type Data = PathBuf;
+
+    fn label(&self, root_path: &Self::Data) -> Spans {
+        self.strip_prefix(&root_path)
+            .unwrap_or(self)
+            .to_string_lossy()
+            .into()
     }
 }
 
 pub struct Menu<T: Item> {
     options: Vec<T>,
+    editor_data: T::Data,
 
     cursor: Option<usize>,
 
@@ -54,10 +75,12 @@ impl<T: Item> Menu<T> {
     // rendering)
     pub fn new(
         options: Vec<T>,
+        editor_data: <T as Item>::Data,
         callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static,
     ) -> Self {
         let mut menu = Self {
             options,
+            editor_data,
             matcher: Box::new(Matcher::default()),
             matches: Vec::new(),
             cursor: None,
@@ -83,16 +106,17 @@ impl<T: Item> Menu<T> {
                 .iter()
                 .enumerate()
                 .filter_map(|(index, option)| {
-                    let text = option.filter_text();
+                    let text: String = option.filter_text(&self.editor_data).into();
                     // TODO: using fuzzy_indices could give us the char idx for match highlighting
                     self.matcher
-                        .fuzzy_match(text, pattern)
+                        .fuzzy_match(&text, pattern)
                         .map(|score| (index, score))
                 }),
         );
         // matches.sort_unstable_by_key(|(_, score)| -score);
-        self.matches
-            .sort_unstable_by_key(|(index, _score)| self.options[*index].sort_text());
+        self.matches.sort_unstable_by_key(|(index, _score)| {
+            self.options[*index].sort_text(&self.editor_data)
+        });
 
         // reset cursor position
         self.cursor = None;
@@ -127,10 +151,10 @@ impl<T: Item> Menu<T> {
         let n = self
             .options
             .first()
-            .map(|option| option.row().cells.len())
+            .map(|option| option.row(&self.editor_data).cells.len())
             .unwrap_or_default();
         let max_lens = self.options.iter().fold(vec![0; n], |mut acc, option| {
-            let row = option.row();
+            let row = option.row(&self.editor_data);
             // maintain max for each column
             for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) {
                 let width = cell.content.width();
@@ -300,7 +324,7 @@ impl<T: Item + 'static> Component for Menu<T> {
         let scroll_line = (win_height - scroll_height) * scroll
             / std::cmp::max(1, len.saturating_sub(win_height));
 
-        let rows = options.iter().map(|option| option.row());
+        let rows = options.iter().map(|option| option.row(&self.editor_data));
         let table = Table::new(rows)
             .style(style)
             .highlight_style(selected)
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index 948a5f2b..8d2bd325 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -23,8 +23,6 @@ pub use text::Text;
 use helix_core::regex::Regex;
 use helix_core::regex::RegexBuilder;
 use helix_view::{Document, Editor, View};
-use tui;
-use tui::text::Spans;
 
 use std::path::PathBuf;
 
@@ -172,10 +170,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
 
     FilePicker::new(
         files,
-        move |path: &PathBuf| {
-            // format_fn
-            Spans::from(path.strip_prefix(&root).unwrap_or(path).to_string_lossy())
-        },
+        root,
         move |cx, path: &PathBuf, action| {
             if let Err(e) = cx.editor.open(path, action) {
                 let err = if let Some(err) = e.source() {
diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs
index 1581b0a1..01fea718 100644
--- a/helix-term/src/ui/picker.rs
+++ b/helix-term/src/ui/picker.rs
@@ -6,7 +6,6 @@ use crate::{
 use crossterm::event::Event;
 use tui::{
     buffer::Buffer as Surface,
-    text::Spans,
     widgets::{Block, BorderType, Borders},
 };
 
@@ -30,6 +29,8 @@ use helix_view::{
     Document, Editor,
 };
 
+use super::menu::Item;
+
 pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72;
 /// Biggest file size to preview in bytes
 pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024;
@@ -37,7 +38,7 @@ pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024;
 /// File path and range of lines (used to align and highlight lines)
 pub type FileLocation = (PathBuf, Option<(usize, usize)>);
 
-pub struct FilePicker<T> {
+pub struct FilePicker<T: Item> {
     picker: Picker<T>,
     pub truncate_start: bool,
     /// Caches paths to documents
@@ -84,15 +85,15 @@ impl Preview<'_, '_> {
     }
 }
 
-impl<T> FilePicker<T> {
+impl<T: Item> FilePicker<T> {
     pub fn new(
         options: Vec<T>,
-        format_fn: impl Fn(&T) -> Spans + 'static,
+        editor_data: T::Data,
         callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
         preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
     ) -> Self {
         let truncate_start = true;
-        let mut picker = Picker::new(options, format_fn, callback_fn);
+        let mut picker = Picker::new(options, editor_data, callback_fn);
         picker.truncate_start = truncate_start;
 
         Self {
@@ -163,7 +164,7 @@ impl<T> FilePicker<T> {
     }
 }
 
-impl<T: 'static> Component for FilePicker<T> {
+impl<T: Item + 'static> Component for FilePicker<T> {
     fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
         // +---------+ +---------+
         // |prompt   | |preview  |
@@ -280,8 +281,9 @@ impl<T: 'static> Component for FilePicker<T> {
     }
 }
 
-pub struct Picker<T> {
+pub struct Picker<T: Item> {
     options: Vec<T>,
+    editor_data: T::Data,
     // filter: String,
     matcher: Box<Matcher>,
     /// (index, score)
@@ -299,14 +301,13 @@ pub struct Picker<T> {
     /// Whether to truncate the start (default true)
     pub truncate_start: bool,
 
-    format_fn: Box<dyn Fn(&T) -> Spans>,
     callback_fn: Box<dyn Fn(&mut Context, &T, Action)>,
 }
 
-impl<T> Picker<T> {
+impl<T: Item> Picker<T> {
     pub fn new(
         options: Vec<T>,
-        format_fn: impl Fn(&T) -> Spans + 'static,
+        editor_data: T::Data,
         callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
     ) -> Self {
         let prompt = Prompt::new(
@@ -318,6 +319,7 @@ impl<T> Picker<T> {
 
         let mut picker = Self {
             options,
+            editor_data,
             matcher: Box::new(Matcher::default()),
             matches: Vec::new(),
             filters: Vec::new(),
@@ -325,7 +327,6 @@ impl<T> Picker<T> {
             prompt,
             previous_pattern: String::new(),
             truncate_start: true,
-            format_fn: Box::new(format_fn),
             callback_fn: Box::new(callback_fn),
             completion_height: 0,
         };
@@ -371,9 +372,9 @@ impl<T> Picker<T> {
             #[allow(unstable_name_collisions)]
             self.matches.retain_mut(|(index, score)| {
                 let option = &self.options[*index];
-                // TODO: maybe using format_fn isn't the best idea here
-                let line: String = (self.format_fn)(option).into();
-                match self.matcher.fuzzy_match(&line, pattern) {
+                let text = option.sort_text(&self.editor_data);
+
+                match self.matcher.fuzzy_match(&text, pattern) {
                     Some(s) => {
                         // Update the score
                         *score = s;
@@ -399,11 +400,10 @@ impl<T> Picker<T> {
                             self.filters.binary_search(&index).ok()?;
                         }
 
-                        // TODO: maybe using format_fn isn't the best idea here
-                        let line: String = (self.format_fn)(option).into();
+                        let text = option.filter_text(&self.editor_data);
 
                         self.matcher
-                            .fuzzy_match(&line, pattern)
+                            .fuzzy_match(&text, pattern)
                             .map(|score| (index, score))
                     }),
             );
@@ -477,7 +477,7 @@ impl<T> Picker<T> {
 // - on input change:
 //  - score all the names in relation to input
 
-impl<T: 'static> Component for Picker<T> {
+impl<T: Item + 'static> Component for Picker<T> {
     fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
         self.completion_height = viewport.1.saturating_sub(4);
         Some(viewport)
@@ -610,7 +610,7 @@ impl<T: 'static> Component for Picker<T> {
                 surface.set_string(inner.x.saturating_sub(2), inner.y + i as u16, ">", selected);
             }
 
-            let spans = (self.format_fn)(option);
+            let spans = option.label(&self.editor_data);
             let (_score, highlights) = self
                 .matcher
                 .fuzzy_indices(&String::from(&spans), self.prompt.line())
diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs
index b4278c86..602090e5 100644
--- a/helix-tui/src/text.rs
+++ b/helix-tui/src/text.rs
@@ -402,6 +402,12 @@ impl<'a> From<&'a str> for Text<'a> {
     }
 }
 
+impl<'a> From<Cow<'a, str>> for Text<'a> {
+    fn from(s: Cow<'a, str>) -> Text<'a> {
+        Text::raw(s)
+    }
+}
+
 impl<'a> From<Span<'a>> for Text<'a> {
     fn from(span: Span<'a>) -> Text<'a> {
         Text {