diff --git a/book/src/themes.md b/book/src/themes.md
index e3b95c0a..a59df2fd 100644
--- a/book/src/themes.md
+++ b/book/src/themes.md
@@ -297,6 +297,7 @@ These scopes are used for theming the editor interface:
 | `ui.bufferline.background`        | Style for bufferline background                                                                |
 | `ui.popup`                        | Documentation popups (e.g. Space + k)                                                          |
 | `ui.popup.info`                   | Prompt for multiple key options                                                                |
+| `ui.picker.header`                | Column names in pickers with multiple columns                                                  |
 | `ui.window`                       | Borderlines separating splits                                                                  |
 | `ui.help`                         | Description box for commands                                                                   |
 | `ui.text`                         | Default text style, command prompts, popup text, etc.                                          |
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 69496eb6..5600a1e4 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -2317,7 +2317,9 @@ fn global_search(cx: &mut Context) {
                     return;
                 }
 
-                let (picker, injector) = Picker::stream(current_path);
+                // TODO
+                let columns = vec![];
+                let (picker, injector) = Picker::stream(columns, current_path);
 
                 let dedup_symlinks = file_picker_config.deduplicate_links;
                 let absolute_root = search_root
@@ -2420,6 +2422,7 @@ fn global_search(cx: &mut Context) {
                     let call = move |_: &mut Editor, compositor: &mut Compositor| {
                         let picker = Picker::with_stream(
                             picker,
+                            0,
                             injector,
                             move |cx, FileResult { path, line_num }, action| {
                                 let doc = match cx.editor.open(path, action) {
@@ -2937,7 +2940,8 @@ fn buffer_picker(cx: &mut Context) {
     // mru
     items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at));
 
-    let picker = Picker::new(items, (), |cx, meta, action| {
+    let columns = vec![];
+    let picker = Picker::new(columns, 0, items, (), |cx, meta, action| {
         cx.editor.switch(meta.id, action);
     })
     .with_preview(|editor, meta| {
@@ -3014,7 +3018,10 @@ fn jumplist_picker(cx: &mut Context) {
         }
     };
 
+    let columns = vec![];
     let picker = Picker::new(
+        columns,
+        0,
         cx.editor
             .tree
             .views()
@@ -3180,7 +3187,8 @@ pub fn command_palette(cx: &mut Context) {
                 }
             }));
 
-            let picker = Picker::new(commands, keymap, move |cx, command, _action| {
+            let columns = vec![];
+            let picker = Picker::new(columns, 0, commands, keymap, move |cx, command, _action| {
                 let mut ctx = Context {
                     register,
                     count,
diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs
index 0e50377a..da2b60da 100644
--- a/helix-term/src/commands/dap.rs
+++ b/helix-term/src/commands/dap.rs
@@ -73,9 +73,14 @@ fn thread_picker(
             let debugger = debugger!(editor);
 
             let thread_states = debugger.thread_states.clone();
-            let picker = Picker::new(threads, thread_states, move |cx, thread, _action| {
-                callback_fn(cx.editor, thread)
-            })
+            let columns = vec![];
+            let picker = Picker::new(
+                columns,
+                0,
+                threads,
+                thread_states,
+                move |cx, thread, _action| callback_fn(cx.editor, thread),
+            )
             .with_preview(move |editor, thread| {
                 let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?;
                 let frame = frames.first()?;
@@ -268,7 +273,11 @@ pub fn dap_launch(cx: &mut Context) {
 
     let templates = config.templates.clone();
 
+    let columns = vec![];
+
     cx.push_layer(Box::new(overlaid(Picker::new(
+        columns,
+        0,
         templates,
         (),
         |cx, template, _action| {
@@ -736,7 +745,8 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
 
     let frames = debugger.stack_frames[&thread_id].clone();
 
-    let picker = Picker::new(frames, (), move |cx, frame, _action| {
+    let columns = vec![];
+    let picker = Picker::new(columns, 0, frames, (), move |cx, frame, _action| {
         let debugger = debugger!(cx.editor);
         // TODO: this should be simpler to find
         let pos = debugger.stack_frames[&thread_id]
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
index d585e1be..bf9747a4 100644
--- a/helix-term/src/commands/lsp.rs
+++ b/helix-term/src/commands/lsp.rs
@@ -241,18 +241,25 @@ fn jump_to_position(
     }
 }
 
-type SymbolPicker = Picker<SymbolInformationItem>;
+type SymbolPicker = Picker<SymbolInformationItem, Option<lsp::Url>>;
 
 fn sym_picker(symbols: Vec<SymbolInformationItem>, current_path: Option<lsp::Url>) -> SymbolPicker {
     // TODO: drop current_path comparison and instead use workspace: bool flag?
-    Picker::new(symbols, current_path, move |cx, item, action| {
-        jump_to_location(
-            cx.editor,
-            &item.symbol.location,
-            item.offset_encoding,
-            action,
-        );
-    })
+    let columns = vec![];
+    Picker::new(
+        columns,
+        0,
+        symbols,
+        current_path,
+        move |cx, item, action| {
+            jump_to_location(
+                cx.editor,
+                &item.symbol.location,
+                item.offset_encoding,
+                action,
+            );
+        },
+    )
     .with_preview(move |_editor, item| Some(location_to_file_location(&item.symbol.location)))
     .truncate_start(false)
 }
@@ -263,11 +270,13 @@ enum DiagnosticsFormat {
     HideSourcePath,
 }
 
+type DiagnosticsPicker = Picker<PickerDiagnostic, (DiagnosticStyles, DiagnosticsFormat)>;
+
 fn diag_picker(
     cx: &Context,
     diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>,
     format: DiagnosticsFormat,
-) -> Picker<PickerDiagnostic> {
+) -> DiagnosticsPicker {
     // TODO: drop current_path comparison and instead use workspace: bool flag?
 
     // flatten the map to a vec of (url, diag) pairs
@@ -293,7 +302,10 @@ fn diag_picker(
         error: cx.editor.theme.get("error"),
     };
 
+    let columns = vec![];
     Picker::new(
+        columns,
+        0,
         flat_diag,
         (styles, format),
         move |cx,
@@ -817,7 +829,8 @@ fn goto_impl(
         }
         [] => unreachable!("`locations` should be non-empty for `goto_impl`"),
         _locations => {
-            let picker = Picker::new(locations, cwdir, move |cx, location, action| {
+            let columns = vec![];
+            let picker = Picker::new(columns, 0, locations, cwdir, move |cx, location, action| {
                 jump_to_location(cx.editor, location, offset_encoding, action)
             })
             .with_preview(move |_editor, location| Some(location_to_file_location(location)));
diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index ed1547f1..232e5846 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -9,7 +9,6 @@ use super::*;
 use helix_core::fuzzy::fuzzy_match;
 use helix_core::indent::MAX_INDENT;
 use helix_core::{line_ending, shellwords::Shellwords};
-use helix_lsp::LanguageServerId;
 use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME};
 use helix_view::editor::{CloseError, ConfigEvent};
 use serde_json::Value;
@@ -1378,16 +1377,6 @@ fn lsp_workspace_command(
         return Ok(());
     }
 
-    struct LsIdCommand(LanguageServerId, helix_lsp::lsp::Command);
-
-    impl ui::menu::Item for LsIdCommand {
-        type Data = ();
-
-        fn format(&self, _data: &Self::Data) -> Row {
-            self.1.title.as_str().into()
-        }
-    }
-
     let doc = doc!(cx.editor);
     let ls_id_commands = doc
         .language_servers_with_feature(LanguageServerFeature::WorkspaceCommand)
@@ -1402,7 +1391,7 @@ fn lsp_workspace_command(
     if args.is_empty() {
         let commands = ls_id_commands
             .map(|(ls_id, command)| {
-                LsIdCommand(
+                (
                     ls_id,
                     helix_lsp::lsp::Command {
                         title: command.clone(),
@@ -1415,10 +1404,13 @@ fn lsp_workspace_command(
         let callback = async move {
             let call: job::Callback = Callback::EditorCompositor(Box::new(
                 move |_editor: &mut Editor, compositor: &mut Compositor| {
+                    let columns = vec![];
                     let picker = ui::Picker::new(
+                        columns,
+                        0,
                         commands,
                         (),
-                        move |cx, LsIdCommand(ls_id, command), _action| {
+                        move |cx, (ls_id, command), _action| {
                             execute_lsp_command(cx.editor, *ls_id, command.clone());
                         },
                     );
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index 0a65b12b..01b718d4 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -21,7 +21,7 @@ pub use editor::EditorView;
 use helix_stdx::rope;
 pub use markdown::Markdown;
 pub use menu::Menu;
-pub use picker::{DynamicPicker, FileLocation, Picker};
+pub use picker::{Column as PickerColumn, DynamicPicker, FileLocation, Picker};
 pub use popup::Popup;
 pub use prompt::{Prompt, PromptEvent};
 pub use spinner::{ProgressSpinners, Spinner};
@@ -170,7 +170,9 @@ pub fn raw_regex_prompt(
     cx.push_layer(Box::new(prompt));
 }
 
-pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker<PathBuf> {
+type FilePicker = Picker<PathBuf, PathBuf>;
+
+pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker {
     use ignore::{types::TypesBuilder, WalkBuilder};
     use std::time::Instant;
 
@@ -217,16 +219,23 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker
     });
     log::debug!("file_picker init {:?}", Instant::now().duration_since(now));
 
-    let picker = Picker::new(Vec::new(), root, move |cx, path: &PathBuf, action| {
-        if let Err(e) = cx.editor.open(path, action) {
-            let err = if let Some(err) = e.source() {
-                format!("{}", err)
-            } else {
-                format!("unable to open \"{}\"", path.display())
-            };
-            cx.editor.set_error(err);
-        }
-    })
+    let columns = vec![];
+    let picker = Picker::new(
+        columns,
+        0,
+        Vec::new(),
+        root,
+        move |cx, path: &PathBuf, action| {
+            if let Err(e) = cx.editor.open(path, action) {
+                let err = if let Some(err) = e.source() {
+                    format!("{}", err)
+                } else {
+                    format!("unable to open \"{}\"", path.display())
+                };
+                cx.editor.set_error(err);
+            }
+        },
+    )
     .with_preview(|_editor, path| Some((path.clone().into(), None)));
     let injector = picker.injector();
     let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30);
diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs
index cc86a4fa..ab8e4e15 100644
--- a/helix-term/src/ui/picker.rs
+++ b/helix-term/src/ui/picker.rs
@@ -21,12 +21,13 @@ use tui::{
     buffer::Buffer as Surface,
     layout::Constraint,
     text::{Span, Spans},
-    widgets::{Block, BorderType, Cell, Table},
+    widgets::{Block, BorderType, Cell, Row, Table},
 };
 
 use tui::widgets::Widget;
 
 use std::{
+    borrow::Cow,
     collections::HashMap,
     io::Read,
     path::{Path, PathBuf},
@@ -49,9 +50,9 @@ use helix_view::{
     Document, DocumentId, Editor,
 };
 
-use self::handlers::PreviewHighlightHandler;
+use super::overlay::Overlay;
 
-use super::{menu::Item, overlay::Overlay};
+use self::handlers::PreviewHighlightHandler;
 
 pub const ID: &str = "picker";
 
@@ -129,38 +130,36 @@ impl Preview<'_, '_> {
     }
 }
 
-fn item_to_nucleo<T: Item>(item: T, editor_data: &T::Data) -> Option<(T, Utf32String)> {
-    let row = item.format(editor_data);
-    let mut cells = row.cells.iter();
-    let mut text = String::with_capacity(row.cell_text().map(|cell| cell.len()).sum());
-    let cell = cells.next()?;
-    if let Some(cell) = cell.content.lines.first() {
-        for span in &cell.0 {
-            text.push_str(&span.content);
+fn inject_nucleo_item<T, D>(
+    injector: &nucleo::Injector<T>,
+    columns: &[Column<T, D>],
+    item: T,
+    editor_data: &D,
+) {
+    let column_texts: Vec<Utf32String> = columns
+        .iter()
+        .filter(|column| column.filter)
+        .map(|column| column.format_text(&item, editor_data).into())
+        .collect();
+    injector.push(item, |dst| {
+        for (i, text) in column_texts.into_iter().enumerate() {
+            dst[i] = text;
         }
-    }
-
-    for cell in cells {
-        text.push(' ');
-        if let Some(cell) = cell.content.lines.first() {
-            for span in &cell.0 {
-                text.push_str(&span.content);
-            }
-        }
-    }
-    Some((item, text.into()))
+    });
 }
 
-pub struct Injector<T: Item> {
+pub struct Injector<T, D> {
     dst: nucleo::Injector<T>,
-    editor_data: Arc<T::Data>,
+    columns: Arc<[Column<T, D>]>,
+    editor_data: Arc<D>,
     shutown: Arc<AtomicBool>,
 }
 
-impl<T: Item> Clone for Injector<T> {
+impl<I, D> Clone for Injector<I, D> {
     fn clone(&self) -> Self {
         Injector {
             dst: self.dst.clone(),
+            columns: self.columns.clone(),
             editor_data: self.editor_data.clone(),
             shutown: self.shutown.clone(),
         }
@@ -169,21 +168,56 @@ impl<T: Item> Clone for Injector<T> {
 
 pub struct InjectorShutdown;
 
-impl<T: Item> Injector<T> {
+impl<T, D> Injector<T, D> {
     pub fn push(&self, item: T) -> Result<(), InjectorShutdown> {
         if self.shutown.load(atomic::Ordering::Relaxed) {
             return Err(InjectorShutdown);
         }
 
-        if let Some((item, matcher_text)) = item_to_nucleo(item, &self.editor_data) {
-            self.dst.push(item, |dst| dst[0] = matcher_text);
-        }
+        inject_nucleo_item(&self.dst, &self.columns, item, &self.editor_data);
         Ok(())
     }
 }
 
-pub struct Picker<T: Item> {
-    editor_data: Arc<T::Data>,
+type ColumnFormatFn<T, D> = for<'a> fn(&'a T, &'a D) -> Cell<'a>;
+
+pub struct Column<T, D> {
+    name: Arc<str>,
+    format: ColumnFormatFn<T, D>,
+    /// Whether the column should be passed to nucleo for matching and filtering.
+    /// `DynamicPicker` uses this so that the dynamic column (for example regex in
+    /// global search) is not used for filtering twice.
+    filter: bool,
+}
+
+impl<T, D> Column<T, D> {
+    pub fn new(name: impl Into<Arc<str>>, format: ColumnFormatFn<T, D>) -> Self {
+        Self {
+            name: name.into(),
+            format,
+            filter: true,
+        }
+    }
+
+    pub fn without_filtering(mut self) -> Self {
+        self.filter = false;
+        self
+    }
+
+    fn format<'a>(&self, item: &'a T, data: &'a D) -> Cell<'a> {
+        (self.format)(item, data)
+    }
+
+    fn format_text<'a>(&self, item: &'a T, data: &'a D) -> Cow<'a, str> {
+        let text: String = self.format(item, data).content.into();
+        text.into()
+    }
+}
+
+pub struct Picker<T: 'static + Send + Sync, D: 'static> {
+    columns: Arc<[Column<T, D>]>,
+    primary_column: usize,
+    editor_data: Arc<D>,
     shutdown: Arc<AtomicBool>,
     matcher: Nucleo<T>,
 
@@ -211,16 +245,19 @@ pub struct Picker<T: Item> {
     preview_highlight_handler: Sender<Arc<Path>>,
 }
 
-impl<T: Item + 'static> Picker<T> {
-    pub fn stream(editor_data: T::Data) -> (Nucleo<T>, Injector<T>) {
+impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
+    pub fn stream(columns: Vec<Column<T, D>>, editor_data: D) -> (Nucleo<T>, Injector<T, D>) {
+        let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32;
+        assert!(matcher_columns > 0);
         let matcher = Nucleo::new(
             Config::DEFAULT,
             Arc::new(helix_event::request_redraw),
             None,
-            1,
+            matcher_columns,
         );
         let streamer = Injector {
             dst: matcher.injector(),
+            columns: columns.into(),
             editor_data: Arc::new(editor_data),
             shutown: Arc::new(AtomicBool::new(false)),
         };
@@ -228,24 +265,28 @@ impl<T: Item + 'static> Picker<T> {
     }
 
     pub fn new(
+        columns: Vec<Column<T, D>>,
+        primary_column: usize,
         options: Vec<T>,
-        editor_data: T::Data,
+        editor_data: D,
         callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
     ) -> Self {
+        let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32;
+        assert!(matcher_columns > 0);
         let matcher = Nucleo::new(
             Config::DEFAULT,
             Arc::new(helix_event::request_redraw),
             None,
-            1,
+            matcher_columns,
         );
         let injector = matcher.injector();
         for item in options {
-            if let Some((item, matcher_text)) = item_to_nucleo(item, &editor_data) {
-                injector.push(item, |dst| dst[0] = matcher_text);
-            }
+            inject_nucleo_item(&injector, &columns, item, &editor_data);
         }
         Self::with(
             matcher,
+            columns.into(),
+            primary_column,
             Arc::new(editor_data),
             Arc::new(AtomicBool::new(false)),
             callback_fn,
@@ -254,18 +295,30 @@ impl<T: Item + 'static> Picker<T> {
 
     pub fn with_stream(
         matcher: Nucleo<T>,
-        injector: Injector<T>,
+        primary_column: usize,
+        injector: Injector<T, D>,
         callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
     ) -> Self {
-        Self::with(matcher, injector.editor_data, injector.shutown, callback_fn)
+        Self::with(
+            matcher,
+            injector.columns,
+            primary_column,
+            injector.editor_data,
+            injector.shutown,
+            callback_fn,
+        )
     }
 
     fn with(
         matcher: Nucleo<T>,
-        editor_data: Arc<T::Data>,
+        columns: Arc<[Column<T, D>]>,
+        default_column: usize,
+        editor_data: Arc<D>,
         shutdown: Arc<AtomicBool>,
         callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
     ) -> Self {
+        assert!(!columns.is_empty());
+
         let prompt = Prompt::new(
             "".into(),
             None,
@@ -273,7 +326,14 @@ impl<T: Item + 'static> Picker<T> {
             |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {},
         );
 
+        let widths = columns
+            .iter()
+            .map(|column| Constraint::Length(column.name.chars().count() as u16))
+            .collect();
+
         Self {
+            columns,
+            primary_column: default_column,
             matcher,
             editor_data,
             shutdown,
@@ -284,17 +344,18 @@ impl<T: Item + 'static> Picker<T> {
             show_preview: true,
             callback_fn: Box::new(callback_fn),
             completion_height: 0,
-            widths: Vec::new(),
+            widths,
             preview_cache: HashMap::new(),
             read_buffer: Vec::with_capacity(1024),
             file_fn: None,
-            preview_highlight_handler: PreviewHighlightHandler::<T>::default().spawn(),
+            preview_highlight_handler: PreviewHighlightHandler::<T, D>::default().spawn(),
         }
     }
 
-    pub fn injector(&self) -> Injector<T> {
+    pub fn injector(&self) -> Injector<T, D> {
         Injector {
             dst: self.matcher.injector(),
+            columns: self.columns.clone(),
             editor_data: self.editor_data.clone(),
             shutown: self.shutdown.clone(),
         }
@@ -316,13 +377,17 @@ impl<T: Item + 'static> Picker<T> {
         self
     }
 
+    pub fn with_line(mut self, line: String, editor: &Editor) -> Self {
+        self.prompt.set_line(line, editor);
+        self.handle_prompt_change();
+        self
+    }
+
     pub fn set_options(&mut self, new_options: Vec<T>) {
         self.matcher.restart(false);
         let injector = self.matcher.injector();
         for item in new_options {
-            if let Some((item, matcher_text)) = item_to_nucleo(item, &self.editor_data) {
-                injector.push(item, |dst| dst[0] = matcher_text);
-            }
+            inject_nucleo_item(&injector, &self.columns, item, &self.editor_data);
         }
     }
 
@@ -376,27 +441,39 @@ impl<T: Item + 'static> Picker<T> {
             .map(|item| item.data)
     }
 
+    fn header_height(&self) -> u16 {
+        if self.columns.len() > 1 {
+            1
+        } else {
+            0
+        }
+    }
+
     pub fn toggle_preview(&mut self) {
         self.show_preview = !self.show_preview;
     }
 
     fn prompt_handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
         if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) {
-            let pattern = self.prompt.line();
-            // TODO: better track how the pattern has changed
-            if pattern != &self.previous_pattern {
-                self.matcher.pattern.reparse(
-                    0,
-                    pattern,
-                    CaseMatching::Smart,
-                    pattern.starts_with(&self.previous_pattern),
-                );
-                self.previous_pattern = pattern.clone();
-            }
+            self.handle_prompt_change();
         }
         EventResult::Consumed(None)
     }
 
+    fn handle_prompt_change(&mut self) {
+        let pattern = self.prompt.line();
+        // TODO: better track how the pattern has changed
+        if pattern != &self.previous_pattern {
+            self.matcher.pattern.reparse(
+                0,
+                pattern,
+                CaseMatching::Smart,
+                pattern.starts_with(&self.previous_pattern),
+            );
+            self.previous_pattern = pattern.clone();
+        }
+    }
+
     fn current_file(&self, editor: &Editor) -> Option<FileLocation> {
         self.selection()
             .and_then(|current| (self.file_fn.as_ref()?)(editor, current))
@@ -526,7 +603,7 @@ impl<T: Item + 'static> Picker<T> {
         // -- Render the contents:
         // subtract area of prompt from top
         let inner = inner.clip_top(2);
-        let rows = inner.height as u32;
+        let rows = inner.height.saturating_sub(self.header_height()) as u32;
         let offset = self.cursor - (self.cursor % std::cmp::max(1, rows));
         let cursor = self.cursor.saturating_sub(offset);
         let end = offset
@@ -540,83 +617,94 @@ impl<T: Item + 'static> Picker<T> {
         }
 
         let options = snapshot.matched_items(offset..end).map(|item| {
-            snapshot.pattern().column_pattern(0).indices(
-                item.matcher_columns[0].slice(..),
-                &mut matcher,
-                &mut indices,
-            );
-            indices.sort_unstable();
-            indices.dedup();
-            let mut row = item.data.format(&self.editor_data);
-
-            let mut grapheme_idx = 0u32;
-            let mut indices = indices.drain(..);
-            let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX);
-            if self.widths.len() < row.cells.len() {
-                self.widths.resize(row.cells.len(), Constraint::Length(0));
-            }
             let mut widths = self.widths.iter_mut();
-            for cell in &mut row.cells {
+            let mut matcher_index = 0;
+
+            Row::new(self.columns.iter().map(|column| {
                 let Some(Constraint::Length(max_width)) = widths.next() else {
                     unreachable!();
                 };
+                let mut cell = column.format(item.data, &self.editor_data);
+                let width = if column.filter {
+                    snapshot.pattern().column_pattern(matcher_index).indices(
+                        item.matcher_columns[matcher_index].slice(..),
+                        &mut matcher,
+                        &mut indices,
+                    );
+                    indices.sort_unstable();
+                    indices.dedup();
+                    let mut indices = indices.drain(..);
+                    let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX);
+                    let mut span_list = Vec::new();
+                    let mut current_span = String::new();
+                    let mut current_style = Style::default();
+                    let mut grapheme_idx = 0u32;
+                    let mut width = 0;
 
-                // merge index highlights on top of existing hightlights
-                let mut span_list = Vec::new();
-                let mut current_span = String::new();
-                let mut current_style = Style::default();
-                let mut width = 0;
-
-                let spans: &[Span] = cell.content.lines.first().map_or(&[], |it| it.0.as_slice());
-                for span in spans {
-                    // this looks like a bug on first glance, we are iterating
-                    // graphemes but treating them as char indices. The reason that
-                    // this is correct is that nucleo will only ever consider the first char
-                    // of a grapheme (and discard the rest of the grapheme) so the indices
-                    // returned by nucleo are essentially grapheme indecies
-                    for grapheme in span.content.graphemes(true) {
-                        let style = if grapheme_idx == next_highlight_idx {
-                            next_highlight_idx = indices.next().unwrap_or(u32::MAX);
-                            span.style.patch(highlight_style)
-                        } else {
-                            span.style
-                        };
-                        if style != current_style {
-                            if !current_span.is_empty() {
-                                span_list.push(Span::styled(current_span, current_style))
+                    let spans: &[Span] =
+                        cell.content.lines.first().map_or(&[], |it| it.0.as_slice());
+                    for span in spans {
+                        // this looks like a bug on first glance, we are iterating
+                        // graphemes but treating them as char indices. The reason that
+                        // this is correct is that nucleo will only ever consider the first char
+                        // of a grapheme (and discard the rest of the grapheme) so the indices
+                        // returned by nucleo are essentially grapheme indecies
+                        for grapheme in span.content.graphemes(true) {
+                            let style = if grapheme_idx == next_highlight_idx {
+                                next_highlight_idx = indices.next().unwrap_or(u32::MAX);
+                                span.style.patch(highlight_style)
+                            } else {
+                                span.style
+                            };
+                            if style != current_style {
+                                if !current_span.is_empty() {
+                                    span_list.push(Span::styled(current_span, current_style))
+                                }
+                                current_span = String::new();
+                                current_style = style;
                             }
-                            current_span = String::new();
-                            current_style = style;
+                            current_span.push_str(grapheme);
+                            grapheme_idx += 1;
                         }
-                        current_span.push_str(grapheme);
-                        grapheme_idx += 1;
+                        width += span.width();
                     }
-                    width += span.width();
-                }
 
-                span_list.push(Span::styled(current_span, current_style));
+                    span_list.push(Span::styled(current_span, current_style));
+                    cell = Cell::from(Spans::from(span_list));
+                    matcher_index += 1;
+                    width
+                } else {
+                    cell.content
+                        .lines
+                        .first()
+                        .map(|line| line.width())
+                        .unwrap_or_default()
+                };
+
                 if width as u16 > *max_width {
                     *max_width = width as u16;
                 }
-                *cell = Cell::from(Spans::from(span_list));
 
-                // spacer
-                if grapheme_idx == next_highlight_idx {
-                    next_highlight_idx = indices.next().unwrap_or(u32::MAX);
-                }
-                grapheme_idx += 1;
-            }
-
-            row
+                cell
+            }))
         });
 
-        let table = Table::new(options)
+        let mut table = Table::new(options)
             .style(text_style)
             .highlight_style(selected)
             .highlight_symbol(" > ")
             .column_spacing(1)
             .widths(&self.widths);
 
+        // -- Header
+        if self.columns.len() > 1 {
+            let header_style = cx.editor.theme.get("ui.picker.header");
+
+            table = table.header(Row::new(self.columns.iter().map(|column| {
+                Cell::from(Span::styled(Cow::from(&*column.name), header_style))
+            })));
+        }
+
         use tui::widgets::TableState;
 
         table.render_table(
@@ -746,7 +834,7 @@ impl<T: Item + 'static> Picker<T> {
     }
 }
 
-impl<T: Item + 'static + Send + Sync> Component for Picker<T> {
+impl<I: 'static + Send + Sync, D: 'static + Send + Sync> Component for Picker<I, D> {
     fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
         // +---------+ +---------+
         // |prompt   | |preview  |
@@ -872,7 +960,7 @@ impl<T: Item + 'static + Send + Sync> Component for Picker<T> {
     }
 
     fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> {
-        self.completion_height = height.saturating_sub(4);
+        self.completion_height = height.saturating_sub(4 + self.header_height());
         Some((width, height))
     }
 
@@ -880,7 +968,7 @@ impl<T: Item + 'static + Send + Sync> Component for Picker<T> {
         Some(ID)
     }
 }
-impl<T: Item> Drop for Picker<T> {
+impl<T: 'static + Send + Sync, D> Drop for Picker<T, D> {
     fn drop(&mut self) {
         // ensure we cancel any ongoing background threads streaming into the picker
         self.shutdown.store(true, atomic::Ordering::Relaxed)
@@ -896,14 +984,14 @@ pub type DynQueryCallback<T> =
 
 /// A picker that updates its contents via a callback whenever the
 /// query string changes. Useful for live grep, workspace symbols, etc.
-pub struct DynamicPicker<T: ui::menu::Item + Send + Sync> {
-    file_picker: Picker<T>,
+pub struct DynamicPicker<T: 'static + Send + Sync, D: 'static + Send + Sync> {
+    file_picker: Picker<T, D>,
     query_callback: DynQueryCallback<T>,
     query: String,
 }
 
-impl<T: ui::menu::Item + Send + Sync> DynamicPicker<T> {
-    pub fn new(file_picker: Picker<T>, query_callback: DynQueryCallback<T>) -> Self {
+impl<T: Send + Sync, D: Send + Sync> DynamicPicker<T, D> {
+    pub fn new(file_picker: Picker<T, D>, query_callback: DynQueryCallback<T>) -> Self {
         Self {
             file_picker,
             query_callback,
@@ -912,20 +1000,22 @@ impl<T: ui::menu::Item + Send + Sync> DynamicPicker<T> {
     }
 }
 
-impl<T: Item + Send + Sync + 'static> Component for DynamicPicker<T> {
+impl<T: Send + Sync + 'static, D: Send + Sync + 'static> Component for DynamicPicker<T, D> {
     fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
         self.file_picker.render(area, surface, cx);
     }
 
     fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
         let event_result = self.file_picker.handle_event(event, cx);
-        let current_query = self.file_picker.prompt.line();
+        let Some(current_query) = self.file_picker.primary_query() else {
+            return event_result;
+        };
 
         if !matches!(event, Event::IdleTimeout) || self.query == *current_query {
             return event_result;
         }
 
-        self.query.clone_from(current_query);
+        self.query = current_query.to_string();
 
         let new_options = (self.query_callback)(current_query.to_owned(), cx.editor);
 
@@ -934,7 +1024,7 @@ impl<T: Item + Send + Sync + 'static> Component for DynamicPicker<T> {
             let callback = Callback::EditorCompositor(Box::new(move |_editor, compositor| {
                 // Wrapping of pickers in overlay is done outside the picker code,
                 // so this is fragile and will break if wrapped in some other widget.
-                let picker = match compositor.find_id::<Overlay<DynamicPicker<T>>>(ID) {
+                let picker = match compositor.find_id::<Overlay<Self>>(ID) {
                     Some(overlay) => &mut overlay.content.file_picker,
                     None => return,
                 };
diff --git a/helix-term/src/ui/picker/handlers.rs b/helix-term/src/ui/picker/handlers.rs
index 7a77efa4..f01c982a 100644
--- a/helix-term/src/ui/picker/handlers.rs
+++ b/helix-term/src/ui/picker/handlers.rs
@@ -3,19 +3,16 @@ use std::{path::Path, sync::Arc, time::Duration};
 use helix_event::AsyncHook;
 use tokio::time::Instant;
 
-use crate::{
-    job,
-    ui::{menu::Item, overlay::Overlay},
-};
+use crate::{job, ui::overlay::Overlay};
 
 use super::{CachedPreview, DynamicPicker, Picker};
 
-pub(super) struct PreviewHighlightHandler<T: Item> {
+pub(super) struct PreviewHighlightHandler<T: 'static + Send + Sync, D: 'static + Send + Sync> {
     trigger: Option<Arc<Path>>,
-    phantom_data: std::marker::PhantomData<T>,
+    phantom_data: std::marker::PhantomData<(T, D)>,
 }
 
-impl<T: Item> Default for PreviewHighlightHandler<T> {
+impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Default for PreviewHighlightHandler<T, D> {
     fn default() -> Self {
         Self {
             trigger: None,
@@ -24,7 +21,9 @@ impl<T: Item> Default for PreviewHighlightHandler<T> {
     }
 }
 
-impl<T: Item> AsyncHook for PreviewHighlightHandler<T> {
+impl<T: 'static + Send + Sync, D: 'static + Send + Sync> AsyncHook
+    for PreviewHighlightHandler<T, D>
+{
     type Event = Arc<Path>;
 
     fn handle_event(
@@ -51,9 +50,9 @@ impl<T: Item> AsyncHook for PreviewHighlightHandler<T> {
         };
 
         job::dispatch_blocking(move |editor, compositor| {
-            let picker = match compositor.find::<Overlay<Picker<T>>>() {
+            let picker = match compositor.find::<Overlay<Picker<T, D>>>() {
                 Some(Overlay { content, .. }) => content,
-                None => match compositor.find::<Overlay<DynamicPicker<T>>>() {
+                None => match compositor.find::<Overlay<DynamicPicker<T, D>>>() {
                     Some(Overlay { content, .. }) => &mut content.file_picker,
                     None => return,
                 },
@@ -88,10 +87,10 @@ impl<T: Item> AsyncHook for PreviewHighlightHandler<T> {
                 };
 
                 job::dispatch_blocking(move |editor, compositor| {
-                    let picker = match compositor.find::<Overlay<Picker<T>>>() {
+                    let picker = match compositor.find::<Overlay<Picker<T, D>>>() {
                         Some(Overlay { content, .. }) => Some(content),
                         None => compositor
-                            .find::<Overlay<DynamicPicker<T>>>()
+                            .find::<Overlay<DynamicPicker<T, D>>>()
                             .map(|overlay| &mut overlay.content.file_picker),
                     };
                     let Some(picker) = picker else {
diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs
index 14b242df..19183470 100644
--- a/helix-term/src/ui/prompt.rs
+++ b/helix-term/src/ui/prompt.rs
@@ -93,11 +93,15 @@ impl Prompt {
     }
 
     pub fn with_line(mut self, line: String, editor: &Editor) -> Self {
+        self.set_line(line, editor);
+        self
+    }
+
+    pub fn set_line(&mut self, line: String, editor: &Editor) {
         let cursor = line.len();
         self.line = line;
         self.cursor = cursor;
         self.recalculate_completion(editor);
-        self
     }
 
     pub fn with_language(