diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index c31c97fa..2f1db4b6 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -1360,6 +1360,32 @@ mod cmd {
         editor.set_status(editor.clipboard_provider.name().into());
     }
 
+    fn change_current_directory(editor: &mut Editor, args: &[&str], _: PromptEvent) {
+        let dir = match args.first() {
+            Some(dir) => dir,
+            None => {
+                editor.set_error("target directory not provided".into());
+                return;
+            }
+        };
+
+        if let Err(e) = std::env::set_current_dir(dir) {
+            editor.set_error(format!(
+                "Couldn't change the current working directory: {:?}",
+                e
+            ));
+            return;
+        }
+
+        match std::env::current_dir() {
+            Ok(cwd) => editor.set_status(format!(
+                "Current working directory is now {}",
+                cwd.display()
+            )),
+            Err(e) => editor.set_error(format!("Couldn't get the new working directory: {}", e)),
+        }
+    }
+
     pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
         TypableCommand {
             name: "quit",
@@ -1529,6 +1555,13 @@ mod cmd {
             fun: show_clipboard_provider,
             completer: None,
         },
+        TypableCommand {
+            name: "change-current-directory",
+            alias: Some("cd"),
+            doc: "Change the current working directory (:cd <dir>).",
+            fun: change_current_directory,
+            completer: Some(completers::directory),
+        },
     ];
 
     pub static COMMANDS: Lazy<HashMap<&'static str, &'static TypableCommand>> = Lazy::new(|| {
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index f60152c9..594dabdd 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -152,8 +152,46 @@ pub mod completers {
         names
     }
 
-    // TODO: we could return an iter/lazy thing so it can fetch as many as it needs.
     pub fn filename(input: &str) -> Vec<Completion> {
+        filename_impl(input, |entry| {
+            let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir());
+
+            if is_dir {
+                FileMatch::AcceptIncomplete
+            } else {
+                FileMatch::Accept
+            }
+        })
+    }
+
+    pub fn directory(input: &str) -> Vec<Completion> {
+        filename_impl(input, |entry| {
+            let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir());
+
+            if is_dir {
+                FileMatch::Accept
+            } else {
+                FileMatch::Reject
+            }
+        })
+    }
+
+    #[derive(Copy, Clone, PartialEq, Eq)]
+    enum FileMatch {
+        /// Entry should be ignored
+        Reject,
+        /// Entry is usable but can't be the end (for instance if the entry is a directory and we
+        /// try to match a file)
+        AcceptIncomplete,
+        /// Entry is usable and can be the end of the match
+        Accept,
+    }
+
+    // TODO: we could return an iter/lazy thing so it can fetch as many as it needs.
+    fn filename_impl<F>(input: &str, filter_fn: F) -> Vec<Completion>
+    where
+        F: Fn(&ignore::DirEntry) -> FileMatch,
+    {
         // Rust's filename handling is really annoying.
 
         use ignore::WalkBuilder;
@@ -184,7 +222,13 @@ pub mod completers {
             .max_depth(Some(1))
             .build()
             .filter_map(|file| {
-                file.ok().map(|entry| {
+                file.ok().and_then(|entry| {
+                    let fmatch = filter_fn(&entry);
+
+                    if fmatch == FileMatch::Reject {
+                        return None;
+                    }
+
                     let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir());
 
                     let path = entry.path();
@@ -199,11 +243,12 @@ pub mod completers {
                         path.strip_prefix(&dir).unwrap_or(path).to_path_buf()
                     };
 
-                    if is_dir {
+                    if fmatch == FileMatch::AcceptIncomplete {
                         path.push("");
                     }
+
                     let path = path.to_str().unwrap().to_owned();
-                    (end.clone(), Cow::from(path))
+                    Some((end.clone(), Cow::from(path)))
                 })
             }) // TODO: unwrap or skip
             .filter(|(_, path)| !path.is_empty()) // TODO