Files
mim/src/tui.zig
T
2026-06-21 03:01:20 +02:00

1035 lines
44 KiB
Zig

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);
}