diff --git a/build.zig b/build.zig
index 1f693d334974a281b0bc04207cad78f38ec69baf..9b91dcc1e3fc0d5f82dee5217193d0d3a1be1c7e 100644
--- a/build.zig
+++ b/build.zig
@@ -7,6 +7,8 @@ pub fn build(b: *std.build.Builder) void {
     const exe = b.addExecutable("ncdu", "src/main.zig");
     exe.setTarget(target);
     exe.setBuildMode(mode);
+    exe.linkLibC();
+    exe.linkSystemLibrary("ncurses");
     exe.install();
 
     const run_cmd = exe.run();
diff --git a/src/browser.zig b/src/browser.zig
new file mode 100644
index 0000000000000000000000000000000000000000..567d3472d41795a3c755ce523b1303c06ca24ba0
--- /dev/null
+++ b/src/browser.zig
@@ -0,0 +1,22 @@
+const std = @import("std");
+const main = @import("main.zig");
+const ui = @import("ui.zig");
+
+pub fn draw() void {
+    ui.style(.hd);
+    _ = ui.c.mvhline(0, 0, ' ', ui.cols);
+    _ = ui.c.mvaddstr(0, 0, "ncdu " ++ main.program_version ++ " ~ Use the arrow keys to navigate, press ");
+    ui.style(.key_hd);
+    _ = ui.c.addch('?');
+    ui.style(.hd);
+    _ = ui.c.addstr(" for help");
+    // TODO: [imported]/[readonly] indicators
+
+    ui.style(.default);
+    _ = ui.c.mvhline(1, 0, ' ', ui.cols);
+    // TODO: path
+
+    ui.style(.hd);
+    _ = ui.c.mvhline(ui.rows-1, 0, ' ', ui.cols);
+    _ = ui.c.mvaddstr(ui.rows-1, 1, "No items to display.");
+}
diff --git a/src/main.zig b/src/main.zig
index 32c53d988ac70e84716859c504401e5ae16e9544..452d78e9747bd3352986f6aa5b4af06e55113a88 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -1,9 +1,13 @@
+pub const program_version = "2.0";
+
 const std = @import("std");
 const model = @import("model.zig");
 const scan = @import("scan.zig");
+const ui = @import("ui.zig");
+const browser = @import("browser.zig");
+const c = @cImport(@cInclude("locale.h"));
 
-var general_purpose_allocator = std.heap.GeneralPurposeAllocator(.{}){};
-pub const allocator = &general_purpose_allocator.allocator;
+pub const allocator = std.heap.c_allocator;
 
 pub const Config = struct {
     same_fs: bool = true,
@@ -15,7 +19,9 @@ pub const Config = struct {
 
     update_delay: u32 = 100,
     si: bool = false,
-    // TODO: color scheme
+    nc_tty: bool = false,
+    ui_color: enum { off, dark } = .off,
+    thousands_sep: []const u8 = ".",
 
     read_only: bool = false,
     can_shell: bool = true,
@@ -24,11 +30,6 @@ pub const Config = struct {
 
 pub var config = Config{};
 
-fn die(comptime fmt: []const u8, args: anytype) noreturn {
-    _ = std.io.getStdErr().writer().print(fmt, args) catch {};
-    std.process.exit(1);
-}
-
 // Simple generic argument parser, supports getopt_long() style arguments.
 // T can be any type that has a 'fn next(T) ?[]const u8' method, e.g.:
 //   var args = Args(std.process.ArgIteratorPosix).init(std.process.ArgIteratorPosix.init());
@@ -55,7 +56,7 @@ fn Args(T: anytype) type {
             return Self{ .it = it };
         }
 
-        pub fn shortopt(self: *Self, s: []const u8) Option {
+        fn shortopt(self: *Self, s: []const u8) Option {
             self.shortbuf[0] = '-';
             self.shortbuf[1] = s[0];
             self.short = if (s.len > 1) s[1..] else null;
@@ -67,18 +68,18 @@ fn Args(T: anytype) type {
         /// 'opt' indicates whether it's an option or positional argument,
         /// 'val' will be either -x, --something or the argument.
         pub fn next(self: *Self) ?Option {
-            if (self.last_arg != null) die("Option '{s}' does not expect an argument.\n", .{ self.last.? });
+            if (self.last_arg != null) ui.die("Option '{s}' does not expect an argument.\n", .{ self.last.? });
             if (self.short) |s| return self.shortopt(s);
             const val = self.it.next() orelse return null;
             if (self.argsep or val.len == 0 or val[0] != '-') return Option{ .opt = false, .val = val };
-            if (val.len == 1) die("Invalid option '-'.\n", .{});
+            if (val.len == 1) ui.die("Invalid option '-'.\n", .{});
             if (val.len == 2 and val[1] == '-') {
                 self.argsep = true;
                 return self.next();
             }
             if (val[1] == '-') {
                 if (std.mem.indexOfScalar(u8, val, '=')) |sep| {
-                    if (sep == 2) die("Invalid option '{s}'.\n", .{val});
+                    if (sep == 2) ui.die("Invalid option '{s}'.\n", .{val});
                     self.last_arg = val[sep+1.. :0];
                     self.last = val[0..sep];
                     return Option{ .opt = true, .val = self.last.? };
@@ -100,7 +101,7 @@ fn Args(T: anytype) type {
                 return a;
             }
             if (self.it.next()) |o| return o;
-            die("Option '{s}' requires an argument.\n", .{ self.last.? });
+            ui.die("Option '{s}' requires an argument.\n", .{ self.last.? });
         }
     };
 }
@@ -146,25 +147,53 @@ fn writeTree(out: anytype, e: *model.Entry, indent: u32) @TypeOf(out).Error!void
 }
 
 fn version() noreturn {
-    // TODO: don't hardcode this version here.
-    _ = std.io.getStdOut().writer().writeAll("ncdu 2.0\n") catch {};
+    std.io.getStdOut().writer().writeAll("ncdu " ++ program_version ++ "\n") catch {};
     std.process.exit(0);
 }
 
 fn help() noreturn {
-    // TODO
-    _ = std.io.getStdOut().writer().writeAll("ncdu 2.0\n") catch {};
+    std.io.getStdOut().writer().writeAll(
+        "ncdu <options> <directory>\n\n"
+     ++ "  -h,--help                  This help message\n"
+     ++ "  -q                         Quiet mode, refresh interval 2 seconds\n"
+     ++ "  -v,-V,--version            Print version\n"
+     ++ "  -x                         Same filesystem\n"
+     ++ "  -e                         Enable extended information\n"
+     ++ "  -r                         Read only\n"
+     ++ "  -o FILE                    Export scanned directory to FILE\n"
+     ++ "  -f FILE                    Import scanned directory from FILE\n"
+     ++ "  -0,-1,-2                   UI to use when scanning (0=none,2=full ncurses)\n"
+     ++ "  --si                       Use base 10 (SI) prefixes instead of base 2\n"
+     ++ "  --exclude PATTERN          Exclude files that match PATTERN\n"
+     ++ "  -X, --exclude-from FILE    Exclude files that match any pattern in FILE\n"
+     ++ "  -L, --follow-symlinks      Follow symbolic links (excluding directories)\n"
+     ++ "  --exclude-caches           Exclude directories containing CACHEDIR.TAG\n"
+     ++ "  --exclude-kernfs           Exclude Linux pseudo filesystems (procfs,sysfs,cgroup,...)\n"
+     ++ "  --confirm-quit             Confirm quitting ncdu\n"
+     ++ "  --color SCHEME             Set color scheme (off/dark)\n"
+    ) catch {};
     std.process.exit(0);
 }
 
 pub fn main() anyerror!void {
+    // Grab thousands_sep from the current C locale.
+    // (We can safely remove this when not linking against libc, it's a somewhat obscure feature)
+    _ = c.setlocale(c.LC_ALL, "");
+    if (c.localeconv()) |locale| {
+        if (locale.*.thousands_sep) |sep| {
+            const span = std.mem.spanZ(sep);
+            if (span.len > 0)
+                config.thousands_sep = span;
+        }
+    }
+
     var args = Args(std.process.ArgIteratorPosix).init(std.process.ArgIteratorPosix.init());
     var scan_dir: ?[]const u8 = null;
     _ = args.next(); // program name
     while (args.next()) |opt| {
         if (!opt.opt) {
             // XXX: ncdu 1.x doesn't error, it just silently ignores all but the last argument.
-            if (scan_dir != null) die("Multiple directories given, see ncdu -h for help.\n", .{});
+            if (scan_dir != null) ui.die("Multiple directories given, see ncdu -h for help.\n", .{});
             scan_dir = opt.val;
             continue;
         }
@@ -180,14 +209,23 @@ pub fn main() anyerror!void {
         else if(opt.is("--exclude-caches")) config.exclude_caches = true
         else if(opt.is("--exclude-kernfs")) config.exclude_kernfs = true
         else if(opt.is("--confirm-quit")) config.confirm_quit = true
-        else die("Unrecognized option '{s}'.\n", .{opt.val});
-        // TODO: -o, -f, -0, -1, -2, --exclude, -X, --exclude-from, --color
+        else if(opt.is("--color")) {
+            const val = args.arg();
+            if (std.mem.eql(u8, val, "off")) config.ui_color = .off
+            else if (std.mem.eql(u8, val, "dark")) config.ui_color = .dark
+            else ui.die("Unknown --color option: {s}.\n", .{val});
+        } else ui.die("Unrecognized option '{s}'.\n", .{opt.val});
+        // TODO: -o, -f, -0, -1, -2, --exclude, -X, --exclude-from
     }
 
-    std.log.info("align={}, Entry={}, Dir={}, Link={}, File={}.",
-        .{@alignOf(model.Dir), @sizeOf(model.Entry), @sizeOf(model.Dir), @sizeOf(model.Link), @sizeOf(model.File)});
     try scan.scanRoot(scan_dir orelse ".");
 
+    ui.init();
+    defer ui.deinit();
+    browser.draw();
+
+    _ = ui.c.getch();
+
     //var out = std.io.bufferedWriter(std.io.getStdOut().writer());
     //try writeTree(out.writer(), &model.root.entry, 0);
     //try out.flush();
diff --git a/src/ui.zig b/src/ui.zig
new file mode 100644
index 0000000000000000000000000000000000000000..88410e095bdae7d1fe58f4cfcd2c4bfa7dc348c7
--- /dev/null
+++ b/src/ui.zig
@@ -0,0 +1,146 @@
+// Ncurses wrappers and TUI helper functions.
+
+const std = @import("std");
+const main = @import("main.zig");
+
+pub const c = @cImport({
+    @cInclude("stdio.h");
+    @cInclude("string.h");
+    @cInclude("unistd.h");
+    @cInclude("curses.h");
+});
+
+var inited: bool = false;
+
+pub var rows: i32 = undefined;
+pub var cols: i32 = undefined;
+
+pub fn die(comptime fmt: []const u8, args: anytype) noreturn {
+    deinit();
+    _ = std.io.getStdErr().writer().print(fmt, args) catch {};
+    std.process.exit(1);
+}
+
+const StyleAttr = struct { fg: i16, bg: i16, attr: u32 };
+const StyleDef = struct {
+    name: []const u8,
+    off: StyleAttr,
+    dark: StyleAttr,
+    fn style(self: *const @This()) StyleAttr {
+        return switch (main.config.ui_color) {
+            .off => self.off,
+            .dark => self.dark,
+        };
+    }
+};
+
+const styles = [_]StyleDef{
+    .{  .name = "default",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = 0 },
+        .dark = .{ .fg = -1,              .bg = -1,             .attr = 0 } },
+    .{  .name = "box_title",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_BOLD },
+        .dark = .{ .fg = c.COLOR_BLUE,    .bg = -1,             .attr = c.A_BOLD } },
+    .{  .name = "hd", // header + footer
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_REVERSE },
+        .dark = .{ .fg = c.COLOR_BLACK,   .bg = c.COLOR_CYAN,   .attr = 0 } },
+    .{  .name = "sel",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_REVERSE },
+        .dark = .{ .fg = c.COLOR_WHITE,   .bg = c.COLOR_GREEN,  .attr = c.A_BOLD } },
+    .{  .name = "num",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = 0 },
+        .dark = .{ .fg = c.COLOR_YELLOW,  .bg = -1,             .attr = c.A_BOLD } },
+    .{  .name = "num_hd",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_REVERSE },
+        .dark = .{ .fg = c.COLOR_YELLOW,  .bg = c.COLOR_CYAN,   .attr = c.A_BOLD } },
+    .{  .name = "num_sel",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_REVERSE },
+        .dark = .{ .fg = c.COLOR_YELLOW,  .bg = c.COLOR_GREEN,  .attr = c.A_BOLD } },
+    .{  .name = "key",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_BOLD },
+        .dark = .{ .fg = c.COLOR_YELLOW,  .bg = -1,             .attr = c.A_BOLD } },
+    .{  .name = "key_hd",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_BOLD|c.A_REVERSE },
+        .dark = .{ .fg = c.COLOR_YELLOW,  .bg = c.COLOR_CYAN,   .attr = c.A_BOLD } },
+    .{  .name = "dir",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = 0 },
+        .dark = .{ .fg = c.COLOR_BLUE,    .bg = -1,             .attr = c.A_BOLD } },
+    .{  .name = "dir_sel",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_REVERSE },
+        .dark = .{ .fg = c.COLOR_BLUE,    .bg = c.COLOR_GREEN,  .attr = c.A_BOLD } },
+    .{  .name = "flag",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = 0 },
+        .dark = .{ .fg = c.COLOR_RED,     .bg = -1,             .attr = 0 } },
+    .{  .name = "flag_sel",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_REVERSE },
+        .dark = .{ .fg = c.COLOR_RED,     .bg = c.COLOR_GREEN,  .attr = 0 } },
+    .{  .name = "graph",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = 0 },
+        .dark = .{ .fg = c.COLOR_MAGENTA, .bg = -1,             .attr = 0 } },
+    .{  .name = "graph_sel",
+        .off  = .{ .fg = -1,              .bg = -1,             .attr = c.A_REVERSE },
+        .dark = .{ .fg = c.COLOR_MAGENTA, .bg = c.COLOR_GREEN,  .attr = 0 } },
+};
+
+const Style = lbl: {
+    var fields: [styles.len]std.builtin.TypeInfo.EnumField = undefined;
+    var decls = [_]std.builtin.TypeInfo.Declaration{};
+    inline for (styles) |s, i| {
+        fields[i] = .{
+            .name = s.name,
+            .value = i,
+        };
+    }
+    break :lbl @Type(.{
+        .Enum = .{
+            .layout = .Auto,
+            .tag_type = u8,
+            .fields = &fields,
+            .decls = &decls,
+            .is_exhaustive = true,
+        }
+    });
+};
+
+pub fn style(s: Style) void {
+    _ = c.attr_set(styles[@enumToInt(s)].style().attr, @enumToInt(s)+1, null);
+}
+
+fn updateSize() void {
+    // getmax[yx] macros are marked as "legacy", but Zig can't deal with the "proper" getmaxyx macro.
+    rows = c.getmaxy(c.stdscr);
+    cols = c.getmaxx(c.stdscr);
+}
+
+pub fn init() void {
+    if (inited) return;
+    if (main.config.nc_tty) {
+        var tty = c.fopen("/dev/tty", "r+");
+        if (tty == null) die("Error opening /dev/tty: {s}.\n", .{ c.strerror(std.c.getErrno(-1)) });
+        var term = c.newterm(null, tty, tty);
+        if (term == null) die("Error initializing ncurses.\n", .{});
+        _ = c.set_term(term);
+    } else {
+        if (c.isatty(0) != 1) die("Standard input is not a TTY. Did you mean to import a file using '-f -'?\n", .{});
+        if (c.initscr() == null) die("Error initializing ncurses.\n", .{});
+    }
+    updateSize();
+    _ = c.cbreak();
+    _ = c.noecho();
+    _ = c.curs_set(0);
+    _ = c.keypad(c.stdscr, true);
+
+    _ = c.start_color();
+    _ = c.use_default_colors();
+    for (styles) |s, i| _ = c.init_pair(@intCast(i16, i+1), s.style().fg, s.style().bg);
+
+    inited = true;
+}
+
+pub fn deinit() void {
+    if (!inited) return;
+    _ = c.erase();
+    _ = c.refresh();
+    _ = c.endwin();
+    inited = false;
+}