diff --git a/README.md b/README.md
index 678f195350667a479757bdb95a62c45cc47aa614..5552cb1366f4caf1b02378e2488c1a8e4900a4bb 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,6 @@ Missing features:
 
 - Help window
 - File deletion
-- Opening a shell
 
 ### Improvements compared to the C version
 
diff --git a/doc/ncdu.pod b/doc/ncdu.pod
index c61dcdcdfa416d411e32564657fc542da4ddb0cd..47c1a3376a44ba708edf10d5ce2e5a129736c0a1 100644
--- a/doc/ncdu.pod
+++ b/doc/ncdu.pod
@@ -287,6 +287,12 @@ run ncdu as follows:
   export NCDU_SHELL=vifm
   ncdu
 
+Ncdu will set the C<NCDU_LEVEL> environment variable or increment it before
+spawning the shell. This variable allows you to detect when your shell is
+running from within ncdu, which can be useful to avoid nesting multiple
+instances of ncdu. Ncdu itself does not (currently) warn when attempting to run
+nested instances.
+
 =item q
 
 Quit
diff --git a/src/browser.zig b/src/browser.zig
index 91c4cc63fad15dd8bfb1f906f271f48a8328f16e..0c44bdea6c9210804a38d708099874d0f6375602 100644
--- a/src/browser.zig
+++ b/src/browser.zig
@@ -7,7 +7,7 @@ const c = @cImport(@cInclude("time.h"));
 usingnamespace @import("util.zig");
 
 // Currently opened directory and its parents.
-var dir_parents = model.Parents{};
+pub var dir_parents = model.Parents{};
 
 // Sorted list of all items in the currently opened directory.
 // (first item may be null to indicate the "parent directory" item)
@@ -305,6 +305,7 @@ const Row = struct {
 };
 
 var state: enum { main, quit, info } = .main;
