From e540e64d930433cc030d8eba8650910f5ef8b15d Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 02:50:19 +0200 Subject: [PATCH] Show recoverable panel and editor feedback --- src/tui.zig | 86 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index 290b34e..04039c3 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -65,6 +65,7 @@ pub const Client = struct { leader: leader_mod.Leader, viewport: Viewport, saved_bytes: ?[]u8 = null, + message: ?[]const u8 = null, quit: bool = false, pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client { @@ -96,6 +97,10 @@ pub const Client = struct { 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"); @@ -149,7 +154,9 @@ pub const Client = struct { } const leader_status = self.leader.status(); - const status = if (leader_status.len != 0) + 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}", @@ -206,11 +213,14 @@ pub const Client = struct { try out.append(allocator, '\n'); } - const status = try std.fmt.allocPrint( - allocator, - "panel {d}/{d} x close", - .{ snap.active_panel_index.? + 1, snap.panel_depth }, - ); + 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); @@ -293,7 +303,19 @@ pub const Client = struct { 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; + 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 { @@ -310,6 +332,7 @@ pub const Client = struct { 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"; } }; @@ -665,3 +688,52 @@ test "adversarial: list no-match state and invalid actions recover" { 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); +}