From 16d2a023a3fbe05239eab8804c5962e434c9b433 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 02:02:53 +0200 Subject: [PATCH] Add scripted terminal thin client --- src/main.zig | 2 + src/tui.zig | 296 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 src/tui.zig diff --git a/src/main.zig b/src/main.zig index f585061..fb0e18f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,6 +3,7 @@ const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session = @import("session.zig"); const socket = @import("socket.zig"); +const tui = @import("tui.zig"); pub const version = "0.1.0-dev"; @@ -119,6 +120,7 @@ test { _ = replay; _ = session; _ = socket; + _ = tui; } test "regular: help text names the binary and smoke boundary" { diff --git a/src/tui.zig b/src/tui.zig new file mode 100644 index 0000000..bcea793 --- /dev/null +++ b/src/tui.zig @@ -0,0 +1,296 @@ +const std = @import("std"); +const protocol = @import("protocol.zig"); +const replay = @import("replay.zig"); +const session_mod = @import("session.zig"); + +// First terminal thin client surface, scriptable for E2E-style tests. +// req: session/001, session/003, ui/001, coding/001, testing/001, testing/002 + +test { + _ = Client; +} + +pub const Error = error{ + ViewportTooSmall, + InvalidResize, + UnknownTraceEvent, + ProtocolRejected, + ClientQuit, + NothingSaved, +}; + +pub const Viewport = struct { + width: usize, + height: usize, + + pub fn validate(self: Viewport) !void { + if (self.width == 0 or self.height < 2) return Error.ViewportTooSmall; + } +}; + +pub fn runTrace(allocator: std.mem.Allocator, viewport: Viewport, trace: []const u8) !TraceResult { + var client = try Client.init(allocator, viewport); + defer client.deinit(); + + var lines = std.mem.splitScalar(u8, trace, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + try client.handleTraceLine(line); + } + + const frame = try client.render(allocator); + errdefer allocator.free(frame); + const saved_bytes = if (client.saved_bytes) |bytes| try allocator.dupe(u8, bytes) else null; + errdefer if (saved_bytes) |bytes| allocator.free(bytes); + return .{ .frame = frame, .saved_bytes = saved_bytes, .quit = client.quit }; +} + +pub const TraceResult = struct { + frame: []u8, + saved_bytes: ?[]u8, + quit: bool, + + pub fn deinit(self: TraceResult, allocator: std.mem.Allocator) void { + allocator.free(self.frame); + if (self.saved_bytes) |bytes| allocator.free(bytes); + } +}; + +pub const Client = struct { + allocator: std.mem.Allocator, + session: session_mod.Session, + viewport: Viewport, + saved_bytes: ?[]u8 = null, + quit: bool = false, + + pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client { + try viewport.validate(); + return .{ + .allocator = allocator, + .session = session_mod.Session.init(allocator), + .viewport = viewport, + }; + } + + pub fn deinit(self: *Client) void { + if (self.saved_bytes) |bytes| self.allocator.free(bytes); + self.session.deinit(); + self.* = undefined; + } + + pub fn handleTraceLine(self: *Client, line: []const u8) !void { + if (std.mem.eql(u8, line, "quit")) { + self.quit = true; + return; + } + if (self.quit) return Error.ClientQuit; + + if (std.mem.startsWith(u8, line, "open ")) return self.applyProtocol(line); + if (std.mem.startsWith(u8, line, "insert ")) return self.applyProtocolCommand(line); + if (std.mem.eql(u8, line, "left")) return self.applyProtocol("command move_left"); + if (std.mem.eql(u8, line, "right")) return self.applyProtocol("command move_right"); + if (std.mem.eql(u8, line, "backspace")) return self.applyProtocol("command delete_backward"); + if (std.mem.eql(u8, line, "save")) return self.save(); + if (std.mem.startsWith(u8, line, "resize ")) return self.resize(line[7..]); + if (std.mem.eql(u8, line, "render")) return; + + return Error.UnknownTraceEvent; + } + + pub fn render(self: *const Client, allocator: std.mem.Allocator) ![]u8 { + try self.viewport.validate(); + const snap = try self.session.snapshot(); + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + + const max_body_lines = self.viewport.height - 1; + const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte); + const cursor_col = columnAt(snap.bytes, snap.cursor_byte); + var visible_line_index: usize = 0; + var body_lines_used: usize = 0; + var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n'); + while (line_iter.next()) |line| : (visible_line_index += 1) { + if (body_lines_used >= max_body_lines) break; + try appendVisibleCells(allocator, &out, line, self.viewport.width); + try out.append(allocator, '\n'); + body_lines_used += 1; + + if (visible_line_index == cursor_line and body_lines_used < max_body_lines) { + try appendCursorLine(allocator, &out, @min(cursor_col, self.viewport.width - 1), self.viewport.width); + body_lines_used += 1; + } + } + while (body_lines_used < max_body_lines) : (body_lines_used += 1) { + try out.append(allocator, '~'); + try out.append(allocator, '\n'); + } + + const status = try std.fmt.allocPrint( + allocator, + "mim row={d} col={d} bytes={d}{s}", + .{ cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" }, + ); + defer allocator.free(status); + try appendVisibleCells(allocator, &out, status, self.viewport.width); + return out.toOwnedSlice(allocator); + } + + pub fn saved(self: *const Client) ![]const u8 { + return self.saved_bytes orelse Error.NothingSaved; + } + + fn applyProtocolCommand(self: *Client, line: []const u8) !void { + const command = try std.fmt.allocPrint(self.allocator, "command {s}", .{line}); + defer self.allocator.free(command); + try self.applyProtocol(command); + } + + fn applyProtocol(self: *Client, line: []const u8) !void { + const response = try protocol.handleLine(self.allocator, &self.session, line); + defer self.allocator.free(response); + if (std.mem.startsWith(u8, response, "err ")) return Error.ProtocolRejected; + } + + fn resize(self: *Client, payload: []const u8) !void { + const x = std.mem.indexOfScalar(u8, payload, 'x') orelse return Error.InvalidResize; + const width = std.fmt.parseUnsigned(usize, payload[0..x], 10) catch return Error.InvalidResize; + const height = std.fmt.parseUnsigned(usize, payload[x + 1 ..], 10) catch return Error.InvalidResize; + const viewport = Viewport{ .width = width, .height = height }; + try viewport.validate(); + self.viewport = viewport; + } + + fn save(self: *Client) !void { + const snap = try self.session.snapshot(); + const copy = try self.allocator.dupe(u8, snap.bytes); + if (self.saved_bytes) |old| self.allocator.free(old); + self.saved_bytes = copy; + } +}; + +fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void { + var i: usize = 0; + while (i < bytes.len) { + const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1; + const end = @min(bytes.len, i + len); + const next = bytes[0..end]; + if (session_mod.cellWidth(next) > max_cells) break; + try out.appendSlice(allocator, bytes[i..end]); + i = end; + } +} + +fn appendCursorLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), cursor_col: usize, max_cells: usize) !void { + var i: usize = 0; + while (i < cursor_col and i + 1 < max_cells) : (i += 1) try out.append(allocator, ' '); + try out.append(allocator, '^'); + try out.append(allocator, '\n'); +} + +fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize { + var line: usize = 0; + for (bytes[0..@min(cursor_byte, bytes.len)]) |byte| { + if (byte == '\n') line += 1; + } + return line; +} + +fn columnAt(bytes: []const u8, cursor_byte: usize) usize { + const prefix = bytes[0..@min(cursor_byte, bytes.len)]; + const line_start = if (std.mem.lastIndexOfScalar(u8, prefix, '\n')) |idx| idx + 1 else 0; + return session_mod.cellWidth(prefix[line_start..]); +} + +fn assertLinesFit(frame: []const u8, width: usize) !void { + var lines = std.mem.splitScalar(u8, frame, '\n'); + while (lines.next()) |line| { + try std.testing.expect(session_mod.cellWidth(line) <= width); + } +} + +test "regular: scripted narrow terminal trace edits saves exits and replays saved bytes" { + const trace = + \\open abc + \\right + \\insert é + \\save + \\quit + \\ + ; + const result = try runTrace(std.testing.allocator, .{ .width = 12, .height = 4 }, trace); + defer result.deinit(std.testing.allocator); + try std.testing.expect(result.quit); + try std.testing.expect(std.mem.indexOf(u8, result.frame, "aébc") != null); + try assertLinesFit(result.frame, 12); + try std.testing.expectEqualStrings("aébc", result.saved_bytes.?); + + const recording = + \\protocol open abc + \\protocol command move_right + \\input insert é + \\save aébc + \\ + ; + const replay_result = try replay.replayText(std.testing.allocator, recording); + defer replay_result.deinit(std.testing.allocator); + switch (replay_result) { + .ok => {}, + .diverged => return error.ExpectedReplayOk, + } +} + +test "regular: backspace deletes whole UTF-8 codepoint through terminal client" { + var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 }); + defer client.deinit(); + + try client.handleTraceLine("open aéb"); + try client.handleTraceLine("right"); + try client.handleTraceLine("right"); + try client.handleTraceLine("backspace"); + try client.handleTraceLine("save"); + + try std.testing.expectEqualStrings("ab", try client.saved()); +} + +test "regular: resize changes render width without changing buffer bytes" { + var client = try Client.init(std.testing.allocator, .{ .width = 8, .height = 3 }); + defer client.deinit(); + + try client.handleTraceLine("open abcdefghijk"); + try client.handleTraceLine("resize 5x3"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + + try assertLinesFit(frame, 5); + try client.handleTraceLine("save"); + try std.testing.expectEqualStrings("abcdefghijk", try client.saved()); +} + +test "adversarial: tiny or malformed resize is rejected" { + var client = try Client.init(std.testing.allocator, .{ .width = 4, .height = 3 }); + defer client.deinit(); + try client.handleTraceLine("open abc"); + + try std.testing.expectError(Error.ViewportTooSmall, client.handleTraceLine("resize 0x3")); + try std.testing.expectError(Error.InvalidResize, client.handleTraceLine("resize phone")); +} + +test "adversarial: invalid UTF-8 insertion is rejected and previous bytes can still save" { + var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 }); + defer client.deinit(); + try client.handleTraceLine("open safe"); + + const bad = [_]u8{ 'i', 'n', 's', 'e', 'r', 't', ' ', 0xc3, 0x28 }; + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine(&bad)); + try client.handleTraceLine("save"); + try std.testing.expectEqualStrings("safe", try client.saved()); +} + +test "adversarial: quit prevents further edits" { + var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 }); + defer client.deinit(); + try client.handleTraceLine("open abc"); + try client.handleTraceLine("quit"); + + try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x")); +}