+var message: ?[:0]const u8 = null;
 
 const quit = struct {
     fn draw() void {
@@ -625,6 +626,13 @@ pub fn draw() void {
         .quit => quit.draw(),
         .info => info.draw(),
     }
+    if (message) |m| {
+        const box = ui.Box.create(6, 60, "Message");
+        box.move(2, 2);
+        ui.addstr(m);
+        box.move(4, 34);
+        ui.addstr("Press any key to continue");
+    }
     if (sel_row > 0) ui.move(sel_row, 0);
 }
 
@@ -656,6 +664,11 @@ fn keyInputSelection(ch: i32, idx: *usize, len: usize, page: u32) bool {
 pub fn keyInput(ch: i32) void {
     defer current_view.save();
 
+    if (message != null) {
+        message = null;
+        return;
+    }
+
     switch (state) {
         .main => {}, // fallthrough
         .quit => return quit.keyInput(ch),
@@ -666,13 +679,21 @@ pub fn keyInput(ch: i32) void {
         'q' => if (main.config.confirm_quit) { state = .quit; } else ui.quit(),
         'i' => info.set(dir_items.items[cursor_idx], .info),
         'r' => {
-            if (main.config.imported) {
-                // TODO: Display message
-            } else {
+            if (main.config.imported)
+                message = "Directory imported from file, refreshing is disabled."
+            else {
                 main.state = .refresh;
                 scan.setupRefresh(dir_parents.copy());
             }
         },
+        'b' => {
+            if (main.config.imported)
+                message = "Shell feature not available for imported directories."
+            else if (!main.config.can_shell)
+                message = "Shell feature disabled in read-only mode."
+            else
+                main.state = .shell;
+        },
 
         // Sort & filter settings
         'n' => sortToggle(.name, .asc),
diff --git a/src/main.zig b/src/main.zig
index 71afcd1c5c227a37d6b22209ecbc30ca2547673d..08f9980dd9b3eb5bab6ac273a5819a6d8eb534d2 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -67,7 +67,7 @@ pub const config = struct {
     pub var confirm_quit: bool = false;
 };
 
-pub var state: enum { scan, browse, refresh } = .scan;
+pub var state: enum { scan, browse, refresh, shell } = .scan;
 
 // Simple generic argument parser, supports getopt_long() style arguments.
 // T can be any type that has a 'fn next(T) ?[:0]const u8' method, e.g.:
@@ -174,6 +174,62 @@ fn help() noreturn {
     std.process.exit(0);
 }
 
+
+fn spawnShell() void {
+    ui.deinit();
+    defer ui.init();
+
+    var path = std.ArrayList(u8).init(allocator);
+    defer path.deinit();
+    browser.dir_parents.fmtPath(true, &path);
+
+    var env = std.process.getEnvMap(allocator) catch unreachable;
+    defer env.deinit();
+    // NCDU_LEVEL can only count to 9, keeps the implementation simple.
+    if (env.get("NCDU_LEVEL")) |l|
+        env.put("NCDU_LEVEL", if (l.len == 0) "1" else switch (l[0]) {
+            '0'...'8' => @as([]const u8, &.{l[0]+1}),
+            '9' => "9",
+            else => "1"
+        }) catch unreachable
+    else
+        env.put("NCDU_LEVEL", "1") catch unreachable;
+
+    const shell = std.os.getenvZ("NCDU_SHELL") orelse std.os.getenvZ("SHELL") orelse "/bin/sh";
+    var child = std.ChildProcess.init(&.{shell}, allocator) catch unreachable;
+    defer child.deinit();
+    child.cwd = path.items;
+    child.env_map = &env;
+
+    const term = child.spawnAndWait() catch |e| blk: {
+        _ = std.io.getStdErr().writer().print(
+            "Error spawning shell: {s}\n\nPress enter to continue.\n",
+            .{ ui.errorString(e) }
+        ) catch {};
+        _ = std.io.getStdIn().reader().skipUntilDelimiterOrEof('\n') catch unreachable;
+        break :blk std.ChildProcess.Term{ .Exited = 0 };
+    };
+    if (term != .Exited) {
+        const n = switch (term) {
+            .Exited  => "status",
+            .Signal  => "signal",
+            .Stopped => "stopped",
+            .Unknown => "unknown",
+        };
+        const v = switch (term) {
+            .Exited  => |v| v,
+            .Signal  => |v| v,
+            .Stopped => |v| v,
+            .Unknown => |v| v,
+        };
+        _ = std.io.getStdErr().writer().print(
+            "Shell returned with {s} code {}.\n\nPress enter to continue.\n", .{ n, v }
+        ) catch {};
+        _ = std.io.getStdIn().reader().skipUntilDelimiterOrEof('\n') catch unreachable;
+    }
+}
+
+
 fn readExcludeFile(path: []const u8) !void {
     const f = try std.fs.cwd().openFile(path, .{});
     defer f.close();
@@ -279,12 +335,18 @@ pub fn main() void {
     browser.loadDir();
 
     while (true) {
-        if (state == .refresh) {
-            scan.scan();
-            state = .browse;
-            browser.loadDir();
-        } else
-            handleEvent(true, false);
+        switch (state) {
+            .refresh => {
+                scan.scan();
+                state = .browse;
+                browser.loadDir();
+            },
+            .shell => {
+                spawnShell();
+                state = .browse;
+            },
+            else => handleEvent(true, false)
+        }
     }
 }
 
@@ -298,6 +360,7 @@ pub fn handleEvent(block: bool, force_draw: bool) void {
         switch (state) {
             .scan, .refresh => scan.draw(),
             .browse => browser.draw(),
+            .shell => unreachable,
         }
         if (ui.inited) _ = ui.c.refresh();
         event_delay_timer.reset();
@@ -315,6 +378,7 @@ pub fn handleEvent(block: bool, force_draw: bool) void {
         switch (state) {
             .scan, .refresh => scan.keyInput(ch),
             .browse => browser.keyInput(ch),
+            .shell => unreachable,
         }
         firstblock = false;
     }
diff --git a/src/ui.zig b/src/ui.zig
index 628cefe4310453e70872ba3e7b4789c415138d15..7910ee52076c31d4ca93dc6aa35e17439cb434ee 100644
--- a/src/ui.zig
+++ b/src/ui.zig
@@ -52,19 +52,25 @@ pub fn oom() void {
 // (Would be nicer if Zig just exposed errno so I could call strerror() directly)
 pub fn errorString(e: anyerror) [:0]const u8 {
     return switch (e) {
+        error.AccessDenied => "Access denied",
         error.DiskQuota => "Disk quota exceeded",
+        error.FileNotFound => "No such file or directory",
+        error.FileSystem => "I/O error", // This one is shit, Zig uses this for both EIO and ELOOP in execve().
         error.FileTooBig => "File too big",
+        error.FileBusy => "File is busy",
         error.InputOutput => "I/O error",
+        error.InvalidExe => "Invalid executable",
+        error.IsDir => "Is a directory",
+        error.NameTooLong => "Filename too long",
         error.NoSpaceLeft => "No space left on device",
-        error.AccessDenied => "Access denied",
-        error.SymlinkLoop => "Symlink loop",
+        error.NotDir => "Not a directory",
+        error.OutOfMemory, error.SystemResources => "Out of memory",
         error.ProcessFdQuotaExceeded => "Process file descriptor limit exceeded",
+        error.SymlinkLoop => "Symlink loop",
         error.SystemFdQuotaExceeded => "System file descriptor limit exceeded",
-        error.NameTooLong => "Filename too long",
-        error.FileNotFound => "No such file or directory",
-        error.IsDir => "Is a directory",
-        error.NotDir => "Not a directory",
         else => "Unknown error", // rather useless :(
+        // ^ TODO: remove that one and accept only a restricted error set for
+        // compile-time exhaustiveness checks.
     };
 }