From 73c65a9b6f8ed7d9e93bc5a5b5089114a366dbf4 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 05:08:23 +0200 Subject: [PATCH] Add foreground Pi bridge panel --- src/main.zig | 2 + src/pi_bridge.zig | 137 ++++++++++++++++++++++++++++++++++++++++++++++ src/tui.zig | 64 ++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 src/pi_bridge.zig diff --git a/src/main.zig b/src/main.zig index c92e184..76c97d1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -7,6 +7,7 @@ const leader = @import("leader.zig"); const lsp = @import("lsp.zig"); const mobile_acceptance = @import("mobile_acceptance.zig"); const panel = @import("panel.zig"); +const pi_bridge = @import("pi_bridge.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const repo = @import("repo.zig"); @@ -191,6 +192,7 @@ test { _ = lsp; _ = mobile_acceptance; _ = panel; + _ = pi_bridge; _ = protocol; _ = replay; _ = repo; diff --git a/src/pi_bridge.zig b/src/pi_bridge.zig new file mode 100644 index 0000000..83a22ea --- /dev/null +++ b/src/pi_bridge.zig @@ -0,0 +1,137 @@ +const std = @import("std"); + +// Foreground Pi/local assistant bridge over compact context. +// req: session/002, session/004, governance/002, governance/003, testing/001, testing/002 + +test { + _ = rowsAlloc; +} + +pub const Error = error{ + InvalidCommand, + InvalidContext, +}; + +pub fn rowsAlloc(allocator: std.mem.Allocator, io: std.Io, argv: []const []const u8, context: []const u8) ![][]const u8 { + try validateArgv(argv); + try validateContext(context); + const full_argv = try allocator.alloc([]const u8, argv.len + 1); + defer allocator.free(full_argv); + @memcpy(full_argv[0..argv.len], argv); + full_argv[argv.len] = context; + + var rows = std.ArrayList([]const u8).empty; + errdefer { + for (rows.items) |row| allocator.free(row); + rows.deinit(allocator); + } + const preview = try commandPreviewAlloc(allocator, argv); + defer allocator.free(preview); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "pi:spawned:{s}", .{preview})); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "pi:context:bytes_{d}", .{context.len})); + + const result = std.process.run(allocator, io, .{ + .argv = full_argv, + .stdout_limit = .limited(128 * 1024), + .stderr_limit = .limited(64 * 1024), + }) catch |err| { + try rows.append(allocator, try std.fmt.allocPrint(allocator, "pi:status:spawn_error_{s}", .{@errorName(err)})); + return rows.toOwnedSlice(allocator); + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + try appendTermRow(allocator, &rows, result.term); + try appendOutputRows(allocator, &rows, "stdout", result.stdout); + try appendOutputRows(allocator, &rows, "stderr", result.stderr); + if (rows.items.len == 3) try rows.append(allocator, try allocator.dupe(u8, "pi:output_empty")); + return rows.toOwnedSlice(allocator); +} + +fn validateArgv(argv: []const []const u8) !void { + if (argv.len == 0) return Error.InvalidCommand; + for (argv) |arg| { + if (arg.len == 0 or !std.unicode.utf8ValidateSlice(arg)) return Error.InvalidCommand; + for (arg) |byte| if (byte == 0 or byte == '\n' or byte == '\r') return Error.InvalidCommand; + } +} + +fn validateContext(context: []const u8) !void { + if (context.len == 0 or !std.unicode.utf8ValidateSlice(context)) return Error.InvalidContext; + for (context) |byte| if (byte == 0) return Error.InvalidContext; +} + +fn appendTermRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), term: std.process.Child.Term) !void { + const row = switch (term) { + .exited => |code| try std.fmt.allocPrint(allocator, "pi:status:exit_{d}", .{code}), + .signal => |sig| try std.fmt.allocPrint(allocator, "pi:status:signal_{d}", .{@intFromEnum(sig)}), + .stopped => |sig| try std.fmt.allocPrint(allocator, "pi:status:stopped_{d}", .{@intFromEnum(sig)}), + .unknown => |code| try std.fmt.allocPrint(allocator, "pi:status:unknown_{d}", .{code}), + }; + try rows.append(allocator, row); +} + +fn appendOutputRows(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), prefix: []const u8, output: []const u8) !void { + var lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |raw_line| { + const trimmed = std.mem.trim(u8, raw_line, "\r"); + if (trimmed.len == 0) continue; + const sanitized = try sanitizeAlloc(allocator, trimmed, 120); + defer allocator.free(sanitized); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:{s}", .{ prefix, sanitized })); + } +} + +fn commandPreviewAlloc(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + for (argv, 0..) |arg, index| { + if (index != 0) try out.append(allocator, '_'); + const clean = try sanitizeAlloc(allocator, arg, 40); + defer allocator.free(clean); + try out.appendSlice(allocator, clean); + if (out.items.len >= 120) break; + } + return out.toOwnedSlice(allocator); +} + +fn sanitizeAlloc(allocator: std.mem.Allocator, value: []const u8, max_len: usize) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + var i: usize = 0; + while (i < value.len and out.items.len < max_len) { + const len = std.unicode.utf8ByteSequenceLength(value[i]) catch 1; + const end = @min(value.len, i + len); + const slice = value[i..end]; + if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == '|')) { + try out.append(allocator, '_'); + } else { + try out.appendSlice(allocator, slice); + } + i = end; + } + if (i < value.len) try out.appendSlice(allocator, "..."); + if (out.items.len == 0) try out.append(allocator, '_'); + return out.toOwnedSlice(allocator); +} + +fn freeRows(allocator: std.mem.Allocator, rows: []const []const u8) void { + for (rows) |row| allocator.free(row); + allocator.free(rows); +} + +test "regular: fake pi command receives context and emits output rows" { + const context = "context:v1\ntask=fix_bug\n"; + const rows = try rowsAlloc(std.testing.allocator, std.testing.io, &.{ "sh", "-c", "case \"$1\" in *context:v1*) echo pi_ok;; *) echo missing; exit 2;; esac", "fake-pi" }, context); + defer freeRows(std.testing.allocator, rows); + try std.testing.expectEqualStrings("pi:status:exit_0", rows[2]); + try std.testing.expectEqualStrings("stdout:pi_ok", rows[3]); +} + +test "adversarial: missing pi command and invalid input are explicit" { + try std.testing.expectError(Error.InvalidCommand, rowsAlloc(std.testing.allocator, std.testing.io, &.{}, "context:v1\n")); + try std.testing.expectError(Error.InvalidContext, rowsAlloc(std.testing.allocator, std.testing.io, &.{"true"}, "")); + const rows = try rowsAlloc(std.testing.allocator, std.testing.io, &.{"definitely-not-a-mim-pi"}, "context:v1\n"); + defer freeRows(std.testing.allocator, rows); + try std.testing.expect(std.mem.startsWith(u8, rows[2], "pi:status:spawn_error_")); +} diff --git a/src/tui.zig b/src/tui.zig index db81174..63bfb6d 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1,8 +1,10 @@ const std = @import("std"); +const context_mod = @import("context.zig"); const input = @import("input.zig"); const job_mod = @import("job.zig"); const leader_mod = @import("leader.zig"); const lsp_mod = @import("lsp.zig"); +const pi_bridge = @import("pi_bridge.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const repo_mod = @import("repo.zig"); @@ -164,6 +166,7 @@ pub const Client = struct { if (std.mem.startsWith(u8, line, "lsp_signature ")) return self.openLspSignature(line[14..]); if (std.mem.eql(u8, line, "lsp_param_next")) return self.moveLspParameter(.next); if (std.mem.eql(u8, line, "lsp_param_previous")) return self.moveLspParameter(.previous); + if (std.mem.startsWith(u8, line, "pi_run ")) return self.openPiRun(line[7..]); 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); @@ -537,6 +540,23 @@ pub const Client = struct { self.message = null; } + fn openPiRun(self: *Client, command_line: []const u8) !void { + const io = self.io orelse return Error.ProtocolRejected; + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(self.allocator); + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, command_line, " "), ' '); + while (parts.next()) |part| { + if (part.len == 0) continue; + try argv.append(self.allocator, part); + } + const context_text = try context_mod.sessionContextAlloc(self.allocator, &self.session, "pi_run", "bridge", .{}); + defer self.allocator.free(context_text); + const rows = pi_bridge.rowsAlloc(self.allocator, io, argv.items, context_text) catch return Error.ProtocolRejected; + defer freeOwnedRows(self.allocator, rows); + try self.session.openListPanel("pi-output", rows); + self.message = null; + } + fn jumpCurrentBufferToLspLocation(self: *Client, location: lsp_mod.DiagnosticLocation) !void { const snap = try self.session.snapshot(); const bytes = try self.allocator.dupe(u8, snap.bytes); @@ -1954,3 +1974,47 @@ test "adversarial: malformed hover signature and non-call parameter movement are defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:outside_call") != null); } + +fn makeTuiPiFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, script: []u8 } { + var tmp = std.testing.tmpDir(.{}); + errdefer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "fake-pi.sh", .data = "case \"$1\" in *context:v1*) echo pi_ok;; *) echo missing; exit 2;; esac\n" }); + const script = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}/fake-pi.sh", .{&tmp.sub_path}); + errdefer allocator.free(script); + return .{ .tmp = tmp, .script = script }; +} + +test "regular: pi bridge sends context to fake pi and renders output panel" { + var fixture = try makeTuiPiFixture(std.testing.allocator); + defer { + std.testing.allocator.free(fixture.script); + fixture.tmp.cleanup(); + } + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 80, .height = 7 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("open working buffer"); + const command = try std.fmt.allocPrint(std.testing.allocator, "pi_run sh {s}", .{fixture.script}); + defer std.testing.allocator.free(command); + try client.handleTraceLine(command); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "pi-output") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "pi:context:bytes_") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "pi:status:exit_0") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:pi_ok") != null); +} + +test "adversarial: missing pi command is visible and invalid command preserves buffer" { + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 80, .height = 6 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("open safe"); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("pi_run ")); + try client.handleTraceLine("pi_run definitely-not-a-mim-pi"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "pi:status:spawn_error_") != null); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("safe", snap.bytes); +}