diff --git a/build.zig b/build.zig
index 9b91dcc1e3fc0d5f82dee5217193d0d3a1be1c7e..5519afc0f11c7e71eac69d3f74005f6046d27eb5 100644
--- a/build.zig
+++ b/build.zig
@@ -7,8 +7,9 @@ pub fn build(b: *std.build.Builder) void {
     const exe = b.addExecutable("ncdu", "src/main.zig");
     exe.setTarget(target);
     exe.setBuildMode(mode);
+    exe.addCSourceFile("src/ncurses_refs.c", &[_][]const u8{});
     exe.linkLibC();
-    exe.linkSystemLibrary("ncurses");
+    exe.linkSystemLibrary("ncursesw");
     exe.install();
 
     const run_cmd = exe.run();
@@ -19,4 +20,10 @@ pub fn build(b: *std.build.Builder) void {
 
     const run_step = b.step("run", "Run the app");
     run_step.dependOn(&run_cmd.step);
+
+    const tst = b.addTest("src/main.zig");
+    tst.linkLibC();
+    tst.linkSystemLibrary("ncursesw");
+    const tst_step = b.step("test", "Run tests");
+    tst_step.dependOn(&tst.step);
 }
diff --git a/src/browser.zig b/src/browser.zig
index 567d3472d41795a3c755ce523b1303c06ca24ba0..9024f75cc0ace42c9a0f26d259cd255759657b2f 100644
--- a/src/browser.zig
+++ b/src/browser.zig
@@ -1,22 +1,31 @@
 const std = @import("std");
 const main = @import("main.zig");
+const model = @import("model.zig");
 const ui = @import("ui.zig");
 
-pub fn draw() void {
+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.move(0,0);
+    ui.hline(' ', ui.cols);
+    ui.move(0,0);
+    ui.addstr("ncdu " ++ main.program_version ++ " ~ Use the arrow keys to navigate, press ");
     ui.style(.key_hd);
-    _ = ui.c.addch('?');
+    ui.addch('?');
     ui.style(.hd);
-    _ = ui.c.addstr(" for help");
+    ui.addstr(" for help");
     // TODO: [imported]/[readonly] indicators
 
     ui.style(.default);
-    _ = ui.c.mvhline(1, 0, ' ', ui.cols);
-    // TODO: path
+    ui.move(1,0);
+    ui.hline('-', ui.cols);
+    ui.move(1,3);
+    ui.addch(' ');
+    ui.addstr(try ui.shorten(try ui.toUtf8(model.root.entry.name()), std.math.sub(u32, ui.cols, 5) catch 4));
+    ui.addch(' ');
 
     ui.style(.hd);
-    _ = ui.c.mvhline(ui.rows-1, 0, ' ', ui.cols);
-    _ = ui.c.mvaddstr(ui.rows-1, 1, "No items to display.");
+    ui.move(ui.rows-1, 0);
+    ui.hline(' ', ui.cols);
+    ui.move(ui.rows-1, 1);
+    ui.addstr("No items to display.");
 }
diff --git a/src/main.zig b/src/main.zig
index 24d1461f48a3a1b2c7870faf4d99e0e706bb78c4..cb6043cd78267712b75c7f0eea3a5856b53dd8df 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -242,7 +242,7 @@ pub fn main() anyerror!void {
 
     ui.init();
     defer ui.deinit();
-    browser.draw();
+    try browser.draw();
 
     _ = ui.c.getch();
 
diff --git a/src/ncurses_refs.c b/src/ncurses_refs.c
new file mode 100644
index 0000000000000000000000000000000000000000..ed1a4c36cd4357c254f3c8ee282aefa20f57cb56
--- /dev/null
+++ b/src/ncurses_refs.c
@@ -0,0 +1,23 @@
+#include <curses.h>
+
+/* Zig @cImport() has problems with the ACS_* macros. Two, in fact:
+ *
+ * 1. Naively using the ACS_* macros results in:
+ *
+ *      error: cannot store runtime value in compile time variable
+ *      return acs_map[NCURSES_CAST(u8, c)];
+ *                    ^
+ *    That error doesn't make much sense to me, but it might be
+ *    related to https://github.com/ziglang/zig/issues/5344?
+ *
+ * 2. The 'acs_map' extern variable isn't being linked correctly?
+ *    Haven't investigated this one deeply enough yet, but attempting
+ *    to dereference acs_map from within Zig leads to a segfault;
+ *    its pointer value doesn't make any sense.
+ */
+chtype ncdu_acs_ulcorner() { return ACS_ULCORNER; }
+chtype ncdu_acs_llcorner() { return ACS_LLCORNER; }
+chtype ncdu_acs_urcorner() { return ACS_URCORNER; }
+chtype ncdu_acs_lrcorner() { return ACS_LRCORNER; }
+chtype ncdu_acs_hline()    { return ACS_VLINE   ; }
+chtype ncdu_acs_vline()    { return ACS_HLINE   ; }
diff --git a/src/ui.zig b/src/ui.zig
index dff364294bf272a21ac2c2d2541d4a063dab9d1b..14608bd880d778191115946c4a83a811a2ca8f35 100644
--- a/src/ui.zig
+++ b/src/ui.zig
@@ -7,12 +7,15 @@ pub const c = @cImport({
     @cInclude("stdio.h");
     @cInclude("string.h");
     @cInclude("curses.h");
+    @cDefine("_X_OPEN_SOURCE", "1");
+    @cInclude("wchar.h");
+    @cInclude("locale.h");
 });
 
 var inited: bool = false;
 
-pub var rows: i32 = undefined;
-pub var cols: i32 = undefined;
+pub var rows: u32 = undefined;
+pub var cols: u32 = undefined;
 
 pub fn die(comptime fmt: []const u8, args: anytype) noreturn {
     deinit();
@@ -20,6 +23,131 @@ pub fn die(comptime fmt: []const u8, args: anytype) noreturn {
     std.process.exit(1);
 }
 
+var to_utf8_buf = std.ArrayList(u8).init(main.allocator);
+
+fn toUtf8BadChar(ch: u8) bool {
+    return switch (ch) {
+        0...0x1F, 0x7F => true,
+        else => false
+    };
+}
+
+// Utility function to convert a string to valid (mostly) printable UTF-8.
+// Invalid codepoints will be encoded as '\x##' strings.
+// Returns the given string if it's already valid, otherwise points to an
+// internal buffer that will be invalidated on the next call.
+// (Doesn't check for non-printable Unicode characters)
+// (This program assumes that the console locale is UTF-8, but file names may not be)
+pub fn toUtf8(in: [:0]const u8) ![:0]const u8 {
+    const hasBadChar = blk: {
+        for (in) |ch| if (toUtf8BadChar(ch)) break :blk true;
+        break :blk false;
+    };
+    if (!hasBadChar and std.unicode.utf8ValidateSlice(in)) return in;
+    var i: usize = 0;
+    to_utf8_buf.shrinkRetainingCapacity(0);
+    while (i < in.len) {
+        if (std.unicode.utf8ByteSequenceLength(in[i])) |cp_len| {
+            if (!toUtf8BadChar(in[i]) and i + cp_len <= in.len) {
+                if (std.unicode.utf8Decode(in[i .. i + cp_len])) |_| {
+                    try to_utf8_buf.appendSlice(in[i .. i + cp_len]);
+                    i += cp_len;
+                    continue;
+                } else |_| {}
+            }
+        } else |_| {}
+        try to_utf8_buf.writer().print("\\x{X:0>2}", .{in[i]});
+        i += 1;
+    }
+    return try to_utf8_buf.toOwnedSliceSentinel(0);
+}
+
+var shorten_buf = std.ArrayList(u8).init(main.allocator);
+
+// Shorten the given string to fit in the given number of columns.
+// If the string is too long, only the prefix and suffix will be printed, with '...' in between.
+// Input is assumed to be valid UTF-8.
+// Return value points to the input string or to an internal buffer that is
+// invalidated on a subsequent call.
+pub fn shorten(in: [:0]const u8, max_width: u32) ![:0] const u8 {
+    if (max_width < 4) return "...";
+    var total_width: u32 = 0;
+    var prefix_width: u32 = 0;
+    var prefix_end: u32 = 0;
+    var it = std.unicode.Utf8View.initUnchecked(in).iterator();
+    while (it.nextCodepoint()) |cp| {
+        // XXX: libc assumption: wchar_t is a Unicode point. True for most modern libcs?
+        // (The "proper" way is to use mbtowc(), but I'd rather port the musl wcwidth implementation to Zig so that I *know* it'll be Unicode.
+        // On the other hand, ncurses also use wcwidth() so that would cause duplicated code. Ugh)
+        const cp_width_ = c.wcwidth(cp);
+        const cp_width = @intCast(u32, if (cp_width_ < 0) 1 else cp_width_);
+        const cp_len = std.unicode.utf8CodepointSequenceLength(cp) catch unreachable;
+        total_width += cp_width;
+        if (prefix_width + cp_width <= @divFloor(max_width-1, 2)-1) {
+            prefix_width += cp_width;
+            prefix_end += cp_len;
+            continue;
+        }
+    }
+    if (total_width <= max_width) return in;
+
+    shorten_buf.shrinkRetainingCapacity(0);
+    try shorten_buf.appendSlice(in[0..prefix_end]);
+    try shorten_buf.appendSlice("...");
+
+    var start_width: u32 = prefix_width;
+    var start_len: u32 = prefix_end;
+    it = std.unicode.Utf8View.initUnchecked(in[prefix_end..]).iterator();
+    while (it.nextCodepoint()) |cp| {
+        const cp_width_ = c.wcwidth(cp);
+        const cp_width = @intCast(u32, if (cp_width_ < 0) 1 else cp_width_);
+        const cp_len = std.unicode.utf8CodepointSequenceLength(cp) catch unreachable;
+        start_width += cp_width;
+        start_len += cp_len;
+        if (total_width - start_width <= max_width - prefix_width - 3) {
+            try shorten_buf.appendSlice(in[start_len..]);
+            break;
+        }
+    }
+    return try shorten_buf.toOwnedSliceSentinel(0);
+}
+
+fn shortenTest(in: [:0]const u8, max_width: u32, out: [:0]const u8) void {
+    std.testing.expectEqualStrings(out, shorten(in, max_width) catch unreachable);
+}
+
+test "shorten" {
+    _ = c.setlocale(c.LC_ALL, ""); // libc wcwidth() may not recognize Unicode without this
+    const t = shortenTest;
+    t("abcde", 3, "...");
+    t("abcde", 5, "abcde");
+    t("abcde", 4, "...e");
+    t("abcdefgh", 6, "a...gh");
+    t("abcdefgh", 7, "ab...gh");
+    t("ABCDEFGH", 16, "ABCDEFGH");
+    t("ABCDEFGH", 7, "A...H");
+    t("ABCDEFGH", 8, "A...H");
+    t("ABCDEFGH", 9, "A...GH");
+    t("AaBCDEFGH", 8, "A...H"); // could optimize this, but w/e
+    t("ABCDEFGaH", 8, "A...aH");
+    t("ABCDEFGH", 15, "ABC...FGH");
+}
+
+// ncurses_refs.c
+extern fn ncdu_acs_ulcorner() c.chtype;
+extern fn ncdu_acs_llcorner() c.chtype;
+extern fn ncdu_acs_urcorner() c.chtype;
+extern fn ncdu_acs_lrcorner() c.chtype;
+extern fn ncdu_acs_hline()    c.chtype;
+extern fn ncdu_acs_vline()    c.chtype;
+
+pub fn acs_ulcorner() c.chtype { return ncdu_acs_ulcorner(); }
+pub fn acs_llcorner() c.chtype { return ncdu_acs_llcorner(); }
+pub fn acs_urcorner() c.chtype { return ncdu_acs_urcorner(); }
+pub fn acs_lrcorner() c.chtype { return ncdu_acs_lrcorner(); }
+pub fn acs_hline()    c.chtype { return ncdu_acs_hline()   ; }
+pub fn acs_vline()    c.chtype { return ncdu_acs_vline()   ; }
+
 const StyleAttr = struct { fg: i16, bg: i16, attr: u32 };
 const StyleDef = struct {
     name: []const u8,
@@ -101,14 +229,10 @@ const Style = lbl: {
     });
 };
 
-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);
+    rows = @intCast(u32, c.getmaxy(c.stdscr));
+    cols = @intCast(u32, c.getmaxx(c.stdscr));
 }
 
 pub fn init() void {
@@ -143,3 +267,26 @@ pub fn deinit() void {
     _ = c.endwin();
     inited = false;
 }
+
+pub fn style(s: Style) void {
+    _ = c.attr_set(styles[@enumToInt(s)].style().attr, @enumToInt(s)+1, null);
+}
+
+pub fn move(y: u32, x: u32) void {
+    _ = c.move(@intCast(i32, y), @intCast(i32, x));
+}
+
+// Wraps to the next line if the text overflows, not sure how to disable that.
+// (Well, addchstr() does that, but not entirely sure I want to go that way.
+// Does that even work with UTF-8? Or do I really need to go wchar madness?)
+pub fn addstr(s: [:0]const u8) void {
+    _ = c.addstr(s);
+}
+
+pub fn addch(ch: c.chtype) void {
+    _ = c.addch(ch);
+}
+
+pub fn hline(ch: c.chtype, len: u32) void {
+    _ = c.hline(ch, @intCast(i32, len));
+}