const std = @import("std"); const input = @import("input.zig"); const leader_mod = @import("leader.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const repo_mod = @import("repo.zig"); const session_mod = @import("session.zig"); const symbol_mod = @import("symbol.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 const RenderDiagnostics = struct { viewport_width: usize, viewport_height: usize, frame_bytes: usize, frame_lines: usize, max_line_cells: usize, clipped_sources: usize, }; pub const RenderResult = struct { frame: []u8, diagnostics: RenderDiagnostics, warning: []u8, pub fn deinit(self: RenderResult, allocator: std.mem.Allocator) void { allocator.free(self.frame); allocator.free(self.warning); } }; 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, leader: leader_mod.Leader, repo: repo_mod.Index, viewport: Viewport, saved_bytes: ?[]u8 = null, message: ?[]const 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), .leader = leader_mod.Leader.init(allocator), .repo = repo_mod.Index.init(allocator), .viewport = viewport, }; } pub fn deinit(self: *Client) void { if (self.saved_bytes) |bytes| self.allocator.free(bytes); self.repo.deinit(); self.leader.deinit(); 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, "key ")) return self.handleTraceKey(line[4..]); if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]); if (std.mem.startsWith(u8, line, "repo_gitignore ")) return self.repo.addIgnorePattern(line[15..]); if (std.mem.startsWith(u8, line, "repo_file ")) return self.addRepoFile(line[10..]); if (std.mem.eql(u8, line, "file_picker")) return self.openRepoList("files", false, false); if (std.mem.eql(u8, line, "file_picker all")) return self.openRepoList("files", false, true); if (std.mem.eql(u8, line, "file_tree")) return self.openRepoList("tree", true, false); if (std.mem.eql(u8, line, "file_tree all")) return self.openRepoList("tree", true, true); if (std.mem.eql(u8, line, "file_open_selected")) return self.openSelectedRepoFile(); if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "message ")) { self.message = line[8..]; return; } if (std.mem.eql(u8, line, "list_down")) return self.applyProtocol("command list_down"); if (std.mem.eql(u8, line, "list_up")) return self.applyProtocol("command list_up"); if (std.mem.eql(u8, line, "list_select")) return self.applyProtocol("command list_select"); if (std.mem.eql(u8, line, "list_cancel")) return self.applyProtocol("command list_cancel"); if (std.mem.eql(u8, line, "panel_close")) return self.applyProtocol("command panel_close"); if (std.mem.eql(u8, line, "panel_next")) return self.applyProtocol("command panel_next"); if (std.mem.eql(u8, line, "panel_prev")) return self.applyProtocol("command panel_prev"); 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(); if (snap.active_panel_title != null) return self.renderPanel(allocator, snap); return self.renderEditor(allocator, snap); } pub fn renderWithDiagnostics(self: *const Client, allocator: std.mem.Allocator) !RenderResult { const frame = try self.render(allocator); errdefer allocator.free(frame); var diagnostics = diagnoseFrame(self.viewport, frame); diagnostics.clipped_sources = try self.countClippedSources(allocator); const warning = try renderWarningAlloc(allocator, diagnostics); return .{ .frame = frame, .diagnostics = diagnostics, .warning = warning }; } fn renderEditor(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 { 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 leader_status = self.leader.status(); const status = if (self.message) |message| try std.fmt.allocPrint(allocator, "{s}", .{message}) else if (leader_status.len != 0) try std.fmt.allocPrint( allocator, "{s}{s}{s}", .{ leader_status, if (self.leader.promptText().len > 0) ": " else "", self.leader.promptText() }, ) else 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); } fn renderPanel(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); const max_body_lines = self.viewport.height - 1; var body_lines_used: usize = 0; const path = try self.session.panelPathAlloc(allocator); defer allocator.free(path); const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path}); defer allocator.free(header); try appendVisibleCells(allocator, &out, header, self.viewport.width); try out.append(allocator, '\n'); body_lines_used += 1; const remaining_rows = max_body_lines - body_lines_used; const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null; if (rows) |list_rows| { defer { for (list_rows) |row| allocator.free(row); allocator.free(list_rows); } for (list_rows) |row| { if (body_lines_used >= max_body_lines) break; try appendVisibleCells(allocator, &out, row, self.viewport.width); try out.append(allocator, '\n'); body_lines_used += 1; } } else if (body_lines_used < max_body_lines) { const title = snap.active_panel_title.?; const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title}); defer allocator.free(detail); try appendVisibleCells(allocator, &out, detail, self.viewport.width); try out.append(allocator, '\n'); 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 = if (self.message) |message| try std.fmt.allocPrint(allocator, "{s}", .{message}) else try std.fmt.allocPrint( allocator, "panel {d}/{d} x close", .{ snap.active_panel_index.? + 1, snap.panel_depth }, ); defer allocator.free(status); try appendVisibleCells(allocator, &out, status, self.viewport.width); return out.toOwnedSlice(allocator); } pub fn handleInput(self: *Client, raw: []const u8) !void { if (self.quit) return Error.ClientQuit; const event = input.normalize(raw); if (self.leader.capturesInput() or isLeaderTrigger(event)) { const action = try self.leader.handleEvent(event); defer action.deinit(self.allocator); try self.applyLeaderAction(action); return; } _ = try self.leader.handleEvent(event); try self.applyNormalInput(event); } pub fn saved(self: *const Client) ![]const u8 { return self.saved_bytes orelse Error.NothingSaved; } fn addRepoFile(self: *Client, payload: []const u8) !void { const separator = std.mem.indexOfScalar(u8, payload, '=') orelse return repo_mod.Error.InvalidPath; try self.repo.addFile(payload[0..separator], payload[separator + 1 ..]); } fn openRepoList(self: *Client, title: []const u8, tree: bool, include_ignored: bool) !void { const items = if (tree) try self.repo.treeItemsAlloc(self.allocator, include_ignored) else try self.repo.pickerItemsAlloc(self.allocator, include_ignored); defer self.allocator.free(items); try self.session.openListPanel(title, items); self.message = null; } fn openSelectedRepoFile(self: *Client) !void { const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; const content = self.repo.content(selected) catch return Error.ProtocolRejected; try self.session.openFixture(content); try self.session.closePanel(); self.message = null; } fn handleTraceKey(self: *Client, key_name: []const u8) !void { if (std.mem.eql(u8, key_name, "space")) return self.handleInput(" "); if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r"); if (std.mem.eql(u8, key_name, "backspace")) return self.handleInput("\x7f"); if (std.mem.eql(u8, key_name, "escape")) return self.handleInput("\x1b"); if (std.mem.eql(u8, key_name, "left")) return self.handleInput("\x1b[D"); if (std.mem.eql(u8, key_name, "right")) return self.handleInput("\x1b[C"); if (key_name.len == 1) return self.handleInput(key_name); return Error.UnknownTraceEvent; } fn isLeaderTrigger(event: input.Event) bool { return switch (event) { .key => |key| key == .space, else => false, }; } fn applyNormalInput(self: *Client, event: input.Event) !void { switch (event) { .text => |text| { const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text}); defer self.allocator.free(line); try self.applyProtocolCommand(line); }, .key => |key| switch (key) { .backspace => try self.applyProtocol("command delete_backward"), .arrow_left => try self.applyProtocol("command move_left"), .arrow_right => try self.applyProtocol("command move_right"), else => {}, }, .unknown => {}, } } fn applyLeaderAction(self: *Client, action: leader_mod.Action) !void { switch (action) { .none => {}, .save => try self.save(), .quit => self.quit = true, .open => |path| { const line = try std.fmt.allocPrint(self.allocator, "open {s}", .{path}); defer self.allocator.free(line); try self.applyProtocol(line); }, .symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)), .not_built => {}, } } 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 ")) { self.message = protocolErrorMessage(response); return Error.ProtocolRejected; } self.message = null; } fn protocolErrorMessage(response: []const u8) []const u8 { if (std.mem.indexOf(u8, response, "invalid panel title") != null) return "error: invalid panel title"; if (std.mem.indexOf(u8, response, "no panel open") != null) return "error: no panel open"; if (std.mem.indexOf(u8, response, "invalid list") != null) return "error: invalid list"; if (std.mem.indexOf(u8, response, "active panel is not list") != null) return "error: active panel is not list"; return "error: command failed"; } 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; self.message = "saved"; } fn countClippedSources(self: *const Client, allocator: std.mem.Allocator) !usize { const snap = try self.session.snapshot(); var clipped: usize = 0; if (snap.active_panel_title == null) { var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n'); while (line_iter.next()) |line| { if (session_mod.cellWidth(line) > self.viewport.width) clipped += 1; } if (self.message) |message| { if (session_mod.cellWidth(message) > self.viewport.width) clipped += 1; } return clipped; } const path = try self.session.panelPathAlloc(allocator); defer allocator.free(path); const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path}); defer allocator.free(header); if (session_mod.cellWidth(header) > self.viewport.width) clipped += 1; const remaining_rows = self.viewport.height - 1; const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null; if (rows) |list_rows| { defer { for (list_rows) |row| allocator.free(row); allocator.free(list_rows); } for (list_rows) |row| { if (session_mod.cellWidth(row) > self.viewport.width) clipped += 1; } } else if (snap.active_panel_title) |title| { const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title}); defer allocator.free(detail); if (session_mod.cellWidth(detail) > self.viewport.width) clipped += 1; } const status = if (self.message) |message| try std.fmt.allocPrint(allocator, "{s}", .{message}) else try std.fmt.allocPrint(allocator, "panel {d}/{d} x close", .{ snap.active_panel_index.? + 1, snap.panel_depth }); defer allocator.free(status); if (session_mod.cellWidth(status) > self.viewport.width) clipped += 1; return clipped; } }; 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 diagnoseFrame(viewport: Viewport, frame: []const u8) RenderDiagnostics { var diagnostics = RenderDiagnostics{ .viewport_width = viewport.width, .viewport_height = viewport.height, .frame_bytes = frame.len, .frame_lines = 0, .max_line_cells = 0, .clipped_sources = 0, }; var lines = std.mem.splitScalar(u8, frame, '\n'); while (lines.next()) |line| { diagnostics.frame_lines += 1; diagnostics.max_line_cells = @max(diagnostics.max_line_cells, session_mod.cellWidth(line)); } return diagnostics; } fn renderWarningAlloc(allocator: std.mem.Allocator, diagnostics: RenderDiagnostics) ![]u8 { if (diagnostics.frame_lines != diagnostics.viewport_height) { return std.fmt.allocPrint( allocator, "render warning: expected {d} lines, rendered {d}", .{ diagnostics.viewport_height, diagnostics.frame_lines }, ); } if (diagnostics.max_line_cells > diagnostics.viewport_width) { return std.fmt.allocPrint( allocator, "render warning: line width {d} exceeds viewport {d}", .{ diagnostics.max_line_cells, diagnostics.viewport_width }, ); } if (diagnostics.clipped_sources > 0) { return std.fmt.allocPrint( allocator, "render warning: clipped {d} source line(s) to fit {d} columns", .{ diagnostics.clipped_sources, diagnostics.viewport_width }, ); } return std.fmt.allocPrint( allocator, "render ok: {d} lines, {d} bytes, max {d}/{d} cells", .{ diagnostics.frame_lines, diagnostics.frame_bytes, diagnostics.max_line_cells, diagnostics.viewport_width }, ); } 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")); } test "regular: leader rail is visible in terminal render and dispatches save" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "leader: s save") != null); try client.handleTraceLine("key s"); try std.testing.expectEqualStrings("abc", try client.saved()); } test "regular: leader rail dispatches quit" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); try client.handleTraceLine("key q"); try std.testing.expect(client.quit); try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x")); } test "regular: leader open prompt opens typed UTF-8 payload" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open old"); try client.handleTraceLine("key space"); try client.handleTraceLine("key o"); try client.handleTraceLine("type café.zig"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "café.zig") != null); try client.handleTraceLine("key enter"); try client.handleTraceLine("key space"); try client.handleTraceLine("key s"); try std.testing.expectEqualStrings("café.zig", try client.saved()); } test "regular: leader search and panel-close entries recover as not-built messages" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); try client.handleTraceLine("key /"); const search_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(search_frame); try std.testing.expect(std.mem.indexOf(u8, search_frame, "search is not built") != null); try client.handleTraceLine("key space"); try client.handleTraceLine("key x"); const close_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(close_frame); try std.testing.expect(std.mem.indexOf(u8, close_frame, "panel close is not built") != null); } test "adversarial: unknown leader input recovers without saving or quitting" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); try client.handleTraceLine("key ?"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "unknown leader key") != null); try std.testing.expect(!client.quit); try std.testing.expectError(Error.NothingSaved, client.saved()); } fn expectSymbolTrace(symbol_key: []const u8, expected_bytes: []const u8, expected_cursor_byte: usize) !void { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open base"); try client.handleTraceLine("key space"); try client.handleTraceLine("key p"); const rail = try client.render(std.testing.allocator); defer std.testing.allocator.free(rail); try std.testing.expect(std.mem.indexOf(u8, rail, "symbols:") != null); const line = try std.fmt.allocPrint(std.testing.allocator, "key {s}", .{symbol_key}); defer std.testing.allocator.free(line); try client.handleTraceLine(line); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings(expected_bytes, snap.bytes); try std.testing.expectEqual(expected_cursor_byte, snap.cursor_byte); } test "regular: symbol rail inserts paired braces brackets parens and quotes with cursor inside" { try expectSymbolTrace("p", "()base", 1); try expectSymbolTrace("b", "[]base", 1); try expectSymbolTrace("c", "{}base", 1); try expectSymbolTrace("q", "\"\"base", 1); try expectSymbolTrace("e", "''base", 1); try expectSymbolTrace("t", "``base", 1); } test "regular: symbol rail inserts slash pipe and underscore as single symbols" { try expectSymbolTrace("s", "/base", 1); try expectSymbolTrace("v", "|base", 1); try expectSymbolTrace("u", "_base", 1); } test "adversarial: unknown symbol key does not mutate the buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open base"); try client.handleTraceLine("key space"); try client.handleTraceLine("key p"); try client.handleTraceLine("key ?"); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("base", snap.bytes); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "unknown symbol key") != null); } test "regular: narrow terminal renders active panel instead of editor and returns cleanly" { var client = try Client.init(std.testing.allocator, .{ .width = 20, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open editor-text"); try client.handleTraceLine("panel_open files"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "editor-text") == null); try assertLinesFit(frame, 20); try client.handleTraceLine("panel_close"); const editor_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(editor_frame); try std.testing.expect(std.mem.indexOf(u8, editor_frame, "editor-text") != null); try std.testing.expect(std.mem.indexOf(u8, editor_frame, "panel [files]") == null); try assertLinesFit(editor_frame, 20); } test "regular: nested panel breadcrumbs render on narrow terminal without overflow" { var client = try Client.init(std.testing.allocator, .{ .width = 18, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("panel_open files"); try client.handleTraceLine("panel_open diagnostics"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "panel files>") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "[diagnostics]") == null); try assertLinesFit(frame, 18); try client.handleTraceLine("panel_prev"); const previous_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(previous_frame); try std.testing.expect(std.mem.indexOf(u8, previous_frame, "panel [files]") != null); try assertLinesFit(previous_frame, 18); } test "adversarial: invalid panel title and empty close recover without changing editor render" { var client = try Client.init(std.testing.allocator, .{ .width = 16, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_open bad title")); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_close")); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null); try assertLinesFit(frame, 16); } test "regular: list panel filters moves selects and renders rows" { var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); defer client.deinit(); try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md"); try client.handleTraceLine("list_filter src"); try client.handleTraceLine("list_down"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "> src/panel.zig") != null); try assertLinesFit(frame, 32); try client.handleTraceLine("list_select"); const summary = try client.session.panelSummaryAlloc(std.testing.allocator); defer std.testing.allocator.free(summary); try std.testing.expectEqualStrings("list:filter=src,cursor=1,visible=2,selected=src/panel.zig", summary); } test "regular: list cancel returns to previous editor surface" { var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("list_open files a.zig|b.zig"); try client.handleTraceLine("list_cancel"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "panel") == null); } test "adversarial: list no-match state and invalid actions recover" { var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 }); defer client.deinit(); try client.handleTraceLine("list_open files a.zig|b.zig"); try client.handleTraceLine("list_filter none"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "no matches") != null); try assertLinesFit(frame, 24); try client.handleTraceLine("list_cancel"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("list_down")); } test "regular: save status appears near editor status line" { var client = try Client.init(std.testing.allocator, .{ .width = 20, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); try client.handleTraceLine("key s"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "saved") != null); try assertLinesFit(frame, 20); } test "regular: empty generic panel shows local empty state" { var client = try Client.init(std.testing.allocator, .{ .width = 22, .height = 4 }); defer client.deinit(); try client.handleTraceLine("panel_open help"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "help: no content yet") != null); try assertLinesFit(frame, 22); } test "adversarial: protocol errors render recoverable local message" { var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_open bad title")); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "error: invalid panel") != null); try assertLinesFit(frame, 24); } test "adversarial: long feedback messages are clipped to viewport width" { var client = try Client.init(std.testing.allocator, .{ .width = 16, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("message this-message-is-way-too-long-for-the-phone-width"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try assertLinesFit(frame, 16); try std.testing.expect(std.mem.indexOf(u8, frame, "this-message-is-") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "phone-width") == null); } test "regular: render diagnostics report ok for representative narrow editor frame" { var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open fn main() {}"); try client.handleTraceLine("key space"); try client.handleTraceLine("key p"); try client.handleTraceLine("key c"); try client.handleTraceLine("type x"); const result = try client.renderWithDiagnostics(std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines); try std.testing.expect(result.diagnostics.frame_bytes <= 32 * 5 * 4); try std.testing.expect(result.diagnostics.max_line_cells <= 32); try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources); try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null); } test "regular: render diagnostics report ok for representative list panel frame" { var client = try Client.init(std.testing.allocator, .{ .width = 36, .height = 5 }); defer client.deinit(); try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md"); try client.handleTraceLine("list_filter src"); try client.handleTraceLine("list_down"); const result = try client.renderWithDiagnostics(std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines); try std.testing.expect(result.diagnostics.frame_bytes <= 36 * 5 * 4); try std.testing.expect(result.diagnostics.max_line_cells <= 36); try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources); try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null); } test "adversarial: render diagnostics name clipped editor sources" { var client = try Client.init(std.testing.allocator, .{ .width = 12, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open this-line-is-far-too-long-for-phone"); const result = try client.renderWithDiagnostics(std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines); try std.testing.expect(result.diagnostics.max_line_cells <= 12); try std.testing.expect(result.diagnostics.clipped_sources >= 1); try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null); try std.testing.expect(std.mem.indexOf(u8, result.warning, "12 columns") != null); } test "adversarial: render diagnostics name clipped panel sources" { var client = try Client.init(std.testing.allocator, .{ .width = 14, .height = 4 }); defer client.deinit(); try client.handleTraceLine("list_open files src/very-long-mobile-render-file-name.zig|b.zig"); const result = try client.renderWithDiagnostics(std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines); try std.testing.expect(result.diagnostics.max_line_cells <= 14); try std.testing.expect(result.diagnostics.clipped_sources >= 1); try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null); } test "regular: file picker respects gitignore by default and opens selected file" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 }); defer client.deinit(); try client.handleTraceLine("repo_gitignore zig-out/"); try client.handleTraceLine("repo_gitignore *.o"); try client.handleTraceLine("repo_file src/main.zig=pubfnmain"); try client.handleTraceLine("repo_file zig-out/bin/mim=binary"); try client.handleTraceLine("repo_file build.o=object"); try client.handleTraceLine("file_picker"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out") == null); try std.testing.expect(std.mem.indexOf(u8, frame, "build.o") == null); } try client.handleTraceLine("file_open_selected"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "pubfnmain") != null); } } test "regular: file picker include ignored toggle exposes ignored files" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 }); defer client.deinit(); try client.handleTraceLine("repo_gitignore zig-out/"); try client.handleTraceLine("repo_file src/main.zig=main"); try client.handleTraceLine("repo_file zig-out/bin/mim=binary"); try client.handleTraceLine("file_picker all"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out/bin/mim") != null); } test "regular: file tree uses the same panel primitive and include ignored boundary" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 }); defer client.deinit(); try client.handleTraceLine("repo_gitignore tmp/"); try client.handleTraceLine("repo_file src/lib.zig=lib"); try client.handleTraceLine("repo_file tmp/log.txt=log"); try client.handleTraceLine("file_tree"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "tmp/log") == null); } try client.handleTraceLine("panel_close"); try client.handleTraceLine("file_tree all"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "tmp/log.txt") != null); } } test "adversarial: invalid repo paths and missing selections fail without corrupting editor" { var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(repo_mod.Error.InvalidPath, client.handleTraceLine("repo_file ../secret=x")); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("file_open_selected")); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "safe") != null); }