diff --git a/build.zig.zon b/build.zig.zon
index 6192c1a..4ca04f8 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -2,15 +2,15 @@
     .name = .vaxis,
     .fingerprint = 0x14fbbb94fc556305,
     .version = "0.5.1",
-    .minimum_zig_version = "0.15.1",
+    .minimum_zig_version = "0.16.0-dev.2261+d6b3dd25a",
     .dependencies = .{
         .zigimg = .{
-            .url = "git+https://github.com/zigimg/zigimg#eab2522c023b9259db8b13f2f90d609b7437e5f6",
-            .hash = "zigimg-0.1.0-8_eo2vUZFgAAtN1c6dAO5DdqL0d4cEWHtn6iR5ucZJti",
+            .url = "https://github.com/unexge/zigimg/archive/refs/heads/master.zip",
+            .hash = "zigimg-0.1.0-8_eo2tGrFwD-VzWa_E8HuIuNenM2US7PRXBj5EJdGfrw",
         },
         .uucode = .{
-            .url = "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732",
-            .hash = "uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM",
+            .url = "https://github.com/jacobsandlund/uucode/archive/refs/heads/zig-0.16.zip",
+            .hash = "uucode-0.2.0-ZZjBPkJiVABQGPdyw9BOtU2sflHv3vovBzmYtPRj2hvm",
         },
     },
     .paths = .{
diff --git a/examples/cli.zig b/examples/cli.zig
index 3b44676..2b9a288 100644
--- a/examples/cli.zig
+++ b/examples/cli.zig
@@ -4,30 +4,23 @@ const Cell = vaxis.Cell;
 const TextInput = vaxis.widgets.TextInput;
 
 const log = std.log.scoped(.main);
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer {
-        const deinit_status = gpa.deinit();
-        if (deinit_status == .leak) {
-            log.err("memory leak", .{});
-        }
-    }
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
 
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     defer tty.deinit();
 
     var vx = try vaxis.init(alloc, .{});
     defer vx.deinit(alloc, tty.writer());
 
-    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx };
+    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx, .io = init.io, .queue = .{ .io = init.io } };
     try loop.init();
 
     try loop.start();
     defer loop.stop();
 
-    try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s);
+    try vx.queryTerminal(tty.writer(), init.io, 1 * std.time.ns_per_s);
 
     var text_input = TextInput.init(alloc);
     defer text_input.deinit();
diff --git a/examples/counter.zig b/examples/counter.zig
index d19af42..e68420b 100644
--- a/examples/counter.zig
+++ b/examples/counter.zig
@@ -111,13 +111,10 @@ const Model = struct {
     }
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer _ = gpa.deinit();
+pub fn main(init: std.process.Init) !void {
+    const allocator = init.gpa;
 
-    const allocator = gpa.allocator();
-
-    var app = try vxfw.App.init(allocator);
+    var app = try vxfw.App.init(allocator, init.io);
     defer app.deinit();
 
     // We heap allocate our model because we will require a stable pointer to it in our Button
diff --git a/examples/fuzzy.zig b/examples/fuzzy.zig
index 6ffbe6a..7fef980 100644
--- a/examples/fuzzy.zig
+++ b/examples/fuzzy.zig
@@ -11,7 +11,6 @@ const Model = struct {
 
     /// Used for filtered RichText Spans and result
     arena: std.heap.ArenaAllocator,
-    filtered: std.ArrayList(vxfw.RichText),
     result: []const u8,
 
     pub fn init(gpa: std.mem.Allocator) !*Model {
@@ -201,31 +200,24 @@ fn toLower(arena: std.mem.Allocator, src: []const u8) std.mem.Allocator.Error![]
     return lower;
 }
 
-pub fn main() !void {
-    var debug_allocator = std.heap.GeneralPurposeAllocator(.{}){};
-    defer _ = debug_allocator.deinit();
-
-    const gpa = debug_allocator.allocator();
+pub fn main(init: std.process.Init) !void {
+    const gpa = init.gpa;
 
-    var app = try vxfw.App.init(gpa);
+    var app = try vxfw.App.init(gpa, init.io);
     errdefer app.deinit();
 
     const model = try Model.init(gpa);
     defer model.deinit(gpa);
 
     // Run the command
-    var fd = std.process.Child.init(&.{"fd"}, gpa);
-    fd.stdout_behavior = .Pipe;
-    fd.stderr_behavior = .Pipe;
-    var stdout = std.ArrayList(u8).empty;
-    var stderr = std.ArrayList(u8).empty;
-    defer stdout.deinit(gpa);
-    defer stderr.deinit(gpa);
-    try fd.spawn();
-    try fd.collectOutput(gpa, &stdout, &stderr, 10_000_000);
-    _ = try fd.wait();
-
-    var iter = std.mem.splitScalar(u8, stdout.items, '\n');
+    const result = try std.process.run(gpa, init.io, .{
+        .argv = &.{"fd"},
+        .stdout_limit = .limited(10_000_000),
+    });
+    defer gpa.free(result.stdout);
+    defer gpa.free(result.stderr);
+
+    var iter = std.mem.splitScalar(u8, result.stdout, '\n');
     while (iter.next()) |line| {
         if (line.len == 0) continue;
         try model.list.append(gpa, .{ .text = line });
@@ -235,8 +227,8 @@ pub fn main() !void {
     app.deinit();
 
     if (model.result.len > 0) {
-        _ = try std.posix.write(std.posix.STDOUT_FILENO, model.result);
-        _ = try std.posix.write(std.posix.STDOUT_FILENO, "\n");
+        try std.Io.File.stdout().writeStreamingAll(init.io, model.result);
+        try std.Io.File.stdout().writeStreamingAll(init.io, "\n");
     } else {
         std.process.exit(130);
     }
diff --git a/examples/image.zig b/examples/image.zig
index c14bbdb..af62862 100644
--- a/examples/image.zig
+++ b/examples/image.zig
@@ -8,41 +8,34 @@ const Event = union(enum) {
     winsize: vaxis.Winsize,
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer {
-        const deinit_status = gpa.deinit();
-        if (deinit_status == .leak) {
-            log.err("memory leak", .{});
-        }
-    }
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
 
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     defer tty.deinit();
 
     var vx = try vaxis.init(alloc, .{});
     defer vx.deinit(alloc, tty.writer());
 
-    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx };
+    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx, .io = init.io, .queue = .{ .io = init.io } };
     try loop.init();
 
     try loop.start();
     defer loop.stop();
 
     try vx.enterAltScreen(tty.writer());
-    try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s);
+    try vx.queryTerminal(tty.writer(), init.io, 1 * std.time.ns_per_s);
 
     var read_buffer: [1024 * 1024]u8 = undefined; // 1MB buffer
-    var img1 = try vaxis.zigimg.Image.fromFilePath(alloc, "examples/zig.png", &read_buffer);
+    var img1 = try vaxis.zigimg.Image.fromFilePath(alloc, init.io, "examples/zig.png", &read_buffer);
     defer img1.deinit(alloc);
 
     const imgs = [_]vaxis.Image{
         try vx.transmitImage(alloc, tty.writer(), &img1, .rgba),
         // var img1 = try vaxis.zigimg.Image.fromFilePath(alloc, "examples/zig.png");
-        // try vx.loadImage(alloc, tty.writer(), .{ .path = "examples/zig.png" }),
-        try vx.loadImage(alloc, tty.writer(), .{ .path = "examples/vaxis.png" }),
+        // try vx.loadImage(alloc, init.io, tty.writer(), .{ .path = "examples/zig.png" }),
+        try vx.loadImage(alloc, init.io, tty.writer(), .{ .path = "examples/vaxis.png" }),
     };
     defer vx.freeImage(tty.writer(), imgs[0].id);
     defer vx.freeImage(tty.writer(), imgs[1].id);
diff --git a/examples/list_view.zig b/examples/list_view.zig
index 24cbf8d..5b961e9 100644
--- a/examples/list_view.zig
+++ b/examples/list_view.zig
@@ -52,13 +52,10 @@ const Model = struct {
     }
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer _ = gpa.deinit();
+pub fn main(init: std.process.Init) !void {
+    const allocator = init.gpa;
 
-    const allocator = gpa.allocator();
-
-    var app = try vxfw.App.init(allocator);
+    var app = try vxfw.App.init(allocator, init.io);
     defer app.deinit();
 
     const model = try allocator.create(Model);
diff --git a/examples/main.zig b/examples/main.zig
index 2a4b96b..3caa7d9 100644
--- a/examples/main.zig
+++ b/examples/main.zig
@@ -3,25 +3,17 @@ const vaxis = @import("vaxis");
 const Cell = vaxis.Cell;
 
 const log = std.log.scoped(.main);
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer {
-        const deinit_status = gpa.deinit();
-        //fail test; can't try in defer as defer is executed after we return
-        if (deinit_status == .leak) {
-            log.err("memory leak", .{});
-        }
-    }
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
 
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     defer tty.deinit();
 
     var vx = try vaxis.init(alloc, .{});
     defer vx.deinit(alloc, tty.writer());
 
-    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx };
+    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx, .io = init.io, .queue = .{ .io = init.io } };
     try loop.init();
 
     try loop.start();
@@ -29,7 +21,7 @@ pub fn main() !void {
 
     // Optionally enter the alternate screen
     try vx.enterAltScreen(tty.writer());
-    try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s);
+    try vx.queryTerminal(tty.writer(), init.io, 1 * std.time.ns_per_s);
 
     // We'll adjust the color index every keypress
     var color_idx: u8 = 0;
diff --git a/examples/scroll.zig b/examples/scroll.zig
index 0f92573..adf1f44 100644
--- a/examples/scroll.zig
+++ b/examples/scroll.zig
@@ -151,13 +151,10 @@ const Model = struct {
     }
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer _ = gpa.deinit();
+pub fn main(init: std.process.Init) !void {
+    const allocator = init.gpa;
 
-    const allocator = gpa.allocator();
-
-    var app = try vxfw.App.init(allocator);
+    var app = try vxfw.App.init(allocator, init.io);
     errdefer app.deinit();
 
     var arena = std.heap.ArenaAllocator.init(allocator);
diff --git a/examples/split_view.zig b/examples/split_view.zig
index 78d4e6f..5c48432 100644
--- a/examples/split_view.zig
+++ b/examples/split_view.zig
@@ -49,13 +49,10 @@ const Model = struct {
     }
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer _ = gpa.deinit();
+pub fn main(init: std.process.Init) !void {
+    const allocator = init.gpa;
 
-    const allocator = gpa.allocator();
-
-    var app = try vxfw.App.init(allocator);
+    var app = try vxfw.App.init(allocator, init.io);
     defer app.deinit();
 
     const model = try allocator.create(Model);
diff --git a/examples/table.zig b/examples/table.zig
index 430c3d5..c489828 100644
--- a/examples/table.zig
+++ b/examples/table.zig
@@ -14,16 +14,14 @@ const ActiveSection = enum {
     btm,
 };
 
-pub fn main() !void {
-    var gpa = heap.GeneralPurposeAllocator(.{}){};
-    defer if (gpa.detectLeaks()) log.err("Memory leak detected!", .{});
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
 
     // Users set up below the main function
     const users_buf = try alloc.dupe(User, users[0..]);
 
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     defer tty.deinit();
     const tty_writer = tty.writer();
     var vx = try vaxis.init(alloc, .{
@@ -35,12 +33,12 @@ pub fn main() !void {
         key_press: vaxis.Key,
         winsize: vaxis.Winsize,
         table_upd,
-    }) = .{ .tty = &tty, .vaxis = &vx };
+    }) = .{ .tty = &tty, .vaxis = &vx, .io = init.io, .queue = .{ .io = init.io } };
     try loop.init();
     try loop.start();
     defer loop.stop();
     try vx.enterAltScreen(tty.writer());
-    try vx.queryTerminal(tty.writer(), 250 * std.time.ns_per_ms);
+    try vx.queryTerminal(tty.writer(), init.io, 250 * std.time.ns_per_ms);
 
     const logo =
         \\░█░█░█▀█░█░█░▀█▀░█▀▀░░░▀█▀░█▀█░█▀▄░█░░░█▀▀░
diff --git a/examples/text_input.zig b/examples/text_input.zig
index cea0466..5c3c6c6 100644
--- a/examples/text_input.zig
+++ b/examples/text_input.zig
@@ -18,20 +18,12 @@ const Event = union(enum) {
     foo: u8,
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer {
-        const deinit_status = gpa.deinit();
-        //fail test; can't try in defer as defer is executed after we return
-        if (deinit_status == .leak) {
-            log.err("memory leak", .{});
-        }
-    }
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
 
     // Initalize a tty
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     defer tty.deinit();
 
     // Use a buffered writer for better performance. There are a lot of writes
@@ -47,6 +39,8 @@ pub fn main() !void {
     var loop: vaxis.Loop(Event) = .{
         .vaxis = &vx,
         .tty = &tty,
+        .io = init.io,
+        .queue = .{ .io = init.io },
     };
     try loop.init();
 
@@ -71,7 +65,7 @@ pub fn main() !void {
     try writer.flush();
     // Sends queries to terminal to detect certain features. This should
     // _always_ be called, but is left to the application to decide when
-    try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s);
+    try vx.queryTerminal(tty.writer(), init.io, 1 * std.time.ns_per_s);
 
     // The main event loop. Vaxis provides a thread safe, blocking, buffered
     // queue which can serve as the primary event queue for an application
@@ -94,8 +88,10 @@ pub fn main() !void {
                 } else if (key.matches('n', .{ .ctrl = true })) {
                     try vx.notify(tty.writer(), "vaxis", "hello from vaxis");
                     loop.stop();
-                    var child = std.process.Child.init(&.{"nvim"}, alloc);
-                    _ = try child.spawnAndWait();
+                    var child = try std.process.spawn(init.io, .{
+                        .argv = &.{"nvim"},
+                    });
+                    _ = try child.wait(init.io);
                     try loop.start();
                     try vx.enterAltScreen(tty.writer());
                     vx.queueRefresh();
diff --git a/examples/text_view.zig b/examples/text_view.zig
index 73e8e54..d49cb5b 100644
--- a/examples/text_view.zig
+++ b/examples/text_view.zig
@@ -9,31 +9,24 @@ const Event = union(enum) {
     winsize: vaxis.Winsize,
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-
-    defer {
-        const deinit_status = gpa.deinit();
-        if (deinit_status == .leak) {
-            log.err("memory leak", .{});
-        }
-    }
-
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     defer tty.deinit();
     var vx = try vaxis.init(alloc, .{});
     defer vx.deinit(alloc, tty.writer());
     var loop: vaxis.Loop(Event) = .{
         .vaxis = &vx,
         .tty = &tty,
+        .io = init.io,
+        .queue = .{ .io = init.io },
     };
     try loop.init();
     try loop.start();
     defer loop.stop();
     try vx.enterAltScreen(tty.writer());
-    try vx.queryTerminal(tty.writer(), 20 * std.time.ns_per_s);
+    try vx.queryTerminal(tty.writer(), init.io, 20 * std.time.ns_per_s);
     var text_view = TextView{};
     var text_view_buffer = TextView.Buffer{};
     defer text_view_buffer.deinit(alloc);
@@ -61,6 +54,6 @@ pub fn main() !void {
         win.clear();
         text_view.draw(win, text_view_buffer);
         try vx.render(tty.writer());
-        try tty.writer.flush();
+        try tty.writer().flush();
     }
 }
diff --git a/examples/vaxis.zig b/examples/vaxis.zig
index 5ca566c..8a12a66 100644
--- a/examples/vaxis.zig
+++ b/examples/vaxis.zig
@@ -9,32 +9,24 @@ const Event = union(enum) {
 
 pub const panic = vaxis.panic_handler;
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer {
-        const deinit_status = gpa.deinit();
-        //fail test; can't try in defer as defer is executed after we return
-        if (deinit_status == .leak) {
-            std.log.err("memory leak", .{});
-        }
-    }
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
 
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     defer tty.deinit();
 
     var vx = try vaxis.init(alloc, .{});
     defer vx.deinit(alloc, tty.writer());
 
-    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx };
+    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx, .io = init.io, .queue = .{ .io = init.io } };
     try loop.init();
 
     try loop.start();
     defer loop.stop();
 
     try vx.enterAltScreen(tty.writer());
-    try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s);
+    try vx.queryTerminal(tty.writer(), init.io, 1 * std.time.ns_per_s);
 
     try vx.queryColor(tty.writer(), .fg);
     try vx.queryColor(tty.writer(), .bg);
@@ -84,7 +76,7 @@ pub fn main() !void {
         // try vx.render(bw.writer().any());
         // try bw.flush();
         try vx.render(tty.writer());
-        std.Thread.sleep(16 * std.time.ns_per_ms);
+        try std.Io.sleep(init.io, std.Io.Duration.fromMilliseconds(16), .real);
         switch (dir) {
             .up => {
                 pct += 1;
diff --git a/examples/view.zig b/examples/view.zig
index 1b01dfd..3f5108a 100644
--- a/examples/view.zig
+++ b/examples/view.zig
@@ -13,15 +13,8 @@ const Event = union(enum) {
     winsize: vaxis.Winsize,
 };
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer {
-        const deinit_status = gpa.deinit();
-        if (deinit_status == .leak) {
-            log.err("memory leak", .{});
-        }
-    }
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
 
     var world_map: []const u8 = lg_world_map;
     var map_width = lg_map_width;
@@ -45,7 +38,7 @@ pub fn main() !void {
     });
 
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     defer tty.deinit();
 
     const writer = tty.writer();
@@ -58,13 +51,15 @@ pub fn main() !void {
     var loop: vaxis.Loop(Event) = .{
         .vaxis = &vx,
         .tty = &tty,
+        .io = init.io,
+        .queue = .{ .io = init.io },
     };
     try loop.init();
     try loop.start();
     defer loop.stop();
     try vx.enterAltScreen(writer);
     try writer.flush();
-    try vx.queryTerminal(tty.writer(), 20 * std.time.ns_per_s);
+    try vx.queryTerminal(tty.writer(), init.io, 20 * std.time.ns_per_s);
 
     // Initialize Views
     // - Large Map
diff --git a/examples/vt.zig b/examples/vt.zig
index fc13149..6d7f647 100644
--- a/examples/vt.zig
+++ b/examples/vt.zig
@@ -9,33 +9,24 @@ const Event = union(enum) {
 
 pub const panic = vaxis.panic_handler;
 
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer {
-        const deinit_status = gpa.deinit();
-        //fail test; can't try in defer as defer is executed after we return
-        if (deinit_status == .leak) {
-            std.log.err("memory leak", .{});
-        }
-    }
-    const alloc = gpa.allocator();
+pub fn main(init: std.process.Init) !void {
+    const alloc = init.gpa;
 
     var buffer: [1024]u8 = undefined;
-    var tty = try vaxis.Tty.init(&buffer);
+    var tty = try vaxis.Tty.init(init.io, &buffer);
     const writer = tty.writer();
     var vx = try vaxis.init(alloc, .{});
     defer vx.deinit(alloc, writer);
 
-    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx };
+    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx, .io = init.io, .queue = .{ .io = init.io } };
     try loop.init();
 
     try loop.start();
     defer loop.stop();
 
     try vx.enterAltScreen(writer);
-    try vx.queryTerminal(writer, 1 * std.time.ns_per_s);
-    var env = try std.process.getEnvMap(alloc);
-    defer env.deinit();
+    try vx.queryTerminal(writer, init.io, 1 * std.time.ns_per_s);
+    const env = init.environ_map;
 
     const vt_opts: vaxis.widgets.Terminal.Options = .{
         .winsize = .{
@@ -52,8 +43,9 @@ pub fn main() !void {
     var write_buf: [4096]u8 = undefined;
     var vt = try vaxis.widgets.Terminal.init(
         alloc,
+        init.io,
         &argv,
-        &env,
+        env,
         vt_opts,
         &write_buf,
     );
@@ -62,7 +54,7 @@ pub fn main() !void {
 
     var redraw: bool = false;
     while (true) {
-        std.Thread.sleep(8 * std.time.ns_per_ms);
+        std.Io.sleep(init.io, .fromMilliseconds(8), .awake) catch {};
         // try vt events first
         while (vt.tryEvent()) |event| {
             redraw = true;
diff --git a/src/Loop.zig b/src/Loop.zig
index a11e2f7..cd7e9d9 100644
--- a/src/Loop.zig
+++ b/src/Loop.zig
@@ -18,8 +18,9 @@ pub fn Loop(comptime T: type) type {
 
         tty: *Tty,
         vaxis: *Vaxis,
+        io: std.Io,
 
-        queue: Queue(T, 512) = .{},
+        queue: Queue(T, 512),
         thread: ?std.Thread = null,
         should_quit: bool = false,
 
@@ -214,7 +215,7 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even
                     }
                 },
                 .cap_da1 => {
-                    std.Thread.Futex.wake(&vx.query_futex, 10);
+                    std.Io.futexWake(self.io, u32, &vx.query_futex.raw, 10);
                     vx.queries_done.store(true, .unordered);
                 },
                 .mouse => |mouse| {
@@ -359,7 +360,7 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even
                     vx.caps.multi_cursor = true;
                 },
                 .cap_da1 => {
-                    std.Thread.Futex.wake(&vx.query_futex, 10);
+                    std.Io.futexWake(self.io, u32, &vx.query_futex.raw, 10);
                     vx.queries_done.store(true, .unordered);
                 },
                 .winsize => |winsize| {
@@ -386,13 +387,13 @@ test Loop {
         foo: u8,
     };
 
-    var tty = try vaxis.Tty.init(&.{});
+    var tty = try vaxis.Tty.init(std.testing.io);
     defer tty.deinit();
 
     var vx = try vaxis.init(std.testing.allocator, .{});
     defer vx.deinit(std.testing.allocator, tty.writer());
 
-    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx };
+    var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx, .io = std.testing.io, .queue = .{ .io = std.testing.io } };
     try loop.init();
 
     try loop.start();
@@ -400,5 +401,5 @@ test Loop {
 
     // Optionally enter the alternate screen
     try vx.enterAltScreen(tty.writer());
-    try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_ms);
+    try vx.queryTerminal(tty.writer(), std.testing.io, 1 * std.time.ns_per_ms);
 }
diff --git a/src/Parser.zig b/src/Parser.zig
index 6358269..1b125dd 100644
--- a/src/Parser.zig
+++ b/src/Parser.zig
@@ -118,7 +118,7 @@ inline fn parseGround(input: []const u8) !Result {
             var grapheme_len: usize = 0;
             var cp_count: usize = 0;
 
-            while (grapheme_iter.next()) |result| {
+            while (grapheme_iter.nextCodePoint()) |result| {
                 cp_count += 1;
                 if (result.is_break) {
                     // Found the first grapheme boundary
diff --git a/src/Vaxis.zig b/src/Vaxis.zig
index a6a3830..2e24e9b 100644
--- a/src/Vaxis.zig
+++ b/src/Vaxis.zig
@@ -3,7 +3,7 @@ const builtin = @import("builtin");
 const atomic = std.atomic;
 const base64Encoder = std.base64.standard.Encoder;
 const zigimg = @import("zigimg");
-const IoWriter = std.io.Writer;
+const IoWriter = std.Io.Writer;
 
 const Cell = @import("Cell.zig");
 const Image = @import("Image.zig");
@@ -250,10 +250,10 @@ pub fn exitAltScreen(self: *Vaxis, tty: *IoWriter) !void {
 ///
 /// This call will block until Vaxis.query_futex is woken up, or the timeout.
 /// Event loops can wake up this futex when cap_da1 is received
-pub fn queryTerminal(self: *Vaxis, tty: *IoWriter, timeout_ns: u64) !void {
+pub fn queryTerminal(self: *Vaxis, tty: *IoWriter, io: std.Io, timeout_ns: u64) !void {
     try self.queryTerminalSend(tty);
     // 1 second timeout
-    std.Thread.Futex.timedWait(&self.query_futex, 0, timeout_ns) catch {};
+    std.Io.futexWaitTimeout(io, u32, &self.query_futex.raw, 0, .{ .duration = .{ .raw = .{ .nanoseconds = @intCast(timeout_ns) }, .clock = .awake } }) catch {};
     self.queries_done.store(true, .unordered);
     try self.enableDetectedFeatures(tty);
 }
@@ -265,7 +265,7 @@ pub fn queryTerminalSend(vx: *Vaxis, tty: *IoWriter) !void {
     vx.queries_done.store(false, .unordered);
 
     // TODO: re-enable this
-    // const colorterm = std.posix.getenv("COLORTERM") orelse "";
+    // const colorterm = std.c.getenv("COLORTERM") orelse "";
     // if (std.mem.eql(u8, colorterm, "truecolor") or
     //     std.mem.eql(u8, colorterm, "24bit"))
     // {
@@ -320,22 +320,22 @@ pub fn enableDetectedFeatures(self: *Vaxis, tty: *IoWriter) !void {
         },
         else => {
             // Apply any environment variables
-            if (std.posix.getenv("TERMUX_VERSION")) |_|
+            if (std.c.getenv("TERMUX_VERSION")) |_|
                 self.sgr = .legacy;
-            if (std.posix.getenv("VHS_RECORD")) |_| {
+            if (std.c.getenv("VHS_RECORD")) |_| {
                 self.caps.unicode = .wcwidth;
                 self.caps.kitty_keyboard = false;
                 self.sgr = .legacy;
             }
-            if (std.posix.getenv("TERM_PROGRAM")) |prg| {
-                if (std.mem.eql(u8, prg, "vscode"))
+            if (std.c.getenv("TERM_PROGRAM")) |prg| {
+                if (std.mem.eql(u8, std.mem.span(prg), "vscode"))
                     self.sgr = .legacy;
             }
-            if (std.posix.getenv("VAXIS_FORCE_LEGACY_SGR")) |_|
+            if (std.c.getenv("VAXIS_FORCE_LEGACY_SGR")) |_|
                 self.sgr = .legacy;
-            if (std.posix.getenv("VAXIS_FORCE_WCWIDTH")) |_|
+            if (std.c.getenv("VAXIS_FORCE_WCWIDTH")) |_|
                 self.caps.unicode = .wcwidth;
-            if (std.posix.getenv("VAXIS_FORCE_UNICODE")) |_|
+            if (std.c.getenv("VAXIS_FORCE_UNICODE")) |_|
                 self.caps.unicode = .unicode;
 
             // enable detected features
@@ -1067,6 +1067,7 @@ pub fn transmitImage(
 pub fn loadImage(
     self: *Vaxis,
     alloc: std.mem.Allocator,
+    io: std.Io,
     tty: *IoWriter,
     src: Image.Source,
 ) !Image {
@@ -1074,7 +1075,7 @@ pub fn loadImage(
 
     var read_buffer: [1024 * 1024]u8 = undefined; // 1MB buffer
     var img = switch (src) {
-        .path => |path| try zigimg.Image.fromFilePath(alloc, path, &read_buffer),
+        .path => |path| try zigimg.Image.fromFilePath(alloc, io, path, &read_buffer),
         .mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes),
     };
     defer img.deinit(alloc);
@@ -1481,7 +1482,7 @@ pub fn setTerminalWorkingDirectory(_: *Vaxis, tty: *IoWriter, path: []const u8)
         return error.InvalidAbsolutePath;
     const hostname = switch (builtin.os.tag) {
         .windows => null,
-        else => std.posix.getenv("HOSTNAME"),
+        else => std.c.getenv("HOSTNAME"),
     } orelse "localhost";
 
     const uri: std.Uri = .{
@@ -1495,11 +1496,11 @@ pub fn setTerminalWorkingDirectory(_: *Vaxis, tty: *IoWriter, path: []const u8)
 
 test "render: no output when no changes" {
     var vx = try Vaxis.init(std.testing.allocator, .{});
-    var deinit_writer = std.io.Writer.Allocating.init(std.testing.allocator);
+    var deinit_writer: std.Io.Writer.Allocating = .init(std.testing.allocator);
     defer deinit_writer.deinit();
     defer vx.deinit(std.testing.allocator, &deinit_writer.writer);
 
-    var render_writer = std.io.Writer.Allocating.init(std.testing.allocator);
+    var render_writer: std.Io.Writer.Allocating = .init(std.testing.allocator);
     defer render_writer.deinit();
     try vx.render(&render_writer.writer);
     const output = try render_writer.toOwnedSlice();
diff --git a/src/gwidth.zig b/src/gwidth.zig
index 194139f..ecffaf7 100644
--- a/src/gwidth.zig
+++ b/src/gwidth.zig
@@ -55,10 +55,10 @@ pub fn gwidth(str: []const u8, method: Method) u16 {
             var grapheme_start: usize = 0;
             var prev_break: bool = true;
 
-            while (grapheme_iter.next()) |result| {
+            while (grapheme_iter.nextCodePoint()) |result| {
                 if (prev_break and !result.is_break) {
                     // Start of a new grapheme
-                    const cp_len: usize = std.unicode.utf8CodepointSequenceLength(result.cp) catch 1;
+                    const cp_len: usize = std.unicode.utf8CodepointSequenceLength(result.code_point) catch 1;
                     grapheme_start = grapheme_iter.i - cp_len;
                 }
 
diff --git a/src/queue.zig b/src/queue.zig
index 6d847ea..908cf70 100644
--- a/src/queue.zig
+++ b/src/queue.zig
@@ -1,7 +1,7 @@
 const std = @import("std");
 const assert = std.debug.assert;
 const atomic = std.atomic;
-const Condition = std.Thread.Condition;
+const Condition = std.Io.Condition;
 
 /// Thread safe. Fixed size. Blocking push and pop.
 pub fn Queue(
@@ -11,23 +11,25 @@ pub fn Queue(
     return struct {
         buf: [size]T = undefined,
 
+        io: std.Io,
+
         read_index: usize = 0,
         write_index: usize = 0,
 
-        mutex: std.Thread.Mutex = .{},
+        mutex: std.Io.Mutex = .init,
         // blocks when the buffer is full
-        not_full: Condition = .{},
+        not_full: Condition = .init,
         // ...or empty
-        not_empty: Condition = .{},
+        not_empty: Condition = .init,
 
         const Self = @This();
 
         /// Pop an item from the queue. Blocks until an item is available.
         pub fn pop(self: *Self) T {
-            self.mutex.lock();
-            defer self.mutex.unlock();
+            self.mutex.lockUncancelable(self.io);
+            defer self.mutex.unlock(self.io);
             while (self.isEmptyLH()) {
-                self.not_empty.wait(&self.mutex);
+                self.not_empty.waitUncancelable(self.io, &self.mutex);
             }
             std.debug.assert(!self.isEmptyLH());
             return self.popAndSignalLH();
@@ -36,10 +38,10 @@ pub fn Queue(
         /// Push an item into the queue. Blocks until an item has been
         /// put in the queue.
         pub fn push(self: *Self, item: T) void {
-            self.mutex.lock();
-            defer self.mutex.unlock();
+            self.mutex.lockUncancelable(self.io);
+            defer self.mutex.unlock(self.io);
             while (self.isFullLH()) {
-                self.not_full.wait(&self.mutex);
+                self.not_full.waitUncancelable(self.io, &self.mutex);
             }
             std.debug.assert(!self.isFullLH());
             self.pushAndSignalLH(item);
@@ -49,8 +51,8 @@ pub fn Queue(
         /// was successfully placed in the queue, false if the queue
         /// was full.
         pub fn tryPush(self: *Self, item: T) bool {
-            self.mutex.lock();
-            defer self.mutex.unlock();
+            self.mutex.lockUncancelable(self.io);
+            defer self.mutex.unlock(self.io);
             if (self.isFullLH()) return false;
             self.pushAndSignalLH(item);
             return true;
@@ -59,28 +61,28 @@ pub fn Queue(
         /// Pop an item from the queue. Returns null when no item is
         /// available.
         pub fn tryPop(self: *Self) ?T {
-            self.mutex.lock();
-            defer self.mutex.unlock();
+            self.mutex.lockUncancelable(self.io);
+            defer self.mutex.unlock(self.io);
             if (self.isEmptyLH()) return null;
             return self.popAndSignalLH();
         }
 
         /// Poll the queue. This call blocks until events are in the queue
         pub fn poll(self: *Self) void {
-            self.mutex.lock();
-            defer self.mutex.unlock();
+            self.mutex.lockUncancelable(self.io);
+            defer self.mutex.unlock(self.io);
             while (self.isEmptyLH()) {
-                self.not_empty.wait(&self.mutex);
+                self.not_empty.waitUncancelable(self.io, &self.mutex);
             }
             std.debug.assert(!self.isEmptyLH());
         }
 
         pub fn lock(self: *Self) void {
-            self.mutex.lock();
+            self.mutex.lockUncancelable(self.io);
         }
 
         pub fn unlock(self: *Self) void {
-            self.mutex.unlock();
+            self.mutex.unlock(self.io);
         }
 
         /// Used to efficiently drain the queue while the lock is externally held
@@ -92,7 +94,7 @@ pub fn Queue(
             const was_full = self.isFullLH();
             const item = self.popLH();
             if (was_full) {
-                self.not_full.signal();
+                self.not_full.signal(self.io);
             }
             return item;
         }
@@ -108,15 +110,15 @@ pub fn Queue(
 
         /// Returns `true` if the queue is empty and `false` otherwise.
         pub fn isEmpty(self: *Self) bool {
-            self.mutex.lock();
-            defer self.mutex.unlock();
+            self.mutex.lockUncancelable(self.io);
+            defer self.mutex.unlock(self.io);
             return self.isEmptyLH();
         }
 
         /// Returns `true` if the queue is full and `false` otherwise.
         pub fn isFull(self: *Self) bool {
-            self.mutex.lock();
-            defer self.mutex.unlock();
+            self.mutex.lockUncancelable(self.io);
+            defer self.mutex.unlock(self.io);
             return self.isFullLH();
         }
 
@@ -143,7 +145,7 @@ pub fn Queue(
             self.buf[self.mask(self.write_index)] = item;
             self.write_index = self.mask2(self.write_index + 1);
             if (was_empty) {
-                self.not_empty.signal();
+                self.not_empty.signal(self.io);
             }
         }
 
@@ -151,7 +153,7 @@ pub fn Queue(
             const was_full = self.isFullLH();
             const result = self.popLH();
             if (was_full) {
-                self.not_full.signal();
+                self.not_full.signal(self.io);
             }
             return result;
         }
@@ -167,7 +169,7 @@ pub fn Queue(
 const testing = std.testing;
 const cfg = Thread.SpawnConfig{ .allocator = testing.allocator };
 test "Queue: simple push / pop" {
-    var queue: Queue(u8, 16) = .{};
+    var queue: Queue(u8, 16) = .{ .io = std.testing.io };
     queue.push(1);
     queue.push(2);
     const pop = queue.pop();
@@ -182,7 +184,7 @@ fn testPushPop(q: *Queue(u8, 2)) !void {
 }
 
 test "Fill, wait to push, pop once in another thread" {
-    var queue: Queue(u8, 2) = .{};
+    var queue: Queue(u8, 2) = .{ .io = std.testing.io };
     queue.push(1);
     queue.push(2);
     const t = try Thread.spawn(cfg, testPushPop, .{&queue});
@@ -202,7 +204,7 @@ fn testPush(q: *Queue(u8, 2)) void {
 }
 
 test "Try to pop, fill from another thread" {
-    var queue: Queue(u8, 2) = .{};
+    var queue: Queue(u8, 2) = .{ .io = std.testing.io };
     const thread = try Thread.spawn(cfg, testPush, .{&queue});
     for (0..5) |idx| {
         try testing.expectEqual(@as(u8, @intCast(idx)), queue.pop());
@@ -217,8 +219,8 @@ fn sleepyPop(q: *Queue(u8, 2), state: *atomic.Value(u8)) !void {
 
     // Then we spuriously wake it up, because that's a thing that can
     // happen.
-    q.not_full.signal();
-    q.not_empty.signal();
+    q.not_full.signal(q.io);
+    q.not_empty.signal(q.io);
 
     // Then give the other thread a good chance of waking up. It's not
     // clear that yield guarantees the other thread will be scheduled,
@@ -226,7 +228,7 @@ fn sleepyPop(q: *Queue(u8, 2), state: *atomic.Value(u8)) !void {
     // still full and the push in the other thread is still blocked
     // waiting for space.
     try Thread.yield();
-    std.Thread.sleep(10 * std.time.ns_per_ms);
+    std.Io.sleep(std.testing.io, .fromMilliseconds(10), .awake) catch {};
     // Finally, let that other thread go.
     try std.testing.expectEqual(1, q.pop());
 
@@ -235,15 +237,15 @@ fn sleepyPop(q: *Queue(u8, 2), state: *atomic.Value(u8)) !void {
         try Thread.yield();
     // But we want to ensure that there's a second push waiting, so
     // here's another sleep.
-    std.Thread.sleep(10 * std.time.ns_per_ms);
+    std.Io.sleep(std.testing.io, .fromMilliseconds(10), .awake) catch {};
 
     // Another spurious wake...
-    q.not_full.signal();
-    q.not_empty.signal();
+    q.not_full.signal(q.io);
+    q.not_empty.signal(q.io);
     // And another chance for the other thread to see that it's
     // spurious and go back to sleep.
     try Thread.yield();
-    std.Thread.sleep(10 * std.time.ns_per_ms);
+    std.Io.sleep(std.testing.io, .fromMilliseconds(10), .awake) catch {};
 
     // Pop that thing and we're done.
     try std.testing.expectEqual(2, q.pop());
@@ -256,19 +258,20 @@ test "Fill, block, fill, block" {
     // that too (after some time) then drain the queue. This test
     // fails if the while loop in `push` is turned into an `if`.
 
-    var queue: Queue(u8, 2) = .{};
+    var queue: Queue(u8, 2) = .{ .io = std.testing.io };
     var state = atomic.Value(u8).init(0);
     const thread = try Thread.spawn(cfg, sleepyPop, .{ &queue, &state });
     queue.push(1);
     queue.push(2);
     state.store(1, .release);
-    const now = std.time.milliTimestamp();
+    const start = std.Io.Clock.Timestamp.now(std.testing.io, .awake);
     queue.push(3); // This one should block.
-    const then = std.time.milliTimestamp();
+    const end = std.Io.Clock.Timestamp.now(std.testing.io, .awake);
 
     // Just to make sure the sleeps are yielding to this thread, make
     // sure it took at least 5ms to do the push.
-    try std.testing.expect(then - now > 5);
+    const elapsed = start.durationTo(end);
+    try std.testing.expect(elapsed.raw.toMilliseconds() > 5);
 
     state.store(2, .release);
     // This should block again, waiting for the other thread.
@@ -283,14 +286,14 @@ test "Fill, block, fill, block" {
 fn sleepyPush(q: *Queue(u8, 1), state: *atomic.Value(u8)) !void {
     // Try to ensure the other thread has already started trying to pop.
     try Thread.yield();
-    std.Thread.sleep(10 * std.time.ns_per_ms);
+    std.Io.sleep(std.testing.io, .fromMilliseconds(10), .awake) catch {};
 
     // Spurious wake
-    q.not_full.signal();
-    q.not_empty.signal();
+    q.not_full.signal(q.io);
+    q.not_empty.signal(q.io);
 
     try Thread.yield();
-    std.Thread.sleep(10 * std.time.ns_per_ms);
+    std.Io.sleep(std.testing.io, .fromMilliseconds(10), .awake) catch {};
 
     // Stick something in the queue so it can be popped.
     q.push(1);
@@ -299,11 +302,11 @@ fn sleepyPush(q: *Queue(u8, 1), state: *atomic.Value(u8)) !void {
         try Thread.yield();
     // Give the other thread time to block again.
     try Thread.yield();
-    std.Thread.sleep(10 * std.time.ns_per_ms);
+    std.Io.sleep(std.testing.io, .fromMilliseconds(10), .awake) catch {};
 
     // Spurious wake
-    q.not_full.signal();
-    q.not_empty.signal();
+    q.not_full.signal(q.io);
+    q.not_empty.signal(q.io);
 
     q.push(2);
 }
@@ -313,7 +316,7 @@ test "Drain, block, drain, block" {
     // test should fail if the `while` loop in `pop` is turned into an
     // `if`.
 
-    var queue: Queue(u8, 1) = .{};
+    var queue: Queue(u8, 1) = .{ .io = std.testing.io };
     var state = atomic.Value(u8).init(0);
     const thread = try Thread.spawn(cfg, sleepyPush, .{ &queue, &state });
     try std.testing.expectEqual(1, queue.pop());
@@ -328,11 +331,11 @@ fn readerThread(q: *Queue(u8, 1)) !void {
 
 test "2 readers" {
     // 2 threads read, one thread writes
-    var queue: Queue(u8, 1) = .{};
+    var queue: Queue(u8, 1) = .{ .io = std.testing.io };
     const t1 = try Thread.spawn(cfg, readerThread, .{&queue});
     const t2 = try Thread.spawn(cfg, readerThread, .{&queue});
     try Thread.yield();
-    std.Thread.sleep(10 * std.time.ns_per_ms);
+    std.Io.sleep(std.testing.io, .fromMilliseconds(10), .awake) catch {};
     queue.push(1);
     queue.push(1);
     t1.join();
@@ -344,7 +347,7 @@ fn writerThread(q: *Queue(u8, 1)) !void {
 }
 
 test "2 writers" {
-    var queue: Queue(u8, 1) = .{};
+    var queue: Queue(u8, 1) = .{ .io = std.testing.io };
     const t1 = try Thread.spawn(cfg, writerThread, .{&queue});
     const t2 = try Thread.spawn(cfg, writerThread, .{&queue});
 
diff --git a/src/tty.zig b/src/tty.zig
index 4a3c142..c1da795 100644
--- a/src/tty.zig
+++ b/src/tty.zig
@@ -33,8 +33,11 @@ pub const PosixTty = struct {
     /// The file descriptor of the tty
     fd: posix.fd_t,
 
+    io: std.Io,
+
     /// File.Writer for efficient buffered writing
-    tty_writer: std.fs.File.Writer,
+    tty_buffer: [1024]u8 = undefined,
+    tty_writer: ?std.Io.File.Writer = null,
 
     pub const SignalHandler = struct {
         context: *anyopaque,
@@ -43,7 +46,8 @@ pub const PosixTty = struct {
 
     /// global signal handlers
     var handlers: [8]SignalHandler = undefined;
-    var handler_mutex: std.Thread.Mutex = .{};
+    var handler_mutex: std.Io.Mutex = .init;
+    var handler_io: std.Io = undefined;
     var handler_idx: usize = 0;
 
     var handler_installed: bool = false;
@@ -51,9 +55,9 @@ pub const PosixTty = struct {
     /// initializes a Tty instance by opening /dev/tty and "making it raw". A
     /// signal handler is installed for SIGWINCH. No callbacks are installed, be
     /// sure to register a callback when initializing the event loop
-    pub fn init(buffer: []u8) !PosixTty {
+    pub fn init(io: std.Io) !PosixTty {
         // Open our tty
-        const fd = try posix.open("/dev/tty", .{ .ACCMODE = .RDWR }, 0);
+        const fd = try posix.openat(posix.AT.FDCWD, "/dev/tty", .{ .ACCMODE = .RDWR }, 0);
 
         // Set the termios of the tty
         const termios = try makeRaw(fd);
@@ -69,13 +73,8 @@ pub const PosixTty = struct {
         posix.sigaction(posix.SIG.WINCH, &act, null);
         handler_installed = true;
 
-        const file = std.fs.File{ .handle = fd };
-
-        const self: PosixTty = .{
-            .fd = fd,
-            .termios = termios,
-            .tty_writer = .initStreaming(file, buffer),
-        };
+        handler_io = io;
+        const self: PosixTty = .{ .fd = fd, .io = io, .termios = termios };
 
         global_tty = self;
 
@@ -88,7 +87,7 @@ pub const PosixTty = struct {
             std.log.err("couldn't restore terminal: {}", .{err});
         };
         if (builtin.os.tag != .macos) // closing /dev/tty may block indefinitely on macos
-            posix.close(self.fd);
+            (std.Io.File{ .handle = self.fd, .flags = .{ .nonblocking = false } }).close(handler_io);
     }
 
     /// Resets the signal handler to it's default
@@ -107,7 +106,12 @@ pub const PosixTty = struct {
     }
 
     pub fn writer(self: *PosixTty) *std.Io.Writer {
-        return &self.tty_writer.interface;
+        if (self.tty_writer == null) {
+            const file: std.Io.File = .{ .handle = self.fd, .flags = .{ .nonblocking = false } };
+            self.tty_writer = .initStreaming(file, self.io, &self.tty_buffer);
+        }
+
+        return &self.tty_writer.?.interface;
     }
 
     pub fn read(self: *const PosixTty, buf: []u8) !usize {
@@ -117,16 +121,16 @@ pub const PosixTty = struct {
     /// Install a signal handler for winsize. A maximum of 8 handlers may be
     /// installed
     pub fn notifyWinsize(handler: SignalHandler) !void {
-        handler_mutex.lock();
-        defer handler_mutex.unlock();
+        handler_mutex.lockUncancelable(handler_io);
+        defer handler_mutex.unlock(handler_io);
         if (handler_idx == handlers.len) return error.OutOfMemory;
         handlers[handler_idx] = handler;
         handler_idx += 1;
     }
 
-    fn handleWinch(_: c_int) callconv(.c) void {
-        handler_mutex.lock();
-        defer handler_mutex.unlock();
+    fn handleWinch(_: posix.SIG) callconv(.c) void {
+        if (!handler_mutex.tryLock()) return;
+        defer handler_mutex.unlock(handler_io);
         var i: usize = 0;
         while (i < handler_idx) : (i += 1) {
             const handler = handlers[i];
@@ -189,6 +193,7 @@ pub const PosixTty = struct {
 pub const WindowsTty = struct {
     stdin: windows.HANDLE,
     stdout: windows.HANDLE,
+    io: std.Io,
 
     initial_codepage: c_uint,
     initial_input_mode: CONSOLE_MODE_INPUT,
@@ -198,7 +203,8 @@ pub const WindowsTty = struct {
     buf: [4]u8 = undefined,
 
     /// File.Writer for efficient buffered writing
-    tty_writer: std.fs.File.Writer,
+    tty_buffer: [1024]u8 = undefined,
+    tty_writer: ?std.Io.File.Writer = null,
 
     /// The last mouse button that was pressed. We store the previous state of button presses on each
     /// mouse event so we can detect which button was released
@@ -221,7 +227,7 @@ pub const WindowsTty = struct {
         .ENABLE_LVB_GRID_WORLDWIDE = 1, // enables reverse video and underline
     };
 
-    pub fn init(buffer: []u8) !Tty {
+    pub fn init(io: std.Io) !Tty {
         const stdin: std.fs.File = .stdin();
         const stdout: std.fs.File = .stdout();
 
@@ -237,12 +243,12 @@ pub const WindowsTty = struct {
             return windows.unexpectedError(windows.kernel32.GetLastError());
 
         const self: Tty = .{
+            .io = io,
             .stdin = stdin.handle,
             .stdout = stdout.handle,
             .initial_codepage = initial_output_codepage,
             .initial_input_mode = initial_input_mode,
             .initial_output_mode = initial_output_mode,
-            .tty_writer = .initStreaming(stdout, buffer),
         };
 
         // save a copy of this tty as the global_tty for panic handling
@@ -298,7 +304,12 @@ pub const WindowsTty = struct {
     }
 
     pub fn writer(self: *Tty) *std.Io.Writer {
-        return &self.tty_writer.interface;
+        if (self.tty_writer == null) {
+            const stdout: std.fs.File = .stdout();
+            self.tty_writer = .initStreaming(stdout, &self.tty_buffer);
+        }
+
+        return &self.tty_writer.?.interface;
     }
 
     pub fn read(self: *const Tty, buf: []u8) !usize {
@@ -694,20 +705,20 @@ pub const WindowsTty = struct {
 pub const TestTty = struct {
     /// Used for API compat
     fd: posix.fd_t,
+    io: std.Io,
     pipe_read: posix.fd_t,
     pipe_write: posix.fd_t,
     tty_writer: *std.Io.Writer.Allocating,
 
     /// Initializes a TestTty.
-    pub fn init(buffer: []u8) !TestTty {
-        _ = buffer;
-
+    pub fn init(io: std.Io) !TestTty {
         if (builtin.os.tag == .windows) return error.SkipZigTest;
         const list = try std.testing.allocator.create(std.Io.Writer.Allocating);
         list.* = .init(std.testing.allocator);
-        const r, const w = try posix.pipe();
+        const r, const w = try std.Io.Threaded.pipe2(.{});
         return .{
             .fd = r,
+            .io = io,
             .pipe_read = r,
             .pipe_write = w,
             .tty_writer = list,
@@ -715,8 +726,9 @@ pub const TestTty = struct {
     }
 
     pub fn deinit(self: TestTty) void {
-        std.posix.close(self.pipe_read);
-        std.posix.close(self.pipe_write);
+        const io = std.testing.io;
+        (std.Io.File{ .handle = self.pipe_read, .flags = .{ .nonblocking = false } }).close(io);
+        (std.Io.File{ .handle = self.pipe_write, .flags = .{ .nonblocking = false } }).close(io);
         self.tty_writer.deinit();
         std.testing.allocator.destroy(self.tty_writer);
     }
diff --git a/src/unicode.zig b/src/unicode.zig
index a5253ac..4ff2e4c 100644
--- a/src/unicode.zig
+++ b/src/unicode.zig
@@ -26,10 +26,10 @@ pub const GraphemeIterator = struct {
     }
 
     pub fn next(self: *GraphemeIterator) ?Grapheme {
-        while (self.inner.next()) |res| {
+        while (self.inner.nextCodePoint()) |res| {
             // When leaving a break and entering a non-break, set the start of a cluster
             if (self.prev_break and !res.is_break) {
-                const cp_len: usize = std.unicode.utf8CodepointSequenceLength(res.cp) catch 1;
+                const cp_len: usize = std.unicode.utf8CodepointSequenceLength(res.code_point) catch 1;
                 self.start = self.inner.i - cp_len;
             }
 
diff --git a/src/vxfw/App.zig b/src/vxfw/App.zig
index 6d394a9..fc3962f 100644
--- a/src/vxfw/App.zig
+++ b/src/vxfw/App.zig
@@ -16,7 +16,7 @@ tty: vaxis.Tty,
 vx: vaxis.Vaxis,
 timers: std.ArrayList(vxfw.Tick),
 wants_focus: ?vxfw.Widget,
-buffer: [1024]u8,
+io: std.Io,
 
 /// Runtime options
 pub const Options = struct {
@@ -27,10 +27,10 @@ pub const Options = struct {
 /// Create an application. We require stable pointers to do the set up, so this will create an App
 /// object on the heap. Call destroy when the app is complete to reset terminal state and release
 /// resources
-pub fn init(allocator: Allocator) !App {
-    var app: App = .{
+pub fn init(allocator: Allocator, io: std.Io) !App {
+    return .{
         .allocator = allocator,
-        .tty = undefined,
+        .tty = try vaxis.Tty.init(io),
         .vx = try vaxis.init(allocator, .{
             .system_clipboard_allocator = allocator,
             .kitty_keyboard_flags = .{
@@ -39,10 +39,8 @@ pub fn init(allocator: Allocator) !App {
         }),
         .timers = std.ArrayList(vxfw.Tick){},
         .wants_focus = null,
-        .buffer = undefined,
+        .io = io,
     };
-    app.tty = try vaxis.Tty.init(&app.buffer);
-    return app;
 }
 
 pub fn deinit(self: *App) void {
@@ -55,7 +53,8 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
     const tty = &self.tty;
     const vx = &self.vx;
 
-    var loop: EventLoop = .{ .tty = tty, .vaxis = vx };
+    const io = self.io;
+    var loop: EventLoop = .{ .tty = tty, .vaxis = vx, .io = io, .queue = .{ .io = io } };
     try loop.start();
     defer loop.stop();
 
@@ -65,7 +64,7 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
     loop.postEvent(.focus_in);
 
     try vx.enterAltScreen(tty.writer());
-    try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s);
+    try vx.queryTerminal(tty.writer(), io, 1 * std.time.ns_per_s);
     try vx.setBracketedPaste(tty.writer(), true);
     try vx.subscribeToColorSchemeUpdates(tty.writer());
 
@@ -97,13 +96,14 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
     defer focus_handler.deinit(self.allocator);
 
     // Timestamp of our next frame
-    var next_frame_ms: u64 = @intCast(std.time.milliTimestamp());
+    var next_frame_ms: u64 = nowMs(io);
 
     // Create our event context
     var ctx: vxfw.EventContext = .{
         .alloc = self.allocator,
         .phase = .capturing,
         .cmds = vxfw.CommandList{},
+        .io = io,
         .consume_event = false,
         .redraw = false,
         .quit = false,
@@ -111,13 +111,13 @@ pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
     defer ctx.cmds.deinit(self.allocator);
 
     while (true) {
-        const now_ms: u64 = @intCast(std.time.milliTimestamp());
+        const now_ms: u64 = nowMs(io);
         if (now_ms >= next_frame_ms) {
             // Deadline exceeded. Schedule the next frame
             next_frame_ms = now_ms + tick_ms;
         } else {
             // Sleep until the deadline
-            std.Thread.sleep((next_frame_ms - now_ms) * std.time.ns_per_ms);
+            try std.Io.sleep(io, .fromMilliseconds(@intCast(next_frame_ms - now_ms)), .real);
             next_frame_ms += tick_ms;
         }
 
@@ -296,7 +296,7 @@ fn handleCommand(self: *App, cmds: *vxfw.CommandList) Allocator.Error!void {
 }
 
 fn checkTimers(self: *App, ctx: *vxfw.EventContext) anyerror!void {
-    const now_ms = std.time.milliTimestamp();
+    const now_ms = nowMs(ctx.io);
 
     // timers are always sorted descending
     while (self.timers.pop()) |tick| {
@@ -349,12 +349,16 @@ const MouseHandler = struct {
             .surface = last_frame,
             .z_index = 0,
         };
-        const mouse_point: vxfw.Point = .{
-            .row = @intCast(mouse.row),
-            .col = @intCast(mouse.col),
-        };
-        if (sub.containsPoint(mouse_point)) {
-            try last_frame.hitTest(app.allocator, &hits, mouse_point);
+
+        if (mouse.row >= 0 and mouse.col >= 0) {
+            const mouse_point: vxfw.Point = .{
+                .row = @intCast(mouse.row),
+                .col = @intCast(mouse.col),
+            };
+
+            if (sub.containsPoint(mouse_point)) {
+                try last_frame.hitTest(app.allocator, &hits, mouse_point);
+            }
         }
 
         // We store the hit list from the last mouse event to determine mouse_enter and mouse_leave
@@ -408,12 +412,16 @@ const MouseHandler = struct {
             .surface = last_frame,
             .z_index = 0,
         };
-        const mouse_point: vxfw.Point = .{
-            .row = @intCast(mouse.row),
-            .col = @intCast(mouse.col),
-        };
-        if (sub.containsPoint(mouse_point)) {
-            try last_frame.hitTest(app.allocator, &hits, mouse_point);
+
+        if (mouse.row >= 0 and mouse.col >= 0) {
+            const mouse_point: vxfw.Point = .{
+                .row = @intCast(mouse.row),
+                .col = @intCast(mouse.col),
+            };
+
+            if (sub.containsPoint(mouse_point)) {
+                try last_frame.hitTest(app.allocator, &hits, mouse_point);
+            }
         }
 
         // Handle mouse_enter and mouse_leave events
@@ -602,3 +610,8 @@ const FocusHandler = struct {
         }
     }
 };
+
+fn nowMs(io: std.Io) u64 {
+    const now = std.Io.Clock.now(.real, io);
+    return @intCast(now.toMilliseconds());
+}
diff --git a/src/vxfw/Button.zig b/src/vxfw/Button.zig
index d4b2a9c..075627e 100644
--- a/src/vxfw/Button.zig
+++ b/src/vxfw/Button.zig
@@ -142,6 +142,7 @@ test Button {
     var ctx: vxfw.EventContext = .{
         .alloc = std.testing.allocator,
         .cmds = .empty,
+        .io = std.testing.io,
     };
     defer ctx.cmds.deinit(ctx.alloc);
 
diff --git a/src/vxfw/ListView.zig b/src/vxfw/ListView.zig
index 8ff9551..de93d5d 100644
--- a/src/vxfw/ListView.zig
+++ b/src/vxfw/ListView.zig
@@ -564,6 +564,7 @@ test ListView {
     var ctx: vxfw.EventContext = .{
         .alloc = std.testing.allocator,
         .cmds = .empty,
+        .io = std.testing.io,
     };
     defer ctx.cmds.deinit(ctx.alloc);
 
@@ -731,6 +732,7 @@ test "ListView: uneven scroll" {
     var ctx: vxfw.EventContext = .{
         .alloc = std.testing.allocator,
         .cmds = .empty,
+        .io = std.testing.io,
     };
     defer ctx.cmds.deinit(ctx.alloc);
 
diff --git a/src/vxfw/ScrollView.zig b/src/vxfw/ScrollView.zig
index 36319a4..38859ea 100644
--- a/src/vxfw/ScrollView.zig
+++ b/src/vxfw/ScrollView.zig
@@ -646,6 +646,7 @@ test ScrollView {
     var ctx: vxfw.EventContext = .{
         .alloc = std.testing.allocator,
         .cmds = .empty,
+        .io = std.testing.io,
     };
     defer ctx.cmds.deinit(ctx.alloc);
 
@@ -1044,6 +1045,7 @@ test "ScrollView: uneven scroll" {
     var ctx: vxfw.EventContext = .{
         .alloc = std.testing.allocator,
         .cmds = .empty,
+        .io = std.testing.io,
     };
     defer ctx.cmds.deinit(ctx.alloc);
 
diff --git a/src/vxfw/Spinner.zig b/src/vxfw/Spinner.zig
index 6619522..eb3d8ba 100644
--- a/src/vxfw/Spinner.zig
+++ b/src/vxfw/Spinner.zig
@@ -20,11 +20,11 @@ frame: u4 = 0,
 was_spinning: std.atomic.Value(bool) = .{ .raw = false },
 
 /// Start, or add one, to the spinner counter. Thread safe.
-pub fn start(self: *Spinner) ?vxfw.Command {
+pub fn start(self: *Spinner, io: std.Io) ?vxfw.Command {
     self.was_spinning.store(true, .unordered);
     const count = self.count.fetchAdd(1, .monotonic);
     if (count == 0) {
-        return vxfw.Tick.in(time_lapse, self.widget());
+        return vxfw.Tick.in(io, time_lapse, self.widget());
     }
     return null;
 }
@@ -111,13 +111,13 @@ test Spinner {
     // Start the spinner. This (maybe) returns a Tick command to schedule the next frame. If the
     // spinner is already running, no command is returned. Calling start is thread safe. The
     // returned command can be added to an EventContext to schedule the frame
-    const maybe_cmd = spinner.start();
+    const maybe_cmd = spinner.start(std.testing.io);
     try std.testing.expect(maybe_cmd != null);
     try std.testing.expect(maybe_cmd.? == .tick);
     try std.testing.expectEqual(1, spinner.count.load(.unordered));
 
     // If we call start again, we won't get another command but our counter will go up
-    const maybe_cmd2 = spinner.start();
+    const maybe_cmd2 = spinner.start(std.testing.io);
     try std.testing.expect(maybe_cmd2 == null);
     try std.testing.expectEqual(2, spinner.count.load(.unordered));
 
@@ -126,6 +126,7 @@ test Spinner {
     var ctx: vxfw.EventContext = .{
         .alloc = arena.allocator(),
         .cmds = .empty,
+        .io = std.testing.io,
     };
 
     // The event loop handles the tick event and calls us back with a .tick event. If we should keep
diff --git a/src/vxfw/SplitView.zig b/src/vxfw/SplitView.zig
index 9a355a0..591e5e9 100644
--- a/src/vxfw/SplitView.zig
+++ b/src/vxfw/SplitView.zig
@@ -229,6 +229,7 @@ test SplitView {
     var ctx: vxfw.EventContext = .{
         .alloc = arena.allocator(),
         .cmds = .empty,
+        .io = std.testing.io,
     };
     try split_widget.handleEvent(&ctx, .{ .mouse = mouse });
     // We should get a command to change the mouse shape
diff --git a/src/vxfw/Text.zig b/src/vxfw/Text.zig
index 1f76cba..a7b71bb 100644
--- a/src/vxfw/Text.zig
+++ b/src/vxfw/Text.zig
@@ -223,7 +223,7 @@ pub const SoftwrapIterator = struct {
         // Advance the hard iterator
         if (self.index == self.line.len) {
             self.line = self.hard_iter.next() orelse return null;
-            self.line = std.mem.trimRight(u8, self.line, " \t");
+            self.line = std.mem.trimEnd(u8, self.line, " \t");
             self.index = 0;
         }
 
@@ -237,7 +237,7 @@ pub const SoftwrapIterator = struct {
             if (self.ctx.max.width) |max| {
                 if (cur_width + next_width > max) {
                     // Trim the word to see if it can fit on a line by itself
-                    const trimmed = std.mem.trimLeft(u8, word, " \t");
+                    const trimmed = std.mem.trimStart(u8, word, " \t");
                     const trimmed_bytes = word.len - trimmed.len;
                     // The number of bytes we trimmed is equal to the reduction in length
                     const trimmed_width = next_width - trimmed_bytes;
diff --git a/src/vxfw/TextField.zig b/src/vxfw/TextField.zig
index 6ad108b..e5a4d5b 100644
--- a/src/vxfw/TextField.zig
+++ b/src/vxfw/TextField.zig
@@ -346,7 +346,7 @@ pub fn deleteAfterCursor(self: *TextField) void {
 /// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is
 /// positioned just after the next previous space
 pub fn moveBackwardWordwise(self: *TextField) void {
-    const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " ");
+    const trimmed = std.mem.trimEnd(u8, self.buf.firstHalf(), " ");
     const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last|
         last + 1
     else
@@ -562,6 +562,7 @@ test TextField {
     var ctx: vxfw.EventContext = .{
         .alloc = arena.allocator(),
         .cmds = .empty,
+        .io = std.testing.io,
     };
 
     // Enough boiler plate...Create the text field
diff --git a/src/vxfw/vxfw.zig b/src/vxfw/vxfw.zig
index 1510e71..f768d56 100644
--- a/src/vxfw/vxfw.zig
+++ b/src/vxfw/vxfw.zig
@@ -61,8 +61,8 @@ pub const Tick = struct {
         return lhs.deadline_ms > rhs.deadline_ms;
     }
 
-    pub fn in(ms: u32, widget: Widget) Command {
-        const now = std.time.milliTimestamp();
+    pub fn in(io: std.Io, ms: u32, widget: Widget) Command {
+        const now = std.Io.Clock.now(.real, io).toMilliseconds();
         return .{ .tick = .{
             .deadline_ms = now + ms,
             .widget = widget,
@@ -101,6 +101,7 @@ pub const EventContext = struct {
     phase: Phase = .at_target,
     alloc: Allocator,
     cmds: CommandList,
+    io: std.Io,
 
     /// The event was handled, do not pass it on
     consume_event: bool = false,
@@ -120,7 +121,7 @@ pub const EventContext = struct {
     }
 
     pub fn tick(self: *EventContext, ms: u32, widget: Widget) Allocator.Error!void {
-        try self.addCmd(Tick.in(ms, widget));
+        try self.addCmd(Tick.in(self.io, ms, widget));
     }
 
     pub fn consumeAndRedraw(self: *EventContext) void {
@@ -541,10 +542,10 @@ test "All widgets have a doctest and refAllDecls test" {
     // it easy to fail CI early, or spot bad tests vs non-existant tests
     const excludes = &[_][]const u8{ "vxfw.zig", "App.zig" };
 
-    var cwd = try std.fs.cwd().openDir("./src/vxfw", .{ .iterate = true });
+    var cwd = try std.Io.Dir.cwd().openDir(std.testing.io, "./src/vxfw", .{ .iterate = true });
     var iter = cwd.iterate();
-    defer cwd.close();
-    outer: while (try iter.next()) |file| {
+    defer cwd.close(std.testing.io);
+    outer: while (try iter.next(std.testing.io)) |file| {
         if (file.kind != .file) continue;
         for (excludes) |ex| if (std.mem.eql(u8, ex, file.name)) continue :outer;
 
@@ -552,7 +553,7 @@ test "All widgets have a doctest and refAllDecls test" {
             file.name[0..idx]
         else
             continue;
-        const data = try cwd.readFileAllocOptions(std.testing.allocator, file.name, 10_000_000, null, .of(u8), 0x00);
+        const data = try cwd.readFileAllocOptions(std.testing.io, file.name, std.testing.allocator, std.Io.Limit.limited(10_000_000), .of(u8), 0x00);
         defer std.testing.allocator.free(data);
         var ast = try std.zig.Ast.parse(std.testing.allocator, data, .zig);
         defer ast.deinit(std.testing.allocator);
diff --git a/src/widgets/TextInput.zig b/src/widgets/TextInput.zig
index a6236a4..6b8871b 100644
--- a/src/widgets/TextInput.zig
+++ b/src/widgets/TextInput.zig
@@ -266,7 +266,7 @@ pub fn deleteAfterCursor(self: *TextInput) void {
 /// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is
 /// positioned just after the next previous space
 pub fn moveBackwardWordwise(self: *TextInput) void {
-    const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " ");
+    const trimmed = std.mem.trimEnd(u8, self.buf.firstHalf(), " ");
     const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last|
         last + 1
     else
diff --git a/src/widgets/TextView.zig b/src/widgets/TextView.zig
index 45f9e44..ee326cf 100644
--- a/src/widgets/TextView.zig
+++ b/src/widgets/TextView.zig
@@ -79,52 +79,22 @@ pub const Buffer = struct {
         var cols: usize = self.last_cols;
         var iter = uucode.grapheme.Iterator(uucode.utf8.Iterator).init(.init(content.bytes));
 
-        var grapheme_start: usize = 0;
-        var prev_break: bool = true;
-
-        while (iter.next()) |result| {
-            if (prev_break and !result.is_break) {
-                // Start of a new grapheme
-                const cp_len: usize = std.unicode.utf8CodepointSequenceLength(result.cp) catch 1;
-                grapheme_start = iter.i - cp_len;
-            }
-
-            if (result.is_break) {
-                // End of a grapheme
-                const grapheme_end = iter.i;
-                const grapheme_len = grapheme_end - grapheme_start;
-
-                try self.grapheme.append(allocator, .{
-                    .len = @intCast(grapheme_len),
-                    .offset = @intCast(self.content.items.len + grapheme_start),
-                });
-
-                const cluster = content.bytes[grapheme_start..grapheme_end];
-                if (std.mem.eql(u8, cluster, "\n")) {
-                    self.cols = @max(self.cols, cols);
-                    cols = 0;
-                } else {
-                    // Calculate width using gwidth
-                    const w = vaxis.gwidth.gwidth(cluster, .unicode);
-                    cols +|= w;
-                }
-
-                grapheme_start = grapheme_end;
-            }
-            prev_break = result.is_break;
-        }
-
-        // Flush the last grapheme if we ended mid-cluster
-        if (!prev_break and grapheme_start < content.bytes.len) {
-            const grapheme_len = content.bytes.len - grapheme_start;
+        while (iter.nextGrapheme()) |grapheme| {
+            const grapheme_start = grapheme.start;
+            const grapheme_end = grapheme.end;
+            const grapheme_len = grapheme_end - grapheme_start;
 
             try self.grapheme.append(allocator, .{
                 .len = @intCast(grapheme_len),
                 .offset = @intCast(self.content.items.len + grapheme_start),
             });
 
-            const cluster = content.bytes[grapheme_start..];
-            if (!std.mem.eql(u8, cluster, "\n")) {
+            const cluster = content.bytes[grapheme_start..grapheme_end];
+            if (std.mem.eql(u8, cluster, "\n")) {
+                self.cols = @max(self.cols, cols);
+                cols = 0;
+            } else {
+                // Calculate width using gwidth
                 const w = vaxis.gwidth.gwidth(cluster, .unicode);
                 cols +|= w;
             }
diff --git a/src/widgets/terminal/Command.zig b/src/widgets/terminal/Command.zig
index 0cd24f7..ed41553 100644
--- a/src/widgets/terminal/Command.zig
+++ b/src/widgets/terminal/Command.zig
@@ -14,7 +14,7 @@ working_directory: ?[]const u8,
 // Set after spawn()
 pid: ?std.posix.pid_t = null,
 
-env_map: *const std.process.EnvMap,
+env_map: *const std.process.Environ.Map,
 
 pty: Pty,
 
@@ -43,8 +43,8 @@ pub fn spawn(self: *Command, allocator: std.mem.Allocator) !void {
         try posix.dup2(self.pty.tty.handle, std.posix.STDOUT_FILENO);
         try posix.dup2(self.pty.tty.handle, std.posix.STDERR_FILENO);
 
-        self.pty.tty.close();
-        if (self.pty.pty.handle > 2) self.pty.pty.close();
+        _ = std.os.linux.close(self.pty.tty.handle);
+        if (self.pty.pty.handle > 2) _ = std.os.linux.close(self.pty.pty.handle);
 
         if (self.working_directory) |wd| {
             try std.posix.chdir(wd);
@@ -78,8 +78,8 @@ pub fn spawn(self: *Command, allocator: std.mem.Allocator) !void {
 fn handleSigChild(_: c_int) callconv(.c) void {
     const result = std.posix.waitpid(-1, 0);
 
-    Terminal.global_vt_mutex.lock();
-    defer Terminal.global_vt_mutex.unlock();
+    if (!Terminal.global_vt_mutex.tryLock()) return;
+    defer Terminal.global_vt_mutex.unlock(Terminal.global_vt_io);
     if (Terminal.global_vts) |vts| {
         var vt = vts.get(result.pid) orelse return;
         vt.event_queue.push(.exited);
@@ -97,7 +97,7 @@ pub fn kill(self: *Command) void {
 /// hash map plus options.
 fn createEnvironFromMap(
     arena: std.mem.Allocator,
-    map: *const std.process.EnvMap,
+    map: *const std.process.Environ.Map,
 ) ![:null]?[*:0]u8 {
     const envp_count: usize = map.count();
 
diff --git a/src/widgets/terminal/Pty.zig b/src/widgets/terminal/Pty.zig
index d9f4138..4c67328 100644
--- a/src/widgets/terminal/Pty.zig
+++ b/src/widgets/terminal/Pty.zig
@@ -7,8 +7,8 @@ const Winsize = @import("../../main.zig").Winsize;
 
 const posix = std.posix;
 
-pty: std.fs.File,
-tty: std.fs.File,
+pty: std.Io.File,
+tty: std.Io.File,
 
 /// opens a new tty/pty pair
 pub fn init() !Pty {
@@ -19,9 +19,9 @@ pub fn init() !Pty {
 }
 
 /// closes the tty and pty
-pub fn deinit(self: Pty) void {
-    self.pty.close();
-    self.tty.close();
+pub fn deinit(self: Pty, io: std.Io) void {
+    self.pty.close(io);
+    self.tty.close(io);
 }
 
 /// sets the size of the pty
@@ -37,7 +37,7 @@ pub fn setSize(self: Pty, ws: Winsize) !void {
 }
 
 fn openPtyLinux() !Pty {
-    const p = try posix.open("/dev/ptmx", .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0);
+    const p = try posix.openat(posix.AT.FDCWD, "/dev/ptmx", .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0);
     errdefer posix.close(p);
 
     // unlockpt
@@ -50,7 +50,7 @@ fn openPtyLinux() !Pty {
     const sname = try std.fmt.bufPrint(&buf, "/dev/pts/{d}", .{n});
     std.log.debug("pts: {s}", .{sname});
 
-    const t = try posix.open(sname, .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0);
+    const t = try posix.openat(posix.AT.FDCWD, sname, .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0);
 
     return .{
         .pty = .{ .handle = p },
diff --git a/src/widgets/terminal/Terminal.zig b/src/widgets/terminal/Terminal.zig
index 36d61d7..786ca85 100644
--- a/src/widgets/terminal/Terminal.zig
+++ b/src/widgets/terminal/Terminal.zig
@@ -43,21 +43,23 @@ pub const InputEvent = union(enum) {
     key_press: vaxis.Key,
 };
 
-pub var global_vt_mutex: std.Thread.Mutex = .{};
+pub var global_vt_mutex: std.Io.Mutex = .init;
+var global_vt_io: std.Io = undefined;
 pub var global_vts: ?std.AutoHashMap(i32, *Terminal) = null;
 pub var global_sigchild_installed: bool = false;
 
 allocator: std.mem.Allocator,
+io: std.Io,
 scrollback_size: u16,
 
 pty: Pty,
-pty_writer: std.fs.File.Writer,
+pty_writer: std.Io.File.Writer,
 cmd: Command,
 thread: ?std.Thread = null,
 
 /// the screen we draw from
 front_screen: Screen,
-front_mutex: std.Thread.Mutex = .{},
+front_mutex: std.Io.Mutex = .init,
 
 /// the back screens
 back_screen: *Screen = undefined,
@@ -65,7 +67,7 @@ back_screen_pri: Screen,
 back_screen_alt: Screen,
 // only applies to primary screen
 scroll_offset: usize = 0,
-back_mutex: std.Thread.Mutex = .{},
+back_mutex: std.Io.Mutex = .init,
 // dirty is protected by back_mutex. Only access this field when you hold that mutex
 dirty: bool = false,
 
@@ -79,14 +81,15 @@ working_directory: std.ArrayList(u8) = .empty,
 
 last_printed: []const u8 = "",
 
-event_queue: Queue = .{},
+event_queue: Queue,
 
 /// initialize a Terminal. This sets the size of the underlying pty and allocates the sizes of the
 /// screen
 pub fn init(
     allocator: std.mem.Allocator,
+    io: std.Io,
     argv: []const []const u8,
-    env: *const std.process.EnvMap,
+    env: *const std.process.Environ.Map,
     opts: Options,
     write_buf: []u8,
 ) !Terminal {
@@ -107,8 +110,10 @@ pub fn init(
     while (col < opts.winsize.cols) : (col += 8) {
         try tabs.append(allocator, col);
     }
+    global_vt_io = io;
     return .{
         .allocator = allocator,
+        .io = io,
         .pty = pty,
         .pty_writer = pty.pty.writerStreaming(write_buf),
         .cmd = cmd,
@@ -117,6 +122,7 @@ pub fn init(
         .back_screen_pri = try Screen.init(allocator, opts.winsize.cols, opts.winsize.rows + opts.scrollback_size),
         .back_screen_alt = try Screen.init(allocator, opts.winsize.cols, opts.winsize.rows),
         .tab_stops = tabs,
+        .event_queue = .{ .io = io },
     };
 }
 
@@ -125,8 +131,8 @@ pub fn deinit(self: *Terminal) void {
     self.should_quit = true;
 
     pid: {
-        global_vt_mutex.lock();
-        defer global_vt_mutex.unlock();
+        global_vt_mutex.lockUncancelable(global_vt_io);
+        defer global_vt_mutex.unlock(global_vt_io);
         var vts = global_vts orelse break :pid;
         if (self.cmd.pid) |pid|
             _ = vts.remove(pid);
@@ -143,7 +149,7 @@ pub fn deinit(self: *Terminal) void {
         thread.join();
         self.thread = null;
     }
-    self.pty.deinit();
+    self.pty.deinit(self.io);
     self.front_screen.deinit(self.allocator);
     self.back_screen_pri.deinit(self.allocator);
     self.back_screen_alt.deinit(self.allocator);
@@ -162,16 +168,15 @@ pub fn spawn(self: *Terminal) !void {
     if (self.cmd.working_directory) |pwd| {
         try self.working_directory.appendSlice(self.allocator, pwd);
     } else {
-        const pwd = std.fs.cwd();
         var buffer: [std.fs.max_path_bytes]u8 = undefined;
-        const out_path = try std.os.getFdPath(pwd.fd, &buffer);
+        const out_path = try std.posix.readlinkZ("/proc/self/cwd", &buffer);
         try self.working_directory.appendSlice(self.allocator, out_path);
     }
 
     {
         // add to our global list
-        global_vt_mutex.lock();
-        defer global_vt_mutex.unlock();
+        global_vt_mutex.lockUncancelable(global_vt_io);
+        defer global_vt_mutex.unlock(global_vt_io);
         if (global_vts == null)
             global_vts = std.AutoHashMap(i32, *Terminal).init(self.allocator);
         if (self.cmd.pid) |pid|
@@ -190,8 +195,8 @@ pub fn resize(self: *Terminal, ws: Winsize) !void {
         ws.rows == self.front_screen.height)
         return;
 
-    self.back_mutex.lock();
-    defer self.back_mutex.unlock();
+    self.back_mutex.lockUncancelable(self.io);
+    defer self.back_mutex.unlock(self.io);
 
     self.front_screen.deinit(self.allocator);
     self.front_screen = try Screen.init(self.allocator, ws.cols, ws.rows);
@@ -206,7 +211,7 @@ pub fn resize(self: *Terminal, ws: Winsize) !void {
 
 pub fn draw(self: *Terminal, allocator: std.mem.Allocator, win: vaxis.Window) !void {
     if (self.back_mutex.tryLock()) {
-        defer self.back_mutex.unlock();
+        defer self.back_mutex.unlock(self.io);
         // We keep this as a separate condition so we don't deadlock by obtaining the lock but not
         // having sync
         if (!self.mode.sync) {
@@ -249,7 +254,7 @@ pub fn get_pty_writer(self: *Terminal) *std.Io.Writer {
     return &self.pty_writer.interface;
 }
 
-fn reader(self: *const Terminal, buf: []u8) std.fs.File.Reader {
+fn reader(self: *const Terminal, buf: []u8) std.Io.File.Reader {
     return self.pty.pty.readerStreaming(buf);
 }
 
@@ -265,8 +270,8 @@ fn run(self: *Terminal) !void {
 
     while (!self.should_quit) {
         const event = try parser.parseReader(&reader_.interface);
-        self.back_mutex.lock();
-        defer self.back_mutex.unlock();
+        self.back_mutex.lockUncancelable(self.io);
+        defer self.back_mutex.unlock(self.io);
 
         if (!self.dirty and self.event_queue.tryPush(.redraw))
             self.dirty = true;
