diff --git a/TUI/app.zig b/TUI/app.zig index d62a5489..5993f3cb 100644 --- a/TUI/app.zig +++ b/TUI/app.zig @@ -262,7 +262,7 @@ pub const Model = struct { } pub fn destroyJob(self: *Model, job: *engine.Job) void { - if (job.threaded) job.thread.join(); + engine.joinJob(job); if (job.result) |r| self.alloc.free(r); for (job.history) |t| self.alloc.free(t.text); self.alloc.free(job.history); diff --git a/TUI/bgop.zig b/TUI/bgop.zig index 3753ebcc..21530f87 100644 --- a/TUI/bgop.zig +++ b/TUI/bgop.zig @@ -16,11 +16,21 @@ const turn = @import("turn.zig"); const Model = app.Model; const Op = engine.BgOp; +const SpawnFn = *const fn (*Op) anyerror!std.Thread; + +fn spawnOp(op: *Op) anyerror!std.Thread { + return std.Thread.spawn(.{}, engine.bgRun, .{op}); +} + /// Spawn `kind` in the background, taking ownership of `turns` and `cmd`. /// A non-empty `label` pushes a live row so the wait is visible. Returns false /// when a turn or another op already holds the engine — the caller still owns /// its inputs then. pub fn start(self: *Model, kind: Op.Kind, turns: []engine.Turn, cmd: []const u8, label: []const u8) bool { + return startWithSpawn(self, kind, turns, cmd, label, spawnOp); +} + +fn startWithSpawn(self: *Model, kind: Op.Kind, turns: []engine.Turn, cmd: []const u8, label: []const u8, spawn_fn: SpawnFn) bool { if (self.bg != null or self.pending != null) return false; const op = self.alloc.create(Op) catch return false; // The op carries the same policy a turn does: `!cmd` goes through the @@ -28,12 +38,14 @@ pub fn start(self: *Model, kind: Op.Kind, turns: []engine.Turn, cmd: []const u8, op.* = .{ .kind = kind, .gpa = self.alloc, .turns = turns, .cmd = cmd, .params = turn.paramsOf(self) }; if (label.len > 0) self.push(.pending, label) catch {}; self.bg = op; - if (std.Thread.spawn(.{}, engine.bgRun, .{op})) |th| { + if (spawn_fn(op)) |th| { op.thread = th; } else |_| { - // No thread available: run it inline rather than dropping the command. + // Running inline would freeze paint, input and cancellation — report + // the failed start through finish() without invoking engine work. op.threaded = false; - engine.bgRun(op); + op.start_failed = true; + op.done.store(true, .release); } return true; } @@ -55,7 +67,13 @@ pub fn finish(self: *Model) void { if (op.threaded) op.thread.join(); op.threaded = false; // release() must not join a second time _ = turn.removePendingRows(self); - switch (op.kind) { + if (op.start_failed) { + self.push(.err, switch (op.kind) { + .compact => "compaction failed to start", + .bash => "command failed to start", + .files => "file list failed to start", + }) catch {}; + } else switch (op.kind) { .compact => applyCompact(self, op), .bash => applyBash(self, op), .files => applyFiles(self, op), @@ -248,6 +266,55 @@ test "a second op is refused while one is in flight, and Esc cancels the live on try testing.expect(m.bg == null); } +test "thread spawn failure completes through finish without inline engine work (#537)" { + const Fake = struct { + var calls: usize = 0; + fn spawn(_: *Op) anyerror!std.Thread { + return error.InjectedSpawnFailure; + } + fn compact(_: ?*anyopaque, _: std.mem.Allocator, _: []const engine.Turn, _: *engine.CompactOut) bool { + calls += 1; + return false; + } + fn bash(_: ?*anyopaque, _: std.mem.Allocator, _: []const u8, _: engine.Params) ?[]const u8 { + calls += 1; + return null; + } + fn files(_: ?*anyopaque, _: std.mem.Allocator) ?[]const u8 { + calls += 1; + return null; + } + }; + Fake.calls = 0; + engine.g_compact_fn = Fake.compact; + engine.g_bash_fn = Fake.bash; + engine.g_files_fn = Fake.files; + defer { + engine.g_compact_fn = null; + engine.g_bash_fn = null; + engine.g_files_fn = null; + } + const cases = [_]struct { kind: Op.Kind, message: []const u8 }{ + .{ .kind = .compact, .message = "compaction failed to start" }, + .{ .kind = .bash, .message = "command failed to start" }, + .{ .kind = .files, .message = "file list failed to start" }, + }; + for (cases) |case| { + var m: Model = undefined; + m.setup(testing.allocator); + defer m.deinit(); + try testing.expect(startWithSpawn(&m, case.kind, &.{}, "", "running", Fake.spawn)); + const op = m.bg.?; + try testing.expect(op.start_failed and !op.threaded and op.done.load(.acquire)); + try testing.expectEqual(@as(usize, 0), Fake.calls); + finish(&m); + try testing.expect(m.bg == null); + try testing.expectEqual(app.EntryKind.err, m.history.items[0].kind); + try testing.expectEqualStrings(case.message, m.history.items[0].text); + } + try testing.expectEqual(@as(usize, 0), Fake.calls); +} + test "quit gives a stuck op a bounded wait, then abandons it (#533/#534)" { var m: Model = undefined; m.setup(testing.allocator); diff --git a/TUI/dispatch.zig b/TUI/dispatch.zig index a5e2b115..935cba68 100644 --- a/TUI/dispatch.zig +++ b/TUI/dispatch.zig @@ -19,6 +19,16 @@ const Effect = app.Effect; /// a model turn all use the one engine, so they queue behind each other. pub const busy_note = "an engine call is still running — press Esc to cancel it"; +/// A production model switch replaces ReplCtx.provider and clears its fallback +/// flags. Turns and background ops borrow that same context, so the mutation +/// waits for the existing one-engine policy even within one input batch. +/// Browsing the picker remains UI-only; callers use this at confirmation. +pub fn refuseProviderMutation(self: *Model) bool { + if (self.pending == null and self.bg == null) return false; + self.push(.system, busy_note) catch {}; + return true; +} + pub fn applyLine(self: *Model, raw: []const u8) Effect { const line = std.mem.trim(u8, raw, " \t\r\n"); if (line.len == 0 and self.images.items.len == 0) return .stay; @@ -105,6 +115,8 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { } else if (std.mem.eql(u8, canon, "/model")) { if (arg.len == 0) { self.openOverlay(.model); + } else if (refuseProviderMutation(self)) { + return .stay; } else if (engine.g_model_fn) |f| { // A hand-typed name names no provider, so the engine routes it — // the picker is the surface that knows which seat was meant. @@ -365,233 +377,7 @@ fn onOff(v: bool) []const u8 { return if (v) "on" else "off"; } -test "applyLine /quit and /new" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - try std.testing.expectEqual(Effect.quit, applyLine(&m, "/quit")); - m.quit_requested = false; - try m.push(.user, "keep me"); - _ = applyLine(&m, "/new"); - try std.testing.expectEqual(app.Screen.welcome, m.screen); - try std.testing.expectEqual(@as(usize, 1), m.history.items.len); // system notice -} - -test "/debug opens the observability overlay" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - _ = applyLine(&m, "/debug"); - try std.testing.expectEqual(app.Overlay.debug, m.overlay); -} - -test "/cache opens the same observability overlay" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - _ = applyLine(&m, "/cache"); - try std.testing.expectEqual(app.Overlay.debug, m.overlay); -} - -test "/usage is not a char-count view" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - _ = applyLine(&m, "/usage"); - const text = m.history.items[m.history.items.len - 1].text; - try std.testing.expect(std.mem.indexOf(u8, text, "chars sent") == null); - try std.testing.expect(std.mem.indexOf(u8, text, "no session sink") != null); -} - -test "rewind drops the last user turn" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - try m.push(.user, "one"); - try m.push(.assistant, "two"); - rewind(&m); - try std.testing.expectEqual(@as(usize, 1), m.history.items.len); // rewind notice -} - -test "core pager commands change shipped model state" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - try std.testing.expectEqual(Effect.quit, applyLine(&m, "/exit")); - m.quit_requested = false; - try std.testing.expectEqual(Effect.quit, applyLine(&m, "/q")); - m.quit_requested = false; - - _ = applyLine(&m, "/help"); - try std.testing.expectEqual(app.Overlay.help, m.overlay); - m.closeOverlay(); - - try m.push(.user, "stay"); - _ = applyLine(&m, "/home"); - try std.testing.expectEqual(app.Screen.welcome, m.screen); - try std.testing.expectEqual(app.Focus.prompt, m.focus); - - try std.testing.expectEqual(app.AgentMode.normal, m.mode); - _ = applyLine(&m, "/plan"); - try std.testing.expectEqual(app.AgentMode.plan, m.mode); - _ = applyLine(&m, "/plan"); - try std.testing.expectEqual(app.AgentMode.normal, m.mode); - _ = applyLine(&m, "/always-approve"); - try std.testing.expectEqual(app.AgentMode.always_approve, m.mode); - _ = applyLine(&m, "/yolo"); - try std.testing.expectEqual(app.AgentMode.normal, m.mode); - - _ = applyLine(&m, "/settings"); - try std.testing.expectEqual(app.Overlay.settings, m.overlay); - m.closeOverlay(); - - _ = applyLine(&m, "/model"); - try std.testing.expectEqual(app.Overlay.model, m.overlay); - m.closeOverlay(); - - _ = applyLine(&m, "/clear"); - try std.testing.expectEqual(app.Screen.welcome, m.screen); -} - -test "/usage with a session HUD is the cost line, not chars" { - engine.g_hud_fn = struct { - fn f(kind: engine.HudKind, buf: []u8) usize { - if (kind != .usage) return 0; - const s = "1 api call(s) · 1200 in (200 cached) + 50 out tokens · $0.0123\n"; - const n = @min(s.len, buf.len); - @memcpy(buf[0..n], s[0..n]); - return n; - } - }.f; - defer engine.g_hud_fn = null; - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - _ = applyLine(&m, "/cost"); - const text = m.history.items[m.history.items.len - 1].text; - try std.testing.expect(std.mem.indexOf(u8, text, "api call(s)") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "$0.0123") != null); - try std.testing.expect(std.mem.indexOf(u8, text, "chars sent") == null); - try std.testing.expect(std.mem.indexOf(u8, text, "offline") == null); -} - -test "every slash name printed in /help dispatches" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - m.openOverlay(.help); - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const text = try @import("chrome.zig").overlay(&m, arena.allocator(), 80); - m.closeOverlay(); - - var seen: usize = 0; - var i: usize = 0; - while (i < text.len) { - if (text[i] != '/') { - i += 1; - continue; - } - var j = i + 1; - while (j < text.len and (std.ascii.isAlphanumeric(text[j]) or text[j] == '-')) j += 1; - if (j == i + 1) { - i += 1; - continue; - } - const name = text[i..j]; - const before = m.history.items.len; - const effect = applyLine(&m, name); - if (std.mem.eql(u8, name, "/quit") or std.mem.eql(u8, name, "/exit") or std.mem.eql(u8, name, "/q")) { - try std.testing.expectEqual(Effect.quit, effect); - m.quit_requested = false; - } else { - try std.testing.expectEqual(Effect.stay, effect); - } - if (m.history.items.len > before) { - const last = m.history.items[m.history.items.len - 1].text; - try std.testing.expect(std.mem.indexOf(u8, last, "unknown command") == null); - } - seen += 1; - i = j; - } - try std.testing.expect(seen >= 9); -} - -test "/image attaches a path the next send carries as @[path]" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - _ = applyLine(&m, "/image /tmp/shot.png"); - try std.testing.expectEqual(@as(usize, 1), m.images.items.len); - _ = applyLine(&m, "what is this"); - var user_text: []const u8 = ""; - for (m.history.items) |e| { - if (e.kind == .user) user_text = e.text; - } - try std.testing.expect(std.mem.indexOf(u8, user_text, "@[/tmp/shot.png]") != null); - try std.testing.expect(std.mem.indexOf(u8, user_text, "what is this") != null); - try std.testing.expectEqual(@as(usize, 0), m.images.items.len); -} - -test "looksLikeImagePath accepts file URLs and extensions" { - try std.testing.expect(looksLikeImagePath("/tmp/a.png")); - try std.testing.expect(looksLikeImagePath("file:///Users/me/x.JPEG")); - try std.testing.expect(!looksLikeImagePath("hello.png is a format")); - try std.testing.expect(!looksLikeImagePath("readme.md")); - try std.testing.expect(looksLikeImagePath("/Users/me/My Shot.png")); - try std.testing.expect(!looksLikeImagePath("see /tmp/a.png")); -} - -test "/effort with no arg opens the effort menu" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - m.effort = .high; - try std.testing.expectEqual(app.Effect.stay, applyLine(&m, "/effort")); - try std.testing.expectEqual(app.Overlay.effort, m.overlay); - try std.testing.expectEqual(@as(usize, @intFromEnum(engine.Effort.high)), m.overlay_sel); - try std.testing.expectEqual(app.Effect.stay, applyLine(&m, "/effort low")); - try std.testing.expectEqual(engine.Effort.low, m.effort); -} - -test "/vim-mode toggles and /jump without turns explains" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - try std.testing.expect(!m.vim_mode); - _ = applyLine(&m, "/vim-mode"); - try std.testing.expect(m.vim_mode); - _ = applyLine(&m, "/vim"); - try std.testing.expect(!m.vim_mode); - _ = applyLine(&m, "/jump"); - const last = m.history.items[m.history.items.len - 1].text; - try std.testing.expect(std.mem.indexOf(u8, last, "nothing to jump") != null); - try m.push(.user, "hi"); - _ = applyLine(&m, "/jump"); - try std.testing.expectEqual(app.Overlay.jump, m.overlay); -} - -test "/btw queues an aside while a turn is pending" { - var m: Model = undefined; - m.setup(std.testing.allocator); - defer m.deinit(); - const job = try std.testing.allocator.create(engine.Job); - job.* = .{ .gpa = std.testing.allocator, .history = &.{}, .params = .{}, .stream = .{}, .threaded = false }; - m.pending = job; - defer { - m.pending = null; - std.testing.allocator.destroy(job); - } - _ = applyLine(&m, "/btw remember the tests"); - try std.testing.expectEqual(@as(usize, 1), m.steer_queue.items.len); - try std.testing.expectEqualStrings("remember the tests", m.steer_queue.items[0]); -} - -test "lastLines caps ! output to the tail" { - try std.testing.expectEqualStrings("c\nd", lastLines("a\nb\nc\nd", 2)); - try std.testing.expectEqualStrings("a\nb", lastLines("a\nb", 5)); -} - test { _ = @import("dispatch_tests.zig"); // overflow tests (600-line cap) + _ = @import("dispatch_command_tests.zig"); } diff --git a/TUI/dispatch_command_tests.zig b/TUI/dispatch_command_tests.zig new file mode 100644 index 00000000..ec9ea260 --- /dev/null +++ b/TUI/dispatch_command_tests.zig @@ -0,0 +1,240 @@ +//! dispatch.zig command-path tests. + +const std = @import("std"); + +const app = @import("app.zig"); +const dispatch = @import("dispatch.zig"); +const engine = @import("engine.zig"); +const Effect = app.Effect; +const Model = app.Model; +const applyLine = dispatch.applyLine; +const lastLines = dispatch.lastLines; +const looksLikeImagePath = dispatch.looksLikeImagePath; +const rewind = dispatch.rewind; + +test "applyLine /quit and /new" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + try std.testing.expectEqual(Effect.quit, applyLine(&m, "/quit")); + m.quit_requested = false; + try m.push(.user, "keep me"); + _ = applyLine(&m, "/new"); + try std.testing.expectEqual(app.Screen.welcome, m.screen); + try std.testing.expectEqual(@as(usize, 1), m.history.items.len); // system notice +} + +test "/debug opens the observability overlay" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + _ = applyLine(&m, "/debug"); + try std.testing.expectEqual(app.Overlay.debug, m.overlay); +} + +test "/cache opens the same observability overlay" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + _ = applyLine(&m, "/cache"); + try std.testing.expectEqual(app.Overlay.debug, m.overlay); +} + +test "/usage is not a char-count view" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + _ = applyLine(&m, "/usage"); + const text = m.history.items[m.history.items.len - 1].text; + try std.testing.expect(std.mem.indexOf(u8, text, "chars sent") == null); + try std.testing.expect(std.mem.indexOf(u8, text, "no session sink") != null); +} + +test "rewind drops the last user turn" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + try m.push(.user, "one"); + try m.push(.assistant, "two"); + rewind(&m); + try std.testing.expectEqual(@as(usize, 1), m.history.items.len); // rewind notice +} + +test "core pager commands change shipped model state" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + try std.testing.expectEqual(Effect.quit, applyLine(&m, "/exit")); + m.quit_requested = false; + try std.testing.expectEqual(Effect.quit, applyLine(&m, "/q")); + m.quit_requested = false; + + _ = applyLine(&m, "/help"); + try std.testing.expectEqual(app.Overlay.help, m.overlay); + m.closeOverlay(); + + try m.push(.user, "stay"); + _ = applyLine(&m, "/home"); + try std.testing.expectEqual(app.Screen.welcome, m.screen); + try std.testing.expectEqual(app.Focus.prompt, m.focus); + + try std.testing.expectEqual(app.AgentMode.normal, m.mode); + _ = applyLine(&m, "/plan"); + try std.testing.expectEqual(app.AgentMode.plan, m.mode); + _ = applyLine(&m, "/plan"); + try std.testing.expectEqual(app.AgentMode.normal, m.mode); + _ = applyLine(&m, "/always-approve"); + try std.testing.expectEqual(app.AgentMode.always_approve, m.mode); + _ = applyLine(&m, "/yolo"); + try std.testing.expectEqual(app.AgentMode.normal, m.mode); + + _ = applyLine(&m, "/settings"); + try std.testing.expectEqual(app.Overlay.settings, m.overlay); + m.closeOverlay(); + + _ = applyLine(&m, "/model"); + try std.testing.expectEqual(app.Overlay.model, m.overlay); + m.closeOverlay(); + + _ = applyLine(&m, "/clear"); + try std.testing.expectEqual(app.Screen.welcome, m.screen); +} + +test "/usage with a session HUD is the cost line, not chars" { + engine.g_hud_fn = struct { + fn f(kind: engine.HudKind, buf: []u8) usize { + if (kind != .usage) return 0; + const s = "1 api call(s) · 1200 in (200 cached) + 50 out tokens · $0.0123\n"; + const n = @min(s.len, buf.len); + @memcpy(buf[0..n], s[0..n]); + return n; + } + }.f; + defer engine.g_hud_fn = null; + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + _ = applyLine(&m, "/cost"); + const text = m.history.items[m.history.items.len - 1].text; + try std.testing.expect(std.mem.indexOf(u8, text, "api call(s)") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "$0.0123") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "chars sent") == null); + try std.testing.expect(std.mem.indexOf(u8, text, "offline") == null); +} + +test "every slash name printed in /help dispatches" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + m.openOverlay(.help); + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const text = try @import("chrome.zig").overlay(&m, arena.allocator(), 80); + m.closeOverlay(); + + var seen: usize = 0; + var i: usize = 0; + while (i < text.len) { + if (text[i] != '/') { + i += 1; + continue; + } + var j = i + 1; + while (j < text.len and (std.ascii.isAlphanumeric(text[j]) or text[j] == '-')) j += 1; + if (j == i + 1) { + i += 1; + continue; + } + const name = text[i..j]; + const before = m.history.items.len; + const effect = applyLine(&m, name); + if (std.mem.eql(u8, name, "/quit") or std.mem.eql(u8, name, "/exit") or std.mem.eql(u8, name, "/q")) { + try std.testing.expectEqual(Effect.quit, effect); + m.quit_requested = false; + } else { + try std.testing.expectEqual(Effect.stay, effect); + } + if (m.history.items.len > before) { + const last = m.history.items[m.history.items.len - 1].text; + try std.testing.expect(std.mem.indexOf(u8, last, "unknown command") == null); + } + seen += 1; + i = j; + } + try std.testing.expect(seen >= 9); +} + +test "/image attaches a path the next send carries as @[path]" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + _ = applyLine(&m, "/image /tmp/shot.png"); + try std.testing.expectEqual(@as(usize, 1), m.images.items.len); + _ = applyLine(&m, "what is this"); + var user_text: []const u8 = ""; + for (m.history.items) |e| { + if (e.kind == .user) user_text = e.text; + } + try std.testing.expect(std.mem.indexOf(u8, user_text, "@[/tmp/shot.png]") != null); + try std.testing.expect(std.mem.indexOf(u8, user_text, "what is this") != null); + try std.testing.expectEqual(@as(usize, 0), m.images.items.len); +} + +test "looksLikeImagePath accepts file URLs and extensions" { + try std.testing.expect(looksLikeImagePath("/tmp/a.png")); + try std.testing.expect(looksLikeImagePath("file:///Users/me/x.JPEG")); + try std.testing.expect(!looksLikeImagePath("hello.png is a format")); + try std.testing.expect(!looksLikeImagePath("readme.md")); + try std.testing.expect(looksLikeImagePath("/Users/me/My Shot.png")); + try std.testing.expect(!looksLikeImagePath("see /tmp/a.png")); +} + +test "/effort with no arg opens the effort menu" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + m.effort = .high; + try std.testing.expectEqual(app.Effect.stay, applyLine(&m, "/effort")); + try std.testing.expectEqual(app.Overlay.effort, m.overlay); + try std.testing.expectEqual(@as(usize, @intFromEnum(engine.Effort.high)), m.overlay_sel); + try std.testing.expectEqual(app.Effect.stay, applyLine(&m, "/effort low")); + try std.testing.expectEqual(engine.Effort.low, m.effort); +} + +test "/vim-mode toggles and /jump without turns explains" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + try std.testing.expect(!m.vim_mode); + _ = applyLine(&m, "/vim-mode"); + try std.testing.expect(m.vim_mode); + _ = applyLine(&m, "/vim"); + try std.testing.expect(!m.vim_mode); + _ = applyLine(&m, "/jump"); + const last = m.history.items[m.history.items.len - 1].text; + try std.testing.expect(std.mem.indexOf(u8, last, "nothing to jump") != null); + try m.push(.user, "hi"); + _ = applyLine(&m, "/jump"); + try std.testing.expectEqual(app.Overlay.jump, m.overlay); +} + +test "/btw queues an aside while a turn is pending" { + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + const job = try std.testing.allocator.create(engine.Job); + job.* = .{ .gpa = std.testing.allocator, .history = &.{}, .params = .{}, .stream = .{}, .threaded = false }; + m.pending = job; + defer { + m.pending = null; + std.testing.allocator.destroy(job); + } + _ = applyLine(&m, "/btw remember the tests"); + try std.testing.expectEqual(@as(usize, 1), m.steer_queue.items.len); + try std.testing.expectEqualStrings("remember the tests", m.steer_queue.items[0]); +} + +test "lastLines caps ! output to the tail" { + try std.testing.expectEqualStrings("c\nd", lastLines("a\nb\nc\nd", 2)); + try std.testing.expectEqualStrings("a\nb", lastLines("a\nb", 5)); +} diff --git a/TUI/dispatch_tests.zig b/TUI/dispatch_tests.zig index 77de3cf5..49438656 100644 --- a/TUI/dispatch_tests.zig +++ b/TUI/dispatch_tests.zig @@ -6,6 +6,7 @@ const app = @import("app.zig"); const bgop = @import("bgop.zig"); const dispatch = @import("dispatch.zig"); const engine = @import("engine.zig"); +const overlays = @import("overlays.zig"); const Model = app.Model; /// Spin until the background op finishes, then apply it — what run.zig's loop @@ -63,6 +64,62 @@ test "a model turn cannot start while /compact is rewriting the history (#533)" try std.testing.expect(m.bg == null); } +test "rapid same-batch /compact then /model cannot mutate the shared engine context" { + const Fake = struct { + var model_calls: usize = 0; + var release = std.atomic.Value(bool).init(false); + fn compact(_: ?*anyopaque, _: std.mem.Allocator, _: []const engine.Turn, _: *engine.CompactOut) bool { + while (!release.load(.acquire)) std.Thread.yield() catch {}; + return false; + } + fn model(_: ?*anyopaque, gpa: std.mem.Allocator, _: []const u8, name: []const u8) ?engine.Picked { + model_calls += 1; + return .{ .model = gpa.dupe(u8, name) catch return null, .provider = "next-provider" }; + } + }; + Fake.model_calls = 0; + Fake.release.store(false, .release); + var m: Model = undefined; + m.setup(std.testing.allocator); + defer m.deinit(); + engine.g_compact_fn = Fake.compact; + engine.g_model_fn = Fake.model; + engine.g_model_name = "old-model"; + engine.g_model_provider = "old-provider"; + const entries = [_]engine.ModelEntry{.{ .name = "next", .provider = "next-provider", .has_key = true }}; + engine.g_model_entries = &entries; + defer { + Fake.release.store(true, .release); + engine.g_compact_fn = null; + engine.g_model_fn = null; + engine.g_model_name = ""; + engine.g_model_provider = ""; + engine.g_model_entries = &.{}; + } + + var batch: pacing.Batch = .{}; + for ("/compact") |c| try std.testing.expectEqual(pacing.Push.ok, batch.push(.{ .char = c })); + try std.testing.expectEqual(pacing.Push.ok, batch.push(.enter)); + for ("/model next") |c| try std.testing.expectEqual(pacing.Push.ok, batch.push(.{ .char = c })); + try std.testing.expectEqual(pacing.Push.ok, batch.push(.enter)); + for (batch.items()) |item| try std.testing.expectEqual(app.Effect.stay, keys.handleBatchItem(&m, item)); + + try std.testing.expect(m.bg != null); + try std.testing.expect(!m.bg.?.done.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), Fake.model_calls); + try std.testing.expectEqualStrings("old-model", engine.g_model_name); + _ = dispatch.runCommand(&m, "/model"); // browsing is UI-only and remains available + try std.testing.expectEqual(app.Overlay.model, m.overlay); + try std.testing.expectEqual(app.Effect.stay, overlays.activate(&m)); + try std.testing.expectEqual(@as(usize, 0), Fake.model_calls); + + Fake.release.store(true, .release); + try settle(&m); + _ = dispatch.runCommand(&m, "/model next"); + try std.testing.expectEqual(@as(usize, 1), Fake.model_calls); + try std.testing.expectEqualStrings("next", engine.g_model_name); +} + test "/new /compact /rewind are blocked while a job is pending (#521)" { var m: Model = undefined; m.setup(std.testing.allocator); @@ -231,10 +288,7 @@ fn tick(m: *Model, evs: []const key_mod.Key) void { var b: pacing.Batch = .{}; for (evs) |k| std.debug.assert(b.push(k) == .ok); for (b.items()) |item| { - _ = switch (item) { - .key => |k| keys.handle(m, k), - .wheel => |d| keys.wheelScroll(m, d), - }; + _ = keys.handleBatchItem(m, item); } } @@ -283,10 +337,7 @@ test "typing is never starved behind a wheel flood" { // 400 wheel reports collapse into 3 units of scroll work. try std.testing.expectEqual(@as(usize, 6), b.len); for (b.items()) |item| { - _ = switch (item) { - .key => |k| keys.handle(&m, k), - .wheel => |d| keys.wheelScroll(&m, d), - }; + _ = keys.handleBatchItem(&m, item); } try std.testing.expectEqualStrings("hi!", m.input.getValue()); try std.testing.expectEqual(@as(usize, 600), m.scroll); // (200+100-100)*3 diff --git a/TUI/engine.zig b/TUI/engine.zig index 94c5ad00..6ed896d8 100644 --- a/TUI/engine.zig +++ b/TUI/engine.zig @@ -2,6 +2,7 @@ //! The package never imports the harness: session glue supplies callbacks. const std = @import("std"); +const builtin = @import("builtin"); const events_mod = @import("events.zig"); @@ -174,6 +175,8 @@ pub const Job = struct { thread: std.Thread = undefined, threaded: bool = true, done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// Thread creation failed; finish still owns reporting and cleanup. + start_failed: bool = false, result: ?[]const u8 = null, gpa: std.mem.Allocator, history: []Turn, @@ -212,6 +215,8 @@ pub const BgOp = struct { thread: std.Thread = undefined, threaded: bool = true, done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// Thread creation failed; finish still owns reporting and cleanup. + start_failed: bool = false, /// Esc/Ctrl+C asked the engine to stop; the callbacks observe it through /// the same cancel signal a turn uses. cancelled: bool = false, @@ -306,6 +311,30 @@ pub var g_model_provider: []const u8 = ""; pub var g_model_entries: []const ModelEntry = &.{}; pub var g_cwd: []const u8 = "."; +pub const JobSpawnFn = *const fn (*Job) anyerror!std.Thread; +pub const JobJoinObserver = *const fn () void; +var job_spawn_override: ?JobSpawnFn = null; +var job_join_observer: ?JobJoinObserver = null; + +/// Deterministic test seam for thread creation and join accounting. Production +/// always takes the direct std.Thread paths below. +pub fn setJobThreadHooksForTesting(spawn: ?JobSpawnFn, join_observer: ?JobJoinObserver) void { + std.debug.assert(builtin.is_test); + job_spawn_override = spawn; + job_join_observer = join_observer; +} + +pub fn spawnJob(job: *Job) !std.Thread { + if (builtin.is_test) if (job_spawn_override) |spawn| return spawn(job); + return std.Thread.spawn(.{}, jobRun, .{job}); +} + +pub fn joinJob(job: *Job) void { + if (!job.threaded) return; + if (builtin.is_test) if (job_join_observer) |observe| observe(); + job.thread.join(); +} + pub fn jobRun(job: *Job) void { const reply = if (g_turn_fn) |f| f(g_turn_ctx, job.gpa, job.history, job.params, &job.stream, &job.events) else null; job.result = reply; diff --git a/TUI/issue_537_reviewer_tests.zig b/TUI/issue_537_reviewer_tests.zig new file mode 100644 index 00000000..159f2135 --- /dev/null +++ b/TUI/issue_537_reviewer_tests.zig @@ -0,0 +1,588 @@ +//! Reviewer-W regressions for #537's bounded dropped-head recovery. + +const std = @import("std"); + +const app = @import("app.zig"); +const engine = @import("engine.zig"); +const key_mod = @import("key.zig"); +const keys = @import("keys.zig"); +const pacing = @import("pacing.zig"); +const stall = @import("run_stall.zig"); +const Term = @import("sim.zig").Term; +const Effect = app.Effect; + +const Trajectory = enum { idle, live_turn, compact, bash, files }; +const trajectories = [_]Trajectory{ .idle, .live_turn, .compact, .bash, .files }; + +fn isBackground(trajectory: Trajectory) bool { + return switch (trajectory) { + .compact, .bash, .files => true, + .idle, .live_turn => false, + }; +} + +fn installPending(term: *Term) !void { + const job = try std.testing.allocator.create(engine.Job); + errdefer std.testing.allocator.destroy(job); + job.* = .{ + .gpa = std.testing.allocator, + .history = &.{}, + .params = .{}, + .stream = .{}, + .threaded = false, + }; + try term.model.push(.pending, ""); + term.model.pending = job; +} + +fn installBackground(term: *Term, kind: engine.BgOp.Kind) !*engine.BgOp { + const op = try std.testing.allocator.create(engine.BgOp); + errdefer std.testing.allocator.destroy(op); + op.* = .{ .kind = kind, .gpa = std.testing.allocator, .threaded = false }; + try term.model.push(.pending, "background operation"); + term.model.bg = op; + return op; +} + +const Fixture = struct { + term: Term, + bg: ?*engine.BgOp = null, + + fn init(trajectory: Trajectory) !Fixture { + var fixture: Fixture = undefined; + fixture.bg = null; + fixture.term.init(std.testing.allocator, 80, 24); + errdefer fixture.term.deinit(); + try fixture.term.model.push(.user, "existing turn"); + switch (trajectory) { + .idle => {}, + .live_turn => try installPending(&fixture.term), + .compact => fixture.bg = try installBackground(&fixture.term, .compact), + .bash => fixture.bg = try installBackground(&fixture.term, .bash), + .files => fixture.bg = try installBackground(&fixture.term, .files), + } + fixture.term.now_ms = 100; + fixture.term.model.now_ms = 100; + return fixture; + } + + fn deinit(self: *Fixture) void { + if (self.bg) |op| { + self.term.model.bg = null; + std.testing.allocator.destroy(op); + } + if (self.term.model.pending) |job| { + self.term.model.pending = null; + std.testing.allocator.destroy(job); + } + self.term.deinit(); + } +}; + +fn expectAlive(fixture: *Fixture, trajectory: Trajectory) !void { + try std.testing.expect(!fixture.term.model.cancel_requested); + if (trajectory == .live_turn) try std.testing.expect(fixture.term.model.pending != null); + if (isBackground(trajectory)) try std.testing.expect(fixture.term.model.bg != null and !fixture.bg.?.cancelled); +} + +fn abandonedAt401(head: []const u8, recovery: key_mod.SequenceRecovery, close_paste: bool, fresh: []const u8, buf: []u8) usize { + key_mod.abandonSequence(head, recovery); + if (close_paste) key_mod.endPaste(); + // At 401ms the short genuine-Escape carry is gone, but the one-second + // dropped-head recovery interval is still live. + std.debug.assert(stall.escapeCarryExpired(401, 0)); + std.debug.assert(!stall.armExpired(401, 0)); + key_mod.expireOrphanHead(); + @memcpy(buf[0..fresh.len], fresh); + return key_mod.joinOrphanHead(buf, fresh.len); +} + +test "every internal paste-marker split reconstructs a real event at 401ms" { + const cases = [_]struct { marker: []const u8, start: bool }{ + .{ .marker = "\x1b[200~", .start = true }, + .{ .marker = "\x1b[201~", .start = false }, + }; + for (cases) |case| for (1..case.marker.len) |cut| { + errdefer std.debug.print("marker recovery failure: {s} split {d}\n", .{ case.marker, cut }); + key_mod.resetInputState(); + if (!case.start) { + var open_i: usize = 0; + try std.testing.expectEqual(key_mod.Key.paste_start, key_mod.next("\x1b[200~", &open_i).?); + } + const ctx: stall.StallCtx = .{ .in_paste = !case.start }; + const budget: u8 = if (cut == 1) 12 else if (case.start) 20 else 80; + const verdict: stall.StallVerdict = if (cut == 1) .escape_key else .drop; + try std.testing.expectEqual(stall.StallVerdict.wait, stall.stallVerdict(case.marker[0..cut], budget - 1, ctx)); + try std.testing.expectEqual(verdict, stall.stallVerdict(case.marker[0..cut], budget, ctx)); + + var fresh: [32]u8 = undefined; + const tail = case.marker[cut..]; + @memcpy(fresh[0..tail.len], tail); + fresh[tail.len] = 0x11; // same-read Ctrl-Q: ordering is observable + var joined: [64]u8 = undefined; + const recovery: key_mod.SequenceRecovery = if (cut == 1) .escape else .dropped; + const n = abandonedAt401(case.marker[0..cut], recovery, !case.start, fresh[0 .. tail.len + 1], &joined); + var i: usize = 0; + const marker = key_mod.next(joined[0..n], &i).?; + if (case.start) { + try std.testing.expectEqual(key_mod.Key.paste_start, marker); + try std.testing.expect(key_mod.inPaste()); + } else { + try std.testing.expectEqual(key_mod.Key.paste_end, marker); + try std.testing.expect(!key_mod.inPaste()); + } + try std.testing.expectEqual(key_mod.Key{ .ctrl = 'q' }, key_mod.next(joined[0..n], &i).?); + try std.testing.expectEqual(n, i); + }; + key_mod.resetInputState(); +} + +test "every dropped paste-start split latches before controls on every trajectory" { + const marker = "\x1b[200~"; + const payload = "left\x11\x03\nright"; + for (trajectories) |trajectory| for (2..marker.len) |cut| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + errdefer std.debug.print("paste-start trajectory failure: {s} split {d}\n", .{ @tagName(trajectory), cut }); + try fixture.term.model.input.setValue("seed:"); + const history_len = fixture.term.model.history.items.len; + _ = fixture.term.feed(marker[0..cut]); + for (0..19) |_| try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + try std.testing.expectEqual(stall.StallVerdict.drop, fixture.term.stallTimeout()); + fixture.term.now_ms += 401; + var fresh: [64]u8 = undefined; + const tail = marker[cut..]; + @memcpy(fresh[0..tail.len], tail); + @memcpy(fresh[tail.len .. tail.len + payload.len], payload); + try std.testing.expectEqual(Effect.stay, fixture.term.feed(fresh[0 .. tail.len + payload.len])); + try std.testing.expect(fixture.term.model.pasting and key_mod.inPaste()); + try std.testing.expectEqualStrings("seed:left\nright", fixture.term.model.input.getValue()); + try std.testing.expectEqual(history_len, fixture.term.model.history.items.len); + try expectAlive(&fixture, trajectory); + _ = fixture.term.feed("\x1b[201~"); + }; +} + +const FramedKind = enum { sgr, x10, kitty, osc }; + +fn expectFramed(kind: FramedKind, k: key_mod.Key) !void { + switch (kind) { + .sgr => { + try std.testing.expect(k == .mouse); + try std.testing.expectEqual(@as(u8, 65), k.mouse.btn); + try std.testing.expectEqual(@as(u16, 20), k.mouse.x); + try std.testing.expectEqual(@as(u16, 10), k.mouse.y); + }, + .x10 => { + try std.testing.expect(k == .mouse); + try std.testing.expectEqual(@as(u8, 64), k.mouse.btn); + try std.testing.expectEqual(@as(u16, 4), k.mouse.x); + try std.testing.expectEqual(@as(u16, 4), k.mouse.y); + }, + .kitty => try std.testing.expectEqual(key_mod.Key.left, k), + .osc => try std.testing.expect(k == .bg_report), + } +} + +test "dropped SGR X10 kitty and OSC retain exact framing at 401ms" { + const cases = [_]struct { bytes: []const u8, kind: FramedKind }{ + .{ .bytes = "\x1b[<65;20;10M", .kind = .sgr }, + .{ .bytes = "\x1b[M`$$", .kind = .x10 }, + .{ .bytes = "\x1b[57350;1u", .kind = .kitty }, + .{ .bytes = "\x1b]11;rgb:14/14/14\x07", .kind = .osc }, + }; + for (cases) |case| for (2..case.bytes.len) |cut| { + key_mod.resetInputState(); + try std.testing.expectEqual(stall.StallVerdict.drop, stall.stallVerdict(case.bytes[0..cut], 20, .{})); + var fresh: [128]u8 = undefined; + const tail = case.bytes[cut..]; + @memcpy(fresh[0..tail.len], tail); + fresh[tail.len] = 'X'; + var joined: [192]u8 = undefined; + const n = abandonedAt401(case.bytes[0..cut], .dropped, false, fresh[0 .. tail.len + 1], &joined); + var i: usize = 0; + try expectFramed(case.kind, key_mod.next(joined[0..n], &i).?); + try std.testing.expectEqual(key_mod.Key{ .char = 'X' }, key_mod.next(joined[0..n], &i).?); + try std.testing.expectEqual(n, i); + }; + key_mod.resetInputState(); +} + +test "every SGR separator split preserves button and coordinate alignment" { + const report = "\x1b[<65;20;10M"; + for (report, 0..) |c, at| { + if (c != ';') continue; + for ([_]usize{ at, at + 1 }) |cut| { + key_mod.resetInputState(); + var joined: [64]u8 = undefined; + const n = abandonedAt401(report[0..cut], .dropped, false, report[cut..], &joined); + var i: usize = 0; + const k = key_mod.next(joined[0..n], &i).?; + try expectFramed(.sgr, k); + try std.testing.expectEqual(@as(?i32, -1), pacing.wheelNotch(k)); + try std.testing.expectEqual(n, i); + } + } + key_mod.resetInputState(); +} + +test "unaligned mouse tails fail closed instead of fabricating mouse or wheel" { + const tails = [_][]const u8{ + "65;20;10M", // may start at button, x, or y + "20;10M", + "10M", + ";10M", + "65;20;10m", + "<65;20M", // field-zero marker, but a missing coordinate + }; + for (tails) |tail| { + key_mod.resetInputState(); + key_mod.armOrphan(true); + var i: usize = 0; + const k = key_mod.next(tail, &i).?; + try std.testing.expect(k != .mouse); + try std.testing.expectEqual(@as(?i32, null), pacing.wheelNotch(k)); + try std.testing.expectEqual(tail.len, i); + } + // With field zero and all three fields proven, recovery remains exact. + key_mod.resetInputState(); + key_mod.armOrphan(true); + var i: usize = 0; + const framed = key_mod.next("<65;20;10M", &i).?; + try std.testing.expectEqual(@as(?i32, -1), pacing.wheelNotch(framed)); + // Full ESC framing still cannot invent a missing SGR coordinate. + key_mod.resetInputState(); + i = 0; + const malformed = key_mod.next("\x1b[<65;20M", &i).?; + try std.testing.expect(malformed != .mouse); + try std.testing.expectEqual(@as(?i32, null), pacing.wheelNotch(malformed)); + key_mod.resetInputState(); +} + +test "a mismatched dropped head disarms before byte-at-a-time prose on every trajectory" { + for (trajectories) |trajectory| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try fixture.term.model.input.setValue("seed:"); + fixture.term.model.scroll = 7; + fixture.term.model.follow = false; + _ = fixture.term.feed("\x1b[<65;20"); + for (0..19) |_| try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + try std.testing.expectEqual(stall.StallVerdict.drop, fixture.term.stallTimeout()); + fixture.term.now_ms += 401; + for ("[Alice]") |c| { + _ = fixture.term.feed(&[_]u8{c}); + fixture.term.now_ms += 50; + } + try std.testing.expectEqualStrings("seed:[Alice]", fixture.term.model.input.getValue()); + try std.testing.expectEqual(@as(usize, 0), fixture.term.pending); + try std.testing.expectEqual(@as(usize, 7), fixture.term.model.scroll); + try std.testing.expect(!fixture.term.model.follow); + try expectAlive(&fixture, trajectory); + } +} + +fn dispatchProductionBatch(m: *app.Model, bytes: []const u8) !Effect { + var batch: pacing.Batch = .{}; + var i: usize = 0; + while (key_mod.next(bytes, &i)) |k| try std.testing.expectEqual(pacing.Push.ok, batch.push(k)); + // Both reports are consecutive and must reach run.zig as one folded item. + try std.testing.expectEqual(@as(usize, 2), batch.folded); + var wheel_items: usize = 0; + var effect: Effect = .stay; + for (batch.items()) |item| { + if (item == .wheel) { + wheel_items += 1; + try std.testing.expectEqual(@as(i32, 2), item.wheel); + } + effect = keys.handleBatchItem(m, item); + if (effect != .stay) break; + } + try std.testing.expectEqual(@as(usize, 1), wheel_items); + return effect; +} + +test "production folded SGR and X10 wheels are paste-aware on every trajectory" { + const reads = [_][]const u8{ + "\x1b[200~left\x1b[<64;4;4M\x1b[<64;4;4Mright\x1b[201~", + "\x1b[200~left\x1b[M`$$\x1b[M`$$right\x1b[201~", + }; + for (trajectories) |trajectory| for (reads) |bytes| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + fixture.term.model.scroll = 7; + fixture.term.model.follow = false; + fixture.term.model.sel.active = true; + fixture.term.model.sel.pressed = true; + try std.testing.expectEqual(Effect.stay, try dispatchProductionBatch(&fixture.term.model, bytes)); + try std.testing.expectEqualStrings("leftright", fixture.term.model.input.getValue()); + try std.testing.expectEqual(@as(usize, 7), fixture.term.model.scroll); + try std.testing.expect(!fixture.term.model.follow); + try std.testing.expect(fixture.term.model.sel.active and fixture.term.model.sel.pressed); + try std.testing.expect(!fixture.term.model.pasting and !key_mod.inPaste()); + try expectAlive(&fixture, trajectory); + }; +} + +test "every paste marker cut is safe on every runtime trajectory" { + const start = "\x1b[200~"; + const end = "\x1b[201~"; + for (trajectories) |trajectory| for (1..start.len) |cut| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + const history_len = fixture.term.model.history.items.len; + _ = fixture.term.feed(start[0..cut]); + try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + const payload = "left\x11\x03\nright"; + var fresh: [32]u8 = undefined; + const tail = start[cut..]; + @memcpy(fresh[0..tail.len], tail); + @memcpy(fresh[tail.len .. tail.len + payload.len], payload); + _ = fixture.term.feed(fresh[0 .. tail.len + payload.len]); + try std.testing.expectEqualStrings("left\nright", fixture.term.model.input.getValue()); + try std.testing.expect(fixture.term.model.pasting and key_mod.inPaste()); + _ = fixture.term.feed(end[0..cut]); + try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + _ = fixture.term.feed(end[cut..]); + try std.testing.expect(!fixture.term.model.pasting and !key_mod.inPaste()); + try std.testing.expectEqual(history_len, fixture.term.model.history.items.len); + try expectAlive(&fixture, trajectory); + }; +} + +test "dropped paste heads accumulate secondary late reads on every trajectory" { + for (trajectories) |trajectory| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try fixture.term.model.input.setValue("seed:"); + const history_len = fixture.term.model.history.items.len; + + _ = fixture.term.feed("\x1b[20"); + for (0..19) |_| try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + try std.testing.expectEqual(stall.StallVerdict.drop, fixture.term.stallTimeout()); + fixture.term.now_ms += 401; + try std.testing.expectEqual(Effect.stay, fixture.term.feed("0")); + try std.testing.expectEqualStrings("seed:", fixture.term.model.input.getValue()); + try std.testing.expectEqual(@as(usize, 0), fixture.term.pending); + + try std.testing.expectEqual(Effect.stay, fixture.term.feed("~left\x11\x03\nright")); + try std.testing.expect(fixture.term.model.pasting and key_mod.inPaste()); + try std.testing.expectEqualStrings("seed:left\nright", fixture.term.model.input.getValue()); + try std.testing.expectEqual(history_len, fixture.term.model.history.items.len); + try expectAlive(&fixture, trajectory); + _ = fixture.term.feed("\x1b[201~"); + } +} + +test "every paste marker cut permits a second bounded partial tail" { + const cases = [_]struct { marker: []const u8, start: bool }{ + .{ .marker = "\x1b[200~", .start = true }, + .{ .marker = "\x1b[201~", .start = false }, + }; + for (cases) |case| for (2..case.marker.len - 1) |cut| { + const tail = case.marker[cut..]; + for (1..tail.len) |second_cut| { + key_mod.resetInputState(); + if (!case.start) { + var open_i: usize = 0; + try std.testing.expectEqual(key_mod.Key.paste_start, key_mod.next("\x1b[200~", &open_i).?); + } + key_mod.abandonSequence(case.marker[0..cut], .dropped); + var buf: [64]u8 = undefined; + @memcpy(buf[0..second_cut], tail[0..second_cut]); + try std.testing.expectEqual(@as(usize, 0), key_mod.joinOrphanHead(&buf, second_cut)); + const rest = tail[second_cut..]; + @memcpy(buf[0..rest.len], rest); + buf[rest.len] = 0x11; + const n = key_mod.joinOrphanHead(&buf, rest.len + 1); + var i: usize = 0; + const expected: key_mod.Key = if (case.start) .paste_start else .paste_end; + try std.testing.expectEqual(expected, key_mod.next(buf[0..n], &i).?); + try std.testing.expectEqual(key_mod.Key{ .ctrl = 'q' }, key_mod.next(buf[0..n], &i).?); + try std.testing.expectEqual(n, i); + } + }; + key_mod.resetInputState(); +} + +test "full input buffer recovers paste start before every payload byte" { + key_mod.resetInputState(); + defer key_mod.resetInputState(); + key_mod.abandonSequence("\x1b[20", .dropped); + var buf: [16 * 1024]u8 = undefined; + @memset(&buf, 'x'); + @memcpy(buf[0..2], "0~"); + buf[2] = 0x11; + buf[3] = 0x03; + buf[4] = '\n'; + const n = key_mod.joinOrphanHead(&buf, buf.len); + try std.testing.expectEqual(buf.len - 2, n); + var i: usize = 0; + try std.testing.expectEqual(key_mod.Key.paste_start, key_mod.next(buf[0..n], &i).?); + try std.testing.expectEqual(key_mod.Key{ .ctrl = 'q' }, key_mod.next(buf[0..n], &i).?); + try std.testing.expectEqual(key_mod.Key{ .ctrl = 'c' }, key_mod.next(buf[0..n], &i).?); + try std.testing.expectEqual(key_mod.Key{ .char = '\n' }, key_mod.next(buf[0..n], &i).?); + var xs: usize = 0; + while (key_mod.next(buf[0..n], &i)) |k| { + try std.testing.expectEqual(key_mod.Key{ .char = 'x' }, k); + xs += 1; + } + try std.testing.expectEqual(buf.len - 5, xs); + try std.testing.expectEqual(n, i); +} + +test "invalid and oversized late tails fail closed and disarm" { + key_mod.resetInputState(); + defer key_mod.resetInputState(); + key_mod.abandonSequence("\x1b[20", .dropped); + var bad = [_]u8{ 'x', 0x11 }; + const bad_n = key_mod.joinOrphanHead(&bad, bad.len); + var i: usize = 0; + try std.testing.expectEqual(key_mod.Key{ .char = 'x' }, key_mod.next(bad[0..bad_n], &i).?); + try std.testing.expectEqual(key_mod.Key{ .ctrl = 'q' }, key_mod.next(bad[0..bad_n], &i).?); + try std.testing.expect(!key_mod.inPaste()); + + key_mod.abandonSequence("\x1b[", .dropped); + var oversized: [65]u8 = @splat('2'); + try std.testing.expectEqual(oversized.len, key_mod.joinOrphanHead(&oversized, oversized.len)); + i = 0; + try std.testing.expectEqual(key_mod.Key{ .char = '2' }, key_mod.next(&oversized, &i).?); + try std.testing.expect(!key_mod.inPaste()); +} + +test "a possible paste-start ESC cannot cancel live work before one second" { + for (trajectories[1..]) |trajectory| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + const history_len = fixture.term.model.history.items.len; + _ = fixture.term.feed("\x1b"); + for (1..stall.live_escape_stalls) |_| { + try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + try expectAlive(&fixture, trajectory); + } + try std.testing.expectEqual(Effect.stay, fixture.term.feed("[200~left\x11\x03\nright")); + try std.testing.expectEqualStrings("left\nright", fixture.term.model.input.getValue()); + try std.testing.expectEqual(history_len, fixture.term.model.history.items.len); + try expectAlive(&fixture, trajectory); + _ = fixture.term.feed("\x1b[201~"); + } +} + +test "live lone Escape is bounded while Ctrl-C and CSI-u Escape stay immediate" { + for (trajectories[1..]) |trajectory| { + var bounded = try Fixture.init(trajectory); + defer bounded.deinit(); + _ = bounded.term.feed("\x1b"); + for (1..stall.live_escape_stalls) |_| try std.testing.expectEqual(stall.StallVerdict.wait, bounded.term.stallTimeout()); + try std.testing.expectEqual(stall.StallVerdict.escape_key, bounded.term.stallTimeout()); + if (trajectory == .live_turn) try std.testing.expect(bounded.term.model.cancel_requested); + if (isBackground(trajectory)) try std.testing.expect(bounded.bg.?.cancelled); + + var ctrl_c = try Fixture.init(trajectory); + defer ctrl_c.deinit(); + _ = ctrl_c.term.feed("\x03"); + if (trajectory == .live_turn) try std.testing.expect(ctrl_c.term.model.cancel_requested); + if (isBackground(trajectory)) try std.testing.expect(ctrl_c.bg.?.cancelled); + + var kitty = try Fixture.init(trajectory); + defer kitty.deinit(); + _ = kitty.term.feed("\x1b[27u"); + if (trajectory == .live_turn) try std.testing.expect(kitty.term.model.cancel_requested); + if (isBackground(trajectory)) try std.testing.expect(kitty.bg.?.cancelled); + } +} + +const FullEventKind = enum { sgr, x10, kitty, osc }; +const full_event_cases = [_]struct { head: []const u8, tail: []const u8, kind: FullEventKind }{ + .{ .head = "\x1b[<65;20", .tail = ";10M", .kind = .sgr }, + .{ .head = "\x1b[M", .tail = "`$$", .kind = .x10 }, + .{ .head = "\x1b[57350;", .tail = "1u", .kind = .kitty }, + .{ .head = "\x1b]11;rgb:f6", .tail = "/f6/f6\x07", .kind = .osc }, +}; + +test "full reads recover framed events before remaining bytes on every trajectory" { + for (trajectories) |trajectory| for (full_event_cases) |case| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + errdefer std.debug.print("full event failure: {s} / {s}\n", .{ @tagName(trajectory), @tagName(case.kind) }); + try fixture.term.model.input.setValue(if (case.kind == .kitty) "ab" else "seed:"); + fixture.term.model.scroll = 7; + fixture.term.model.follow = false; + fixture.term.model.theme_explicit = false; + fixture.term.model.theme_id = .night; + const history_len = fixture.term.model.history.items.len; + _ = fixture.term.feed(case.head); + try std.testing.expectEqual(case.head.len, fixture.term.pending); + fixture.term.stallDropPending(); + + var full: [16 * 1024]u8 = @splat(0x07); + @memcpy(full[0..case.tail.len], case.tail); + full[full.len - 1] = 'X'; + try std.testing.expectEqual(Effect.stay, fixture.term.feed(&full)); + try std.testing.expectEqual(@as(usize, 0), fixture.term.pending); + switch (case.kind) { + .sgr => { + try std.testing.expectEqualStrings("seed:X", fixture.term.model.input.getValue()); + try std.testing.expectEqual(@as(usize, 4), fixture.term.model.scroll); + }, + .x10 => { + try std.testing.expectEqualStrings("seed:X", fixture.term.model.input.getValue()); + try std.testing.expectEqual(@as(usize, 10), fixture.term.model.scroll); + }, + .kitty => try std.testing.expectEqualStrings("aXb", fixture.term.model.input.getValue()), + .osc => { + try std.testing.expectEqualStrings("seed:X", fixture.term.model.input.getValue()); + try std.testing.expectEqual(.day, fixture.term.model.theme_id); + }, + } + try std.testing.expectEqual(history_len, fixture.term.model.history.items.len); + try expectAlive(&fixture, trajectory); + }; +} + +test "one feed carries a 16KiB paste payload through its later terminator" { + const start = "\x1b[200~"; + const end = "\x1b[201~"; + const payload: [16 * 1024]u8 = @splat('p'); + var wire: [payload.len + start.len + end.len]u8 = undefined; + @memcpy(wire[0..start.len], start); + @memcpy(wire[start.len .. start.len + payload.len], &payload); + @memcpy(wire[start.len + payload.len ..], end); + for (trajectories) |trajectory| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + const history_len = fixture.term.model.history.items.len; + try std.testing.expectEqual(Effect.stay, fixture.term.feed(&wire)); + try std.testing.expectEqualStrings(&payload, fixture.term.model.input.getValue()); + try std.testing.expect(!fixture.term.model.pasting and !key_mod.inPaste()); + try std.testing.expectEqual(@as(usize, 0), fixture.term.pending); + try std.testing.expectEqual(history_len, fixture.term.model.history.items.len); + try expectAlive(&fixture, trajectory); + } +} + +test "feed stops at Ctrl-Q across a 16KiB+1 paste-start/paste-end wire" { + const start = "\x1b[200~"; + const end = "\x1b[201~"; + const total = 16 * 1024 + 1; + const payload_len = total - start.len - end.len - 2; + var wire: [total]u8 = undefined; + var at: usize = 0; + @memcpy(wire[at .. at + start.len], start); + at += start.len; + @memset(wire[at .. at + payload_len], 'p'); + at += payload_len; + @memcpy(wire[at .. at + end.len], end); + at += end.len; + wire[at] = 0x11; // Ctrl-Q: the last byte of the first 16KiB chunk + wire[at + 1] = 'x'; // supplied after the effect; no tty queue exists here + + for (trajectories) |trajectory| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try std.testing.expectEqual(Effect.quit, fixture.term.feed(&wire)); + try std.testing.expectEqual(payload_len, fixture.term.model.input.getValue().len); + try std.testing.expectEqualStrings(wire[start.len .. start.len + payload_len], fixture.term.model.input.getValue()); + try std.testing.expect(!fixture.term.model.pasting and !key_mod.inPaste()); + } +} diff --git a/TUI/issue_537_tests.zig b/TUI/issue_537_tests.zig new file mode 100644 index 00000000..4a644c42 --- /dev/null +++ b/TUI/issue_537_tests.zig @@ -0,0 +1,598 @@ +//! Adversarial bracketed-paste and nested-sequence coverage for #537. + +const std = @import("std"); + +const app = @import("app.zig"); +const engine = @import("engine.zig"); +const key_mod = @import("key.zig"); +const stall = @import("run_stall.zig"); +const theme_mod = @import("theme.zig"); +const Term = @import("sim.zig").Term; +const Effect = app.Effect; + +const PasteTrajectory = enum { idle, live_turn, background_compact, background_bash, background_files }; + +const all_trajectories = [_]PasteTrajectory{ .idle, .live_turn, .background_compact, .background_bash, .background_files }; + +fn isBackground(trajectory: PasteTrajectory) bool { + return switch (trajectory) { + .background_compact, .background_bash, .background_files => true, + .idle, .live_turn => false, + }; +} + +fn installPending(term: *Term) !void { + const job = try std.testing.allocator.create(engine.Job); + errdefer std.testing.allocator.destroy(job); + job.* = .{ + .gpa = std.testing.allocator, + .history = &.{}, + .params = .{}, + .stream = .{}, + .threaded = false, + }; + try term.model.push(.pending, ""); + term.model.pending = job; +} + +fn installBackground(term: *Term, kind: engine.BgOp.Kind) !*engine.BgOp { + const op = try std.testing.allocator.create(engine.BgOp); + errdefer std.testing.allocator.destroy(op); + op.* = .{ .kind = kind, .gpa = std.testing.allocator, .threaded = false }; + try term.model.push(.pending, "background operation"); + term.model.bg = op; + return op; +} + +const Fixture = struct { + term: Term, + bg: ?*engine.BgOp = null, + + fn init(trajectory: PasteTrajectory) !Fixture { + var fixture: Fixture = undefined; + fixture.bg = null; + fixture.term.init(std.testing.allocator, 80, 24); + errdefer fixture.term.deinit(); + try fixture.term.model.push(.user, "existing turn"); + switch (trajectory) { + .idle => {}, + .live_turn => try installPending(&fixture.term), + .background_compact => fixture.bg = try installBackground(&fixture.term, .compact), + .background_bash => fixture.bg = try installBackground(&fixture.term, .bash), + .background_files => fixture.bg = try installBackground(&fixture.term, .files), + } + fixture.term.model.scroll = 7; + fixture.term.model.follow = false; + fixture.term.model.selected = 3; + fixture.term.now_ms = 100; + fixture.term.model.now_ms = 100; + return fixture; + } + + fn deinit(self: *Fixture) void { + if (self.bg) |op| { + self.term.model.bg = null; + std.testing.allocator.destroy(op); + } + if (self.term.model.pending) |job| { + self.term.model.pending = null; + std.testing.allocator.destroy(job); + } + self.term.deinit(); + } +}; + +fn trajectoryName(trajectory: PasteTrajectory) []const u8 { + return switch (trajectory) { + .idle => "idle", + .live_turn => "live-turn", + .background_compact => "background-compact", + .background_bash => "background-bash", + .background_files => "background-files", + }; +} + +/// Deliver a genuine Escape through the same 25ms stall policy as run.zig, +/// then advance beyond its 400ms carried-head window but not its 1s exact-tail +/// arm. A live operation uses the full bounded one-second ambiguity window. +fn armCarryExpiredEscape(term: *Term) !void { + _ = term.feed("\x1b"); + var verdict: stall.StallVerdict = .wait; + for (0..stall.live_escape_stalls) |_| { + verdict = term.stallTimeout(); + if (verdict != .wait) break; + } + try std.testing.expectEqual(stall.StallVerdict.escape_key, verdict); + term.now_ms += 401; +} + +fn expectOperationAlive(fixture: *Fixture, trajectory: PasteTrajectory) !void { + try std.testing.expect(!fixture.term.model.cancel_requested); + if (trajectory == .live_turn) try std.testing.expect(fixture.term.model.pending != null); + if (isBackground(trajectory)) try std.testing.expect(fixture.term.model.bg != null and !fixture.bg.?.cancelled); +} + +const C0PasteCase = struct { + name: []const u8, + byte: u8, + expected: []const u8, +}; + +// These are the actual single-byte tty encodings, not synthetic Key values. +// In particular, Ctrl-J is LF and is intentionally retained as paste newline. +const c0_paste_cases = [_]C0PasteCase{ + .{ .name = "Ctrl-Q", .byte = 0x11, .expected = "leftright" }, + .{ .name = "Ctrl-C", .byte = 0x03, .expected = "leftright" }, + .{ .name = "Ctrl-Z", .byte = 0x1a, .expected = "leftright" }, + .{ .name = "Ctrl-N", .byte = 0x0e, .expected = "leftright" }, + .{ .name = "Ctrl-J", .byte = 0x0a, .expected = "left\nright" }, + .{ .name = "Ctrl-K", .byte = 0x0b, .expected = "leftright" }, + .{ .name = "Tab", .byte = 0x09, .expected = "leftright" }, + .{ .name = "Backspace (DEL)", .byte = 0x7f, .expected = "leftright" }, + .{ .name = "Backspace (BS)", .byte = 0x08, .expected = "leftright" }, +}; + +fn pasteInvariantFailure(failures: *usize, trajectory: PasteTrajectory, case: C0PasteCase, what: []const u8) void { + failures.* += 1; + std.debug.print("C0 paste failure [{s} / {s}]: {s}\n", .{ trajectoryName(trajectory), case.name, what }); +} + +// A bracketed paste is one draft on every TUI path. The raw byte is fed in its +// own read to exercise the runtime parser boundary while idle, during a turn, +// and during a background engine operation. +test "C0 bytes inside bracketed paste stay one draft on every TUI trajectory (#537)" { + var failures: usize = 0; + + for (all_trajectories) |trajectory| { + for (c0_paste_cases) |case| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + const history_len = fixture.term.model.history.items.len; + + _ = fixture.term.feed("\x1b[200~left"); + const effect = fixture.term.feed(&[_]u8{case.byte}); + if (!fixture.term.model.pasting) pasteInvariantFailure(&failures, trajectory, case, "lost the paste latch before its terminator"); + _ = fixture.term.feed("right\x1b[201~"); + + const m = &fixture.term.model; + if (effect != .stay) pasteInvariantFailure(&failures, trajectory, case, "returned a non-stay effect"); + if (m.quit_requested) pasteInvariantFailure(&failures, trajectory, case, "requested quit"); + if (!std.mem.eql(u8, case.expected, m.input.getValue())) pasteInvariantFailure(&failures, trajectory, case, "changed the draft"); + if (m.steer_queue.items.len != 0) pasteInvariantFailure(&failures, trajectory, case, "queued a steer"); + if (m.history.items.len != history_len) pasteInvariantFailure(&failures, trajectory, case, "created a history entry"); + if (m.focus != .prompt) pasteInvariantFailure(&failures, trajectory, case, "moved focus"); + if (m.scroll != 7 or m.follow) pasteInvariantFailure(&failures, trajectory, case, "navigated the viewport"); + if (m.selected != 3) pasteInvariantFailure(&failures, trajectory, case, "changed the selected turn"); + if (m.new_arm_until_ms != 0) pasteInvariantFailure(&failures, trajectory, case, "armed Ctrl-N"); + if (m.pasting) pasteInvariantFailure(&failures, trajectory, case, "did not accept the paste terminator"); + if (m.cancel_requested) pasteInvariantFailure(&failures, trajectory, case, "cancelled an in-flight turn"); + if (trajectory == .live_turn and m.pending == null) pasteInvariantFailure(&failures, trajectory, case, "dropped the live turn"); + if (isBackground(trajectory) and (m.bg == null or fixture.bg.?.cancelled)) pasteInvariantFailure(&failures, trajectory, case, "cancelled the background operation"); + } + } + + try std.testing.expectEqual(@as(usize, 0), failures); +} + +const ParsedPasteCase = struct { + name: []const u8, + bytes: []const u8, + middle: []const u8 = "left", + final: []const u8 = "leftright", + held: u32 = 0, +}; + +const parsed_paste_cases = [_]ParsedPasteCase{ + .{ .name = "SGR mouse", .bytes = "\x1b[<0;1;1M" }, + .{ .name = "X10 mouse", .bytes = "\x1b[M !!" }, + .{ .name = "background OSC", .bytes = "\x1b]11;rgb:f6/f6/f6\x07" }, + .{ .name = "kitty Super down", .bytes = "\x1b[57444;1:1u" }, + .{ .name = "kitty Super release", .bytes = "\x1b[57444;1:3u", .held = 8 }, + .{ .name = "kitty modified Backspace", .bytes = "\x1b[127;9u" }, + .{ .name = "kitty text", .bytes = "\x1b[97u", .middle = "lefta", .final = "leftaright", .held = 8 }, + .{ .name = "CSI arrow", .bytes = "\x1b[A" }, + .{ .name = "CSI Delete", .bytes = "\x1b[3~" }, + .{ .name = "SS3 arrow", .bytes = "\x1bOA" }, + .{ .name = "truncated CSI before SGR", .bytes = "\x1b[99;\x1b[<35;2;2M" }, + .{ .name = "truncated SS3 before SS3", .bytes = "\x1bO\x1bOA" }, +}; + +fn expectParsedPasteSafe(fixture: *Fixture, trajectory: PasteTrajectory, case: ParsedPasteCase, cut: usize) !void { + const m = &fixture.term.model; + errdefer std.debug.print("parsed paste failure [{s} / {s} / split {d}]\n", .{ trajectoryName(trajectory), case.name, cut }); + + try std.testing.expectEqual(Effect.stay, fixture.term.feed(case.bytes[0..cut])); + try std.testing.expectEqual(Effect.stay, fixture.term.feed(case.bytes[cut..])); + try std.testing.expect(m.pasting); + try std.testing.expect(key_mod.inPaste()); + try std.testing.expectEqualStrings(case.middle, m.input.getValue()); + try std.testing.expectEqual(app.Overlay.help, m.overlay); + try std.testing.expect(m.sel.active and m.sel.pressed); + try std.testing.expectEqual(@as(theme_mod.Id, .night), m.theme_id); + try std.testing.expectEqual(case.held, key_mod.held); + try std.testing.expect(!m.cancel_requested); + try std.testing.expect(!m.quit_requested); + try std.testing.expectEqual(@as(usize, 0), m.steer_queue.items.len); + if (trajectory == .live_turn) try std.testing.expect(m.pending != null); + if (isBackground(trajectory)) try std.testing.expect(m.bg != null and !fixture.bg.?.cancelled); +} + +// Complete controls and every possible read split have identical inert +// semantics inside a paste. Kitty text remains text, while mouse/background, +// modifier, CSI, and SS3 events cannot reach TUI actions or parser latches. +test "parsed controls inside paste are inert at every split on every TUI trajectory (#537)" { + for (all_trajectories) |trajectory| { + for (parsed_paste_cases) |case| { + var cut: usize = 0; + while (cut <= case.bytes.len) : (cut += 1) { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + _ = fixture.term.feed("\x1b[200~left"); + fixture.term.model.overlay = .help; + fixture.term.model.sel.active = true; + fixture.term.model.sel.pressed = true; + fixture.term.model.theme_id = .night; + key_mod.held = case.held; + + try expectParsedPasteSafe(&fixture, trajectory, case, cut); + _ = fixture.term.feed("right\x1b[201~"); + try std.testing.expectEqualStrings(case.final, fixture.term.model.input.getValue()); + try std.testing.expect(!fixture.term.model.pasting); + try std.testing.expect(!key_mod.inPaste()); + try std.testing.expectEqual(@as(u32, 0), key_mod.held); + } + } + } +} + +test "Escape remains the intentional bracketed paste hatch (#536/#548)" { + var term: Term = undefined; + term.init(std.testing.allocator, 80, 24); + defer term.deinit(); + key_mod.held = 8; + _ = term.feed("\x1b[200~draft"); + try std.testing.expectEqual(@as(u32, 0), key_mod.held); + key_mod.held = 8; + _ = term.press(.escape); + try std.testing.expect(!term.model.pasting); + try std.testing.expect(!key_mod.inPaste()); + try std.testing.expectEqual(@as(u32, 0), key_mod.held); + try std.testing.expectEqualStrings("draft", term.model.input.getValue()); + // Once Escape intentionally closes the paste, a subsequent Ctrl-Q is an + // ordinary key again; the hatch is not a permanent suppression switch. + try std.testing.expectEqual(Effect.quit, term.feed("\x11")); +} + +const ParserLoop = struct { + inbuf: [256]u8 = undefined, + pending: usize = 0, + typed: std.array_list.Managed(u8), + mice: usize = 0, + arrows: usize = 0, + + fn init() ParserLoop { + key_mod.resetInputState(); + return .{ .typed = std.array_list.Managed(u8).init(std.testing.allocator) }; + } + + fn deinit(self: *ParserLoop) void { + self.typed.deinit(); + key_mod.resetInputState(); + } + + fn read(self: *ParserLoop, bytes: []const u8) !void { + @memcpy(self.inbuf[self.pending .. self.pending + bytes.len], bytes); + var i: usize = 0; + const n = self.pending + bytes.len; + while (key_mod.next(self.inbuf[0..n], &i)) |k| switch (k) { + .char => |c| try self.typed.append(c), + .mouse => self.mice += 1, + .left, .right, .up, .down => self.arrows += 1, + else => {}, + }; + self.pending = if (i < n) blk: { + const rest = n - i; + std.mem.copyForwards(u8, self.inbuf[0..rest], self.inbuf[i..n]); + break :blk rest; + } else 0; + } +}; + +const EmbeddedEscapeCase = struct { + name: []const u8, + bytes: []const u8, + mice: usize = 0, + arrows: usize = 0, +}; + +const embedded_escape_cases = [_]EmbeddedEscapeCase{ + .{ .name = "CSI then SGR", .bytes = "left\x1b[99;\x1b[<35;2;2Mright", .mice = 1 }, + .{ .name = "X10 then CSI", .bytes = "left\x1b[M \x1b[Aright", .arrows = 1 }, + .{ .name = "CSI then CSI", .bytes = "left\x1b[99;\x1b[Aright", .arrows = 1 }, + .{ .name = "SS3 then SS3", .bytes = "left\x1bO\x1bOAright", .arrows = 1 }, + .{ .name = "SS3 then CSI", .bytes = "left\x1bO\x1b[Aright", .arrows = 1 }, +}; + +// An ESC embedded in a truncated CSI/SS3 is a new event head. Only the old +// prefix is discarded; the new event is reparsed and its body never becomes +// composer text, regardless of the tty read boundary. +test "50-250ms ESC splits preserve state on every TUI trajectory (#537)" { + const protected = [_]struct { tail: []const u8, ticks: usize }{ + .{ .tail = "[A", .ticks = 2 }, // exact CSI at about 50ms + .{ .tail = "OA", .ticks = 10 }, // exact SS3 at about 250ms + .{ .tail = "[57350;1u", .ticks = 10 }, // parameterized kitty + }; + // Pre-arm two-Escape clear: a phantom Escape would visibly erase the draft. + for (all_trajectories) |trajectory| for (protected) |case| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try fixture.term.model.input.setValue("draft survives"); + _ = fixture.term.press(.escape); + _ = fixture.term.feed("\x1b"); + for (0..case.ticks) |_| try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + _ = fixture.term.feed(case.tail); + try std.testing.expectEqualStrings("draft survives", fixture.term.model.input.getValue()); + try expectOperationAlive(&fixture, trajectory); + }; + + // The ambiguity remains bounded: an actual idle Escape lands on poll 12. + var idle = try Fixture.init(.idle); + defer idle.deinit(); + try idle.term.model.input.setValue("clear me"); + _ = idle.term.press(.escape); + _ = idle.term.feed("\x1b"); + for (0..11) |_| try std.testing.expectEqual(stall.StallVerdict.wait, idle.term.stallTimeout()); + try std.testing.expectEqual(stall.StallVerdict.escape_key, idle.term.stallTimeout()); + try std.testing.expectEqualStrings("", idle.term.model.input.getValue()); +} + +const x10_report = "\x1b[M !!"; + +// X10's final-looking `M` is only the introducer for three payload bytes. Every +// read boundary and every reported 50–250ms delay must retain that whole body. +test "X10 mouse survives every split on every TUI trajectory and in paste (#537)" { + const stalls = [_]usize{ 2, 4, 6, 8, 10 }; + for (all_trajectories) |trajectory| { + for ([_]bool{ false, true }) |pasting| { + for (stalls) |ticks| { + for (0..x10_report.len + 1) |cut| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + errdefer std.debug.print("X10 failure [{s} / paste={} / split {d} / stalls {d}]\n", .{ trajectoryName(trajectory), pasting, cut, ticks }); + if (pasting) { + _ = fixture.term.feed("\x1b[200~left"); + } else if (trajectory == .idle) { + try fixture.term.model.input.setValue("draft survives"); + _ = fixture.term.press(.escape); // a phantom Escape would clear it + } + try std.testing.expectEqual(Effect.stay, fixture.term.feed(x10_report[0..cut])); + for (0..ticks) |_| try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + try std.testing.expectEqual(Effect.stay, fixture.term.feed(x10_report[cut..])); + try std.testing.expectEqual(@as(usize, 0), fixture.term.pending); + if (pasting) { + try std.testing.expect(key_mod.inPaste() and fixture.term.model.pasting); + try std.testing.expectEqualStrings("left", fixture.term.model.input.getValue()); + _ = fixture.term.feed("right\x1b[201~"); + try std.testing.expectEqualStrings("leftright", fixture.term.model.input.getValue()); + } else if (trajectory == .idle) { + try std.testing.expectEqualStrings("draft survives", fixture.term.model.input.getValue()); + } else try std.testing.expectEqualStrings("", fixture.term.model.input.getValue()); + try expectOperationAlive(&fixture, trajectory); + } + } + } + } +} + +test "carry-expired mouse bodies recover at every split on every trajectory (#537)" { + const bodies = [_][]const u8{ x10_report[1..], "[<35;80;24M" }; + for (all_trajectories) |trajectory| for (bodies) |body| for (0..body.len + 1) |cut| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try fixture.term.model.input.setValue("seed"); + try armCarryExpiredEscape(&fixture.term); + _ = fixture.term.feed(body[0..cut]); + _ = fixture.term.feed(body[cut..]); + errdefer std.debug.print("late mouse failure [{s} / split {d}]\n", .{ trajectoryName(trajectory), cut }); + try std.testing.expectEqualStrings("seed", fixture.term.model.input.getValue()); + try std.testing.expectEqual(@as(usize, 0), fixture.term.pending); + try expectOperationAlive(&fixture, trajectory); + }; +} + +test "an ESC inside X10 payload reparses a paste terminator at every split (#537)" { + const broken = "\x1b[M \x1b[201~"; + for (all_trajectories) |trajectory| for (0..broken.len + 1) |cut| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + _ = fixture.term.feed("\x1b[200~left"); + try std.testing.expectEqual(Effect.stay, fixture.term.feed(broken[0..cut])); + for (0..10) |_| try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + try std.testing.expectEqual(Effect.stay, fixture.term.feed(broken[cut..])); + errdefer std.debug.print("X10 paste terminator failure [{s} / split {d}]\n", .{ trajectoryName(trajectory), cut }); + try std.testing.expect(!key_mod.inPaste() and !fixture.term.model.pasting); + try std.testing.expectEqualStrings("left", fixture.term.model.input.getValue()); + try std.testing.expectEqual(@as(usize, 0), fixture.term.pending); + try expectOperationAlive(&fixture, trajectory); + }; +} + +test "live state is latched at the ESC head across fast completion (#537)" { + for (all_trajectories[1..]) |trajectory| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try fixture.term.model.input.setValue("draft survives"); + _ = fixture.term.press(.escape); + _ = fixture.term.feed("\x1b"); + + // The worker finishes before the first quiet tick. Restore the owned + // fixture pointers only for teardown; policy must use the arrival-time + // latch rather than these now-idle model fields. + const pending = fixture.term.model.pending; + const bg = fixture.term.model.bg; + fixture.term.model.pending = null; + fixture.term.model.bg = null; + defer { + fixture.term.model.pending = pending; + fixture.term.model.bg = bg; + } + for (0..12) |_| try std.testing.expectEqual(stall.StallVerdict.wait, fixture.term.stallTimeout()); + _ = fixture.term.feed("[A"); + try std.testing.expectEqualStrings("draft survives", fixture.term.model.input.getValue()); + if (fixture.bg) |op| try std.testing.expect(!op.cancelled); + } +} + +test "carry-expired paste start latches parser and model before same-read controls (#537)" { + for (all_trajectories) |trajectory| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try fixture.term.model.input.setValue("seed:"); + const history_len = fixture.term.model.history.items.len; + try armCarryExpiredEscape(&fixture.term); + + const effect = fixture.term.feed("[200~left\x11\x03\nright"); + errdefer std.debug.print("late paste failure [{s}]\n", .{trajectoryName(trajectory)}); + try std.testing.expectEqual(Effect.stay, effect); + try std.testing.expect(key_mod.inPaste()); + try std.testing.expect(fixture.term.model.pasting); + try std.testing.expectEqualStrings("seed:left\nright", fixture.term.model.input.getValue()); + try std.testing.expect(!fixture.term.model.quit_requested); + try std.testing.expectEqual(history_len, fixture.term.model.history.items.len); + try expectOperationAlive(&fixture, trajectory); + + _ = fixture.term.feed("\x1b[201~"); + try std.testing.expect(!key_mod.inPaste()); + try std.testing.expect(!fixture.term.model.pasting); + } +} + +test "carry-expired exact tails stay text while kitty and OSC remain events (#537)" { + const exact = [_]struct { tail: []const u8, expected: []const u8 }{ + .{ .tail = "[D", .expected = "ab[D" }, + .{ .tail = "OD", .expected = "abOD" }, + }; + for (all_trajectories) |trajectory| { + for (exact) |case| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try fixture.term.model.input.setValue("ab"); + try armCarryExpiredEscape(&fixture.term); + _ = fixture.term.feed(case.tail); + try std.testing.expectEqualStrings(case.expected, fixture.term.model.input.getValue()); + try expectOperationAlive(&fixture, trajectory); + } + var kitty = try Fixture.init(trajectory); + defer kitty.deinit(); + try kitty.term.model.input.setValue("ab"); + try armCarryExpiredEscape(&kitty.term); + _ = kitty.term.feed("[57350;1uX"); + try std.testing.expectEqualStrings("aXb", kitty.term.model.input.getValue()); + try expectOperationAlive(&kitty, trajectory); + + var osc = try Fixture.init(trajectory); + defer osc.deinit(); + try osc.term.model.input.setValue("osc:"); + osc.term.model.theme_explicit = false; + osc.term.model.theme_id = .night; + try armCarryExpiredEscape(&osc.term); + _ = osc.term.feed("]11;rgb:f6/f6/f6\x07X"); + try std.testing.expectEqualStrings("osc:X", osc.term.model.input.getValue()); + try std.testing.expectEqual(theme_mod.Id.day, osc.term.model.theme_id); + try expectOperationAlive(&osc, trajectory); + } +} + +test "50ms byte-read human text survives carry-expired Escape on every trajectory (#537)" { + const human = [_][]const u8{ "[Alice]", "[Home]", "[Down]", "3u apples", "[3~ apples" }; + for (all_trajectories) |trajectory| for (human) |text| { + var fixture = try Fixture.init(trajectory); + defer fixture.deinit(); + try fixture.term.model.input.setValue("seed:"); + try armCarryExpiredEscape(&fixture.term); + for (text) |c| { + _ = fixture.term.feed(&[_]u8{c}); + fixture.term.now_ms += 50; + } + const value = fixture.term.model.input.getValue(); + errdefer std.debug.print("human text failure [{s} / {s}]: {s}\n", .{ trajectoryName(trajectory), text, value }); + try std.testing.expect(std.mem.startsWith(u8, value, "seed:")); + try std.testing.expectEqualStrings(text, value["seed:".len..]); + try std.testing.expectEqual(@as(usize, 0), fixture.term.pending); + try expectOperationAlive(&fixture, trajectory); + }; +} + +test "abandoned kitty releases clear Super before a bare DEL (#537)" { + const super_down = "\x1b[57444;1:1u"; + { + var term: Term = undefined; + term.init(std.testing.allocator, 80, 24); + defer term.deinit(); + _ = term.typeText("draft"); + _ = term.feed(super_down); + try std.testing.expectEqual(@as(u32, 8), key_mod.held); + // The release lost its final `u`; a new ESC replaces that truncated + // sequence and must also clear the modifier it can no longer release. + _ = term.feed("\x1b[57444;1:3\x1b[A"); + try std.testing.expectEqual(@as(u32, 0), key_mod.held); + _ = term.feed("\x7f"); + try std.testing.expectEqualStrings("draf", term.model.input.getValue()); + } + { + var term: Term = undefined; + term.init(std.testing.allocator, 80, 24); + defer term.deinit(); + _ = term.typeText("draft"); + _ = term.feed(super_down); + var wedge: [16 * 1024]u8 = undefined; + const lost_release = "\x1b[57444;1:3"; + @memcpy(wedge[0..lost_release.len], lost_release); + @memset(wedge[lost_release.len..], '1'); + _ = term.feed(&wedge); + try std.testing.expectEqual(wedge.len, term.pending); + try std.testing.expectEqual(@as(u32, 8), key_mod.held); + // The next read abandons the full production-sized pending wedge, but + // keeps scoped orphan recovery alive while DEL reparses as Backspace. + _ = term.feed("\x7f"); + try std.testing.expectEqual(@as(u32, 0), key_mod.held); + try std.testing.expectEqual(@as(usize, 0), term.pending); + try std.testing.expectEqualStrings("draf", term.model.input.getValue()); + } + { + var term: Term = undefined; + term.init(std.testing.allocator, 80, 24); + defer term.deinit(); + _ = term.feed("\x1b[200~left"); + var wedge: [16 * 1024]u8 = undefined; + @memcpy(wedge[0..2], "\x1b["); + @memset(wedge[2..], '1'); + _ = term.feed(&wedge); + key_mod.held = 8; + // Full-wedge cleanup is scoped: Ctrl-Q stays inert inside the paste, + // and the real terminator still owns both parser and model teardown. + try std.testing.expectEqual(Effect.stay, term.feed("\x11")); + try std.testing.expect(key_mod.inPaste() and term.model.pasting); + try std.testing.expectEqual(@as(u32, 0), key_mod.held); + _ = term.feed("\x1b[201~"); + try std.testing.expect(!key_mod.inPaste() and !term.model.pasting); + try std.testing.expectEqualStrings("left", term.model.input.getValue()); + } +} + +test "CSI and SS3 reparse embedded Escape at every split (#537)" { + for (embedded_escape_cases) |case| { + var cut: usize = 0; + while (cut <= case.bytes.len) : (cut += 1) { + var loop = ParserLoop.init(); + defer loop.deinit(); + errdefer std.debug.print("embedded Escape failure [{s} / split {d}]\n", .{ case.name, cut }); + try loop.read(case.bytes[0..cut]); + try loop.read(case.bytes[cut..]); + try std.testing.expectEqualStrings("leftright", loop.typed.items); + try std.testing.expectEqual(case.mice, loop.mice); + try std.testing.expectEqual(case.arrows, loop.arrows); + try std.testing.expectEqual(@as(usize, 0), loop.pending); + } + } +} diff --git a/TUI/key.zig b/TUI/key.zig index f6fe8e69..6b3bf48c 100644 --- a/TUI/key.zig +++ b/TUI/key.zig @@ -17,39 +17,44 @@ pub fn inPaste() bool { return in_paste; } -/// Close a bracketed paste the terminal never closed for us. The read loop -/// calls this when it gives up on a `CSI 201~` that split across reads and -/// never arrived — without it the latch is permanent and every Enter, Escape -/// and slash command becomes literal text for the rest of the session -/// (#532/#536/#548). +/// Close a bracketed paste and clear modifiers whose events were ignored +/// inside it. The read loop also calls this when `CSI 201~` never arrived — +/// without it the latch is permanent and every Enter, Escape and slash command +/// becomes literal text for the rest of the session (#532/#536/#548). pub fn endPaste() void { in_paste = false; + abandonSequence("", .none); } -/// Arm/disarm the orphan-debris sweeper. The read loop arms it when it throws -/// away a partial escape sequence that never finished, because the NEXT read -/// can then legitimately open with that sequence's orphaned tail. +/// Arm/disarm broad orphan recovery after the read loop drops a sequence. pub fn armOrphan(on: bool) void { - orphan.armed = on; + if (on) orphan.armDropped() else orphan.disarm(); } -/// Keep the truncated escape head the read loop just abandoned, so the next -/// read can rejoin it (see key_orphan.zig). -pub fn stashOrphanHead(bytes: []const u8) void { - orphan.stashHead(bytes); +/// Expire the short genuine-Escape carry without discarding a dropped head. +pub fn expireOrphanHead() void { + orphan.expireHead(); } -/// Prepend a stashed head to a freshly read buffer when it completes it. -/// Every read must call this — the head is one-shot and expires here. +/// Prepend a completing stashed head; every read spends this one-shot. pub fn joinOrphanHead(buf: []u8, n: usize) usize { return orphan.joinHead(buf, n); } -/// Drop every latched parser bit. The globals live for one input loop, so a -/// fresh session (or a headless sim.Term) must not inherit a held modifier, an -/// unclosed bracketed paste, or an arm from whatever ran before it. -pub fn resetInputState() void { +pub const SequenceRecovery = enum { none, escape, dropped }; +/// Clear stale modifiers, changing orphan state only as requested by recovery. +pub fn abandonSequence(bytes: []const u8, recovery: SequenceRecovery) void { held = 0; + if (recovery != .none) orphan.stashHead(bytes); + switch (recovery) { + .none => {}, + .escape => orphan.armEscape(), + .dropped => orphan.armDropped(), + } +} +/// A fresh input loop must not inherit any parser latch from the previous one. +pub fn resetInputState() void { + abandonSequence("", .none); in_paste = false; orphan.reset(); } @@ -100,6 +105,13 @@ pub const Mouse = struct { }; pub fn next(bytes: []const u8, i: *usize) ?Key { + // A full input read cannot grow to fit a carried head. key_orphan consumes + // exactly the completing tail and queues its decoded event ahead of every + // byte that was already behind it in the buffer. + if (i.* == 0) if (orphan.takeRecoveredEvent()) |event| { + orphan.end = std.math.maxInt(usize); + return event; + }; if (i.* >= bytes.len) return null; // Orphan CSI debris can only ever be the HEAD of a read: the ESC that // opened the sequence was lost BETWEEN reads (the loop dropped a truncated @@ -114,7 +126,7 @@ pub fn next(bytes: []const u8, i: *usize) ?Key { // the sweeper keep eating whatever the user typed next — `3u // apples` reached the composer as ` apples`, and a lone `3` // was held pending until the stall path dropped it (#531). - orphan.armed = false; + orphan.disarm(); return k; }, // Debris cut short at the read boundary: hold the bytes so the loop @@ -127,7 +139,7 @@ pub fn next(bytes: []const u8, i: *usize) ?Key { } } orphan.end = std.math.maxInt(usize); - orphan.armed = false; + orphan.disarm(); const b = bytes[i.*]; i.* += 1; if (b == 0x1b) return escapeSeq(bytes, i); @@ -185,29 +197,13 @@ fn escapeSeq(bytes: []const u8, i: *usize) ?Key { return .ignore; } i.* += 1; - // X10 mouse (1000h without 1006): CSI M + 3 raw bytes. - if (i.* < bytes.len and bytes[i.*] == 'M' and (i.* + 1 >= bytes.len or bytes[i.* + 1] != ';')) { - if (i.* + 3 >= bytes.len) { - i.* -= 2; - return null; - } - i.* += 1; - const btn = bytes[i.*]; - i.* += 1; - const x = bytes[i.*]; - i.* += 1; - const y = bytes[i.*]; - i.* += 1; - return .{ .mouse = .{ - .btn = if (btn >= 32) btn - 32 else btn, - .x = if (x >= 32) @as(u16, x) - 32 else x, - .y = if (y >= 32) @as(u16, y) - 32 else y, - .down = true, - } }; - } const start = i.*; while (i.* < bytes.len) : (i.* += 1) { const c = bytes[i.*]; + if (c == 0x1b) { + abandonSequence("", .none); + return .ignore; // reparse this event head + } if (c >= 0x40 and c <= 0x7e) { const final = c; const params = bytes[start..i.*]; @@ -246,6 +242,7 @@ fn stringSeq(bytes: []const u8, i: *usize) ?Key { i.* = j + 2; return oscReply(bytes[start], body); } + abandonSequence("", .none); i.* = j; return .ignore; } @@ -256,11 +253,13 @@ fn stringSeq(bytes: []const u8, i: *usize) ?Key { // Alt+]/P/X/^/_ chord. Swallow only the introducer — like any // unbound alt-chord — so the text reparses instead of the // keyboard wedging until it is destroyed (#516). + abandonSequence("", .none); i.* += 1; return .ignore; } } if (bytes.len - i.* > max_reply_pending) { + abandonSequence("", .none); i.* += 1; // over any real reply's size — same chord recovery (#516) return .ignore; } @@ -270,7 +269,7 @@ fn stringSeq(bytes: []const u8, i: *usize) ?Key { /// A terminated OSC body. The only reply we act on is OSC 11 (background /// color, answering run.zig's startup query) — everything else stays inert. -fn oscReply(kind: u8, body: []const u8) Key { +pub fn oscReply(kind: u8, body: []const u8) Key { if (kind != ']') return .ignore; if (!std.mem.startsWith(u8, body, "11;")) return .ignore; const rgb = parseXColor(body[3..]) orelse return .ignore; @@ -299,7 +298,15 @@ fn ss3(bytes: []const u8, i: *usize) ?Key { return null; } const final = bytes[i.* + 1]; - i.* += 2; + i.* += if (final == 0x1b) 1 else 2; + if (final == 0x1b) { + abandonSequence("", .none); + return .ignore; + } + return decodeSs3(final); +} + +pub fn decodeSs3(final: u8) Key { return switch (final) { 'A' => .up, 'B' => .down, @@ -314,22 +321,35 @@ fn ss3(bytes: []const u8, i: *usize) ?Key { }; } -/// CSI M b x y — X10 mouse fallback when the terminal ignores SGR 1006. -/// Without this the three payload bytes are typed into the prompt as garbage -/// on every click and pointer move. +/// CSI M b x y — X10 fallback when the terminal ignores SGR 1006. fn x10Mouse(bytes: []const u8, i: *usize, start: usize) ?Key { + const available_end = @min(i.* + 3, bytes.len); + if (std.mem.indexOfScalar(u8, bytes[i.*..available_end], 0x1b)) |at| { + i.* += at; + abandonSequence("", .none); + return .ignore; + } if (i.* + 3 > bytes.len) { i.* = start - 2; return null; } - const b = bytes[i.*] -| 32; - const x = bytes[i.* + 1] -| 32; - const y = bytes[i.* + 2] -| 32; + const event = decodeX10(bytes[i.* .. i.* + 3]); i.* += 3; - return .{ .mouse = .{ .btn = b, .x = x, .y = y, .down = (b & 3) != 3 } }; + return event; } -fn decodeCsi(params: []const u8, final: u8) Key { +pub fn decodeX10(payload: []const u8) Key { + std.debug.assert(payload.len == 3); + const b = payload[0] -| 32; + return .{ .mouse = .{ + .btn = b, + .x = payload[1] -| 32, + .y = payload[2] -| 32, + .down = (b & 3) != 3, + } }; +} + +pub fn decodeCsi(params: []const u8, final: u8) Key { const mods = csiMods(params); const alt = mods & 2 != 0; const ctrl = mods & 4 != 0; @@ -355,10 +375,11 @@ fn decodeCsi(params: []const u8, final: u8) Key { 12 => .f2, 200 => blk: { in_paste = true; + abandonSequence("", .none); break :blk .paste_start; }, 201 => blk: { - in_paste = false; + endPaste(); break :blk .paste_end; }, 27 => fixterms(params), @@ -368,7 +389,7 @@ fn decodeCsi(params: []const u8, final: u8) Key { 'u' => kitty(params), 'P' => .f1, 'Q' => .f2, - 'M', 'm' => sgrMouse(params, final == 'M'), + 'M', 'm' => if (std.mem.count(u8, params, ";") == 2) sgrMouse(params, final == 'M') else .ignore, // Unknown final — a stray reply or unsupported key, not the Esc key. else => .ignore, }; @@ -412,14 +433,14 @@ fn kitty(params: []const u8) Key { const ev = eventOf(params); if (code >= 57344 and code <= 57454) return functional(code, mods, ev); if (ev == 3) return .ignore; - // A live key is ground truth for held modifiers — resync so a missed + // Outside paste, a live key is ground truth — resync so a missed // release can't latch alt/super forever. The ABSENT mods field is ground // truth too: kitty omits it exactly when no modifiers are down, so a // plain keypress must clear the latch. Before this, Cmd+Tab-ing away // mid-composition latched super (the release went to the other app) and // the next plain Backspace became Cmd+Backspace = delete-to-start, // silently wiping the composer. - held = if (has_mods) mods & 10 else 0; + if (!in_paste) held = if (has_mods) mods & 10 else 0; return mapCode(code, mods); } @@ -433,7 +454,7 @@ fn functional(code: u32, mods: u32, ev: u32) Key { else => 0, }; if (bit != 0) { - if (ev == 3) held &= ~bit else held |= bit; + if (!in_paste) held = if (ev == 3) held & ~bit else held | bit; return .ignore; } if (ev == 3) return .ignore; @@ -538,7 +559,9 @@ fn shiftedAscii(ch: u8) u8 { } fn sgrMouse(params: []const u8, down: bool) Key { - if (params.len == 0 or params[0] != '<') return .ignore; + if (params.len < 6 or params[0] != '<' or params[1] == ';' or params[params.len - 1] == ';' or + std.mem.indexOf(u8, params, ";;") != null or std.mem.indexOfScalar(u8, params, ':') != null) return .ignore; + for (params[1..]) |c| if ((c < '0' or c > '9') and c != ';') return .ignore; var it = std.mem.splitScalar(u8, params[1..], ';'); const btn = leadingInt(it.next() orelse "0"); const x = leadingInt(it.next() orelse "1"); @@ -552,10 +575,8 @@ fn sgrMouse(params: []const u8, down: bool) Key { } pub fn leadingInt(s: []const u8) u32 { - // Saturating (#545): params come raw off stdin, and a 10+-digit run — - // hostile or corrupt input, never a real key — overflowed u32 and aborted - // the whole TUI. maxInt decodes as .ignore / clamped coords everywhere. - // pub: key_orphan.zig (the extracted debris sweeper) parses with it too. + // Saturating (#545): hostile 10+-digit params used to abort the TUI; + // maxInt safely decodes as ignore/clamped coords. Used by key_orphan too. var n: u32 = 0; for (s) |c| { if (c < '0' or c > '9') break; @@ -563,28 +584,3 @@ pub fn leadingInt(s: []const u8) u32 { } return n; } - -test "#545: 10+-digit CSI params saturate instead of aborting the TUI" { - try std.testing.expectEqual(std.math.maxInt(u32), leadingInt("99999999999999999999")); - try std.testing.expectEqual(std.math.maxInt(u32), leadingInt("9999999999")); // 10 digits — the shortest crasher - _ = @import("key_paste.zig"); - try std.testing.expectEqual(@as(u32, 4294967295), leadingInt("4294967295")); // exact maxInt still parses - try std.testing.expectEqual(@as(u32, 200), leadingInt("200~tail")); // normal params unchanged - // End-to-end: the fleet's four crashing payloads decode without a panic. - resetInputState(); - defer resetInputState(); - const payloads = [_][]const u8{ - "\x1b[99999999999999999999;1u", - "\x1b[9999999999;1u", - "\x1b[<0;9999999999;5M", - "\x1b[200~hello \x1b[99999999999999999999;1u world\x1b[201~", - }; - for (payloads) |p| { - var i: usize = 0; - while (i < p.len) { - const before = i; - _ = next(p, &i); - if (i <= before) break; // trailing partial rewound — this payload is done - } - } -} diff --git a/TUI/key_loop_tests.zig b/TUI/key_loop_tests.zig index ada86f12..472be03f 100644 --- a/TUI/key_loop_tests.zig +++ b/TUI/key_loop_tests.zig @@ -16,6 +16,8 @@ const Loop = struct { pending: usize = 0, typed: std.array_list.Managed(u8), mice: usize = 0, + arrows: usize = 0, + paste_starts: usize = 0, paste_ends: usize = 0, bg_reports: usize = 0, @@ -35,7 +37,9 @@ const Loop = struct { .char => |c| try self.typed.append(c), .codepoint => try self.typed.append('?'), .mouse => self.mice += 1, + .left, .right, .up, .down => self.arrows += 1, .escape => try self.typed.append('E'), // a phantom Escape cancels the turn + .paste_start => self.paste_starts += 1, .paste_end => self.paste_ends += 1, .bg_report => self.bg_reports += 1, else => {}, @@ -51,7 +55,7 @@ const Loop = struct { /// grace, so a real Escape is delivered — but the byte is carried, not /// thrown away. fn stallEscape(self: *Loop) !void { - key.stashOrphanHead(self.inbuf[0..self.pending]); + key.abandonSequence(self.inbuf[0..self.pending], .escape); self.pending = 0; try self.typed.append('E'); } @@ -59,19 +63,18 @@ const Loop = struct { /// run.zig's `.drop` verdict: carry the head, arm the sweeper, and close /// out a paste that can no longer be closed by its own marker. fn stallDrop(self: *Loop) void { - key.stashOrphanHead(self.inbuf[0..self.pending]); + key.abandonSequence(self.inbuf[0..self.pending], .dropped); self.pending = 0; - key.armOrphan(true); if (key.inPaste()) { key.endPaste(); self.paste_ends += 1; } } - /// run.zig's carry window elapsing (`stall.carryExpired`): the head is - /// spent before the read, so it can no longer glue itself onto anything. + /// run.zig's short genuine-Escape carry elapsing. Dropped non-lone heads + /// retain their exact framing until the full recovery interval ends. fn expireCarry(_: *Loop) void { - key.stashOrphanHead(""); + key.expireOrphanHead(); } /// run.zig's arm window elapsing (`stall.armExpired`): the loop disarms the @@ -83,6 +86,22 @@ const Loop = struct { } }; +test "dropping a sequence clears kitty modifiers without clearing recovery" { + var loop = Loop.init(std.testing.allocator); + defer loop.deinit(); + key.held = 8; // a Super release was the sequence that got lost + try loop.read("\x1b[57444;1:3"); + loop.stallDrop(); + try std.testing.expectEqual(@as(u32, 0), key.held); + var tail: [32]u8 = undefined; + tail[0] = 'u'; + const n = key.joinOrphanHead(&tail, 1); + var i: usize = 0; + try std.testing.expectEqual(Key.ignore, next(tail[0..n], &i).?); + i = 0; + try std.testing.expectEqual(Key.backspace, next("\x7f", &i).?); +} + test "SGR motion flood chopped at every byte offset never types a character" { key.armOrphan(false); // The exact bytes the user saw on the bottom row, plus one hover report. @@ -112,7 +131,7 @@ test "debris after a dropped ESC head is consumed, not typed — even split agai try loop.read(tail[0..cut]); try loop.read(tail[cut..]); try std.testing.expectEqualStrings("", loop.typed.items); - try std.testing.expectEqual(@as(usize, 2), loop.mice); + try std.testing.expectEqual(@as(usize, 1), loop.mice); } } @@ -156,7 +175,7 @@ test "a body that arrives after the escape_key verdict rejoins its ESC (#530)" { // ssh/tmux cuts right after the 0x1b of a sequence and the next segment is // >50ms late. run.zig delivers Escape (the E below) — but throwing the ESC // away typed the whole body into the composer on top of that. - const bodies = [_][]const u8{ "[<35;80;24M", "[A", "[3~", "OA", "]11;rgb:14/14/14\x07" }; + const bodies = [_][]const u8{ "[<35;80;24M", "[M !!", "[A", "[3~", "OA", "]11;rgb:14/14/14\x07" }; for (bodies) |body| { var loop = Loop.init(std.testing.allocator); defer loop.deinit(); @@ -171,8 +190,8 @@ test "a body that arrives after the escape_key verdict rejoins its ESC (#530)" { defer mouse.deinit(); try mouse.read("\x1b"); try mouse.stallEscape(); - try mouse.read("[<35;80;24M"); - try std.testing.expectEqual(@as(usize, 1), mouse.mice); + try mouse.read("[<35;80;24M\x1b[M !!"); + try std.testing.expectEqual(@as(usize, 2), mouse.mice); var osc = Loop.init(std.testing.allocator); defer osc.deinit(); try osc.read("\x1b"); @@ -181,6 +200,83 @@ test "a body that arrives after the escape_key verdict rejoins its ESC (#530)" { try std.testing.expectEqual(@as(usize, 1), osc.bg_reports); } +test "carry-expired unambiguous mouse kitty and OSC bodies recover (#537)" { + const cases = [_]struct { + body: []const u8, + mice: usize = 0, + arrows: usize = 0, + bg_reports: usize = 0, + }{ + .{ .body = "[M !!", .mice = 1 }, + .{ .body = "[<35;80;24M", .mice = 1 }, + .{ .body = "[57350;1u", .arrows = 1 }, + .{ .body = "]11;rgb:14/14/14\x07", .bg_reports = 1 }, + .{ .body = "]11;rgb:1414/1414/1414\x1b\\", .bg_reports = 1 }, + }; + for (cases) |case| for (0..case.body.len + 1) |cut| { + var loop = Loop.init(std.testing.allocator); + defer loop.deinit(); + try loop.read("\x1b"); + try loop.stallEscape(); + loop.expireCarry(); + try loop.read(case.body[0..cut]); + try loop.read(case.body[cut..]); + try std.testing.expectEqualStrings("E", loop.typed.items); + try std.testing.expectEqual(case.mice, loop.mice); + try std.testing.expectEqual(case.arrows, loop.arrows); + try std.testing.expectEqual(case.bg_reports, loop.bg_reports); + try std.testing.expectEqual(@as(usize, 0), loop.pending); + }; +} + +test "carry-expired paste start is real before control-bearing same-read payload (#537)" { + var loop = Loop.init(std.testing.allocator); + defer loop.deinit(); + try loop.read("\x1b"); + try loop.stallEscape(); + loop.expireCarry(); + try loop.read("[200~left\x11\x03\nright"); + try std.testing.expectEqual(@as(usize, 1), loop.paste_starts); + try std.testing.expect(key.inPaste()); + try std.testing.expectEqualStrings("Eleft\nright", loop.typed.items); + try std.testing.expectEqual(@as(usize, 0), loop.pending); + try loop.read("\x1b[201~"); + try std.testing.expectEqual(@as(usize, 1), loop.paste_ends); + try std.testing.expect(!key.inPaste()); +} + +test "post-Escape byte-at-a-time human and exact tails stay text (#537)" { + const human = [_][]const u8{ + "[Alice]", + "[Home]", + "[Down]", + "[A", + "OA", + "3u apples", + "[3~ apples", + "Orange", + }; + for (human) |text| for ([_]bool{ false, true }) |expire| { + var loop = Loop.init(std.testing.allocator); + defer loop.deinit(); + try loop.read("\x1b"); + try loop.stallEscape(); + if (expire) loop.expireCarry(); + for (text) |c| try loop.read(&[_]u8{c}); + try std.testing.expectEqualStrings("E", loop.typed.items[0..1]); + try std.testing.expectEqualStrings(text, loop.typed.items[1..]); + }; + + // After the 1s arm window even an exact CSI token is ordinary text. + var expired = Loop.init(std.testing.allocator); + defer expired.deinit(); + try expired.read("\x1b"); + try expired.stallEscape(); + expired.expireArm(); + try expired.read("[A"); + try std.testing.expectEqualStrings("E[A", expired.typed.items); +} + test "a dropped head rejoins its tail whatever the split (#531/#546)" { // Every shape takeOrphanCsi structurally cannot sweep: a split ON the // separator, a non-mouse final, an OSC body. @@ -222,7 +318,7 @@ test "the orphan arm covers exactly one lost head (#531)" { defer loop.deinit(); key.armOrphan(true); try loop.read("39;7;32M"); - try std.testing.expectEqual(@as(usize, 1), loop.mice); + try std.testing.expectEqual(@as(usize, 0), loop.mice); try loop.read("3u apples"); try std.testing.expectEqualStrings("3u apples", loop.typed.items); try std.testing.expectEqual(@as(usize, 0), loop.pending); @@ -336,7 +432,7 @@ test "a stale debris arm never eats the token the user types later" { } test "late debris tails are eaten while armed and typed when they are not" { - // The head is dropped, the 400ms carry window expires, and the tail lands + // The head is unavailable, broad recovery is armed, and the tail lands // late: `take` rejected every final outside `M/m/u/~`, so `2A`, `~`, `[A` // and a split paste START marker all typed themselves into the composer. const tails = [_][]const u8{ "2A", "~", "[A", "[3~", "1;2D", "[H", "[Z", "2F" }; diff --git a/TUI/key_orphan.zig b/TUI/key_orphan.zig index 5d0d6131..caa6e0de 100644 --- a/TUI/key_orphan.zig +++ b/TUI/key_orphan.zig @@ -6,20 +6,28 @@ //! that sequence's tail can still land on a LATER read with its introducer //! gone. //! -//! * `stashHead`/`joinHead` keep the abandoned head for exactly one more -//! read and re-attach it when the new bytes really do complete it. The -//! tail then parses as the thing it always was — mouse report, arrow, -//! OSC-11 reply — with no shape guessing at all (#530/#531). -//! * `take` is the fallback sweeper for tails whose head is gone for good, -//! so `;24M` is eaten instead of typed into the composer (#546). +//! * `stashHead`/`joinHead` keep the abandoned head for the next read and +//! re-attach it when the new bytes really do complete it. A dropped +//! non-lone head keeps that exact framing for the full recovery interval; +//! a delivered Escape keeps its shorter, ambiguity-limited carry. The tail +//! then parses as the thing it always was, with no shape guessing +//! (#530/#531). +//! * `take` is the fallback once that exact carry is gone. After a genuine +//! Escape it accepts only bodies distinguishable from prose (mouse, kitty, +//! paste, terminated OSC); short CSI/SS3 spellings remain text (#537). const std = @import("std"); const key = @import("key.zig"); +const recover = @import("key_recover.zig"); const Key = key.Key; /// Set by the read loop when it abandons a truncated escape head: the NEXT /// read may legitimately open with that sequence's orphaned tail. pub var armed: bool = false; +/// Narrow arm used after a lone ESC was delivered as a genuine key. Its late +/// body must retain a CSI/SS3/OSC introducer; accepting headless parameter runs +/// here would eat ordinary text such as `3u apples` after a real Escape. +pub var escape_armed: bool = false; /// End offset of the last fragment consumed from the buffer currently being /// parsed, so back-to-back debris keeps being eaten while a digit run that @@ -31,56 +39,217 @@ pub var end: usize = std.math.maxInt(usize); const max_head = 64; var head: [max_head]u8 = undefined; var head_len: usize = 0; +const RecoveredPaste = enum { start, end }; +var recovered_event: ?Key = null; pub fn reset() void { - armed = false; + disarm(); end = std.math.maxInt(usize); head_len = 0; + recovered_event = null; +} + +pub fn armDropped() void { + armed = true; + escape_armed = false; +} + +pub fn armEscape() void { + armed = false; + escape_armed = true; +} + +pub fn disarm() void { + armed = false; + escape_armed = false; + head_len = 0; + recovered_event = null; +} + +/// Expire only the short exact carry used after a genuine Escape. A non-lone +/// sequence that the loop actually dropped is stronger evidence, so its exact +/// framing remains until the broad recovery interval expires or the next read +/// spends it. +pub fn expireHead() void { + if (!armed) head_len = 0; } -/// Keep the head the loop just gave up on. One shot: `joinHead` spends it on -/// the very next read whether or not it is used. +/// Keep the head the loop just gave up on. `joinHead` may extend it with valid +/// partial tails, but any mismatch spends it before fresh input is parsed. pub fn stashHead(bytes: []const u8) void { head_len = 0; + recovered_event = null; if (bytes.len == 0 or bytes.len > max_head) return; @memcpy(head[0..bytes.len], bytes); head_len = bytes.len; } -/// Re-attach a stashed head to the front of `buf[0..n]` when the new bytes -/// complete it, returning the new length. Spending the head unconditionally is -/// the point: a head whose tail never came can never glue itself onto a later -/// keystroke. +const PasteProgress = union(enum) { + partial, + complete: struct { kind: RecoveredPaste, tail_len: usize }, +}; + +fn pasteProgress(h: []const u8, t: []const u8) ?PasteProgress { + const markers = [_]struct { bytes: []const u8, kind: RecoveredPaste }{ + .{ .bytes = "\x1b[200~", .kind = .start }, + .{ .bytes = "\x1b[201~", .kind = .end }, + }; + const n = h.len + t.len; + for (markers) |marker| { + if (h.len >= marker.bytes.len) continue; + var k: usize = 0; + while (k < @min(n, marker.bytes.len) and at(h, t, k) == marker.bytes[k]) : (k += 1) {} + if (k != @min(n, marker.bytes.len)) continue; + if (n < marker.bytes.len) return .partial; + return .{ .complete = .{ .kind = marker.kind, .tail_len = marker.bytes.len - h.len } }; + } + return null; +} + +/// A dropped exact head may itself be split over several late reads. Retain +/// only prefixes that can still become the same framed sequence, under the +/// same 64-byte and one-second bounds as the original head. +fn validPartial(h: []const u8, t: []const u8) bool { + const n = h.len + t.len; + if (n == 0 or n > max_head or at(h, t, 0) != 0x1b) return false; + if (n == 1) return true; + switch (at(h, t, 1)) { + 'O' => return n < 3, + '[' => { + var k: usize = 2; + while (k < n) : (k += 1) { + const c = at(h, t, k); + if (c >= 0x20 and c <= 0x3f) continue; + if (k == 2 and c == 'M') { + if (n >= 6) return false; + for (k + 1..n) |payload| if (at(h, t, payload) == 0x1b) return false; + return true; + } + return false; // a final is complete or invalid, never partial + } + return true; + }, + ']', 'P', '_', '^', 'X' => { + var k: usize = 2; + while (k < n) : (k += 1) { + const c = at(h, t, k); + if (c == 0x07 or c == '\r' or c == '\n') return false; + if (c == 0x1b) return k + 1 == n; + } + return true; + }, + else => return false, + } +} + +/// A full read leaves no room to prepend a saved head. Return the event decoded +/// from that head and the exact leading tail bytes `joinHead` removed. +pub fn takeRecoveredEvent() ?Key { + const event = recovered_event orelse return null; + recovered_event = null; + return event; +} + +/// Re-attach a stashed head when a later read completes it. Valid dropped-head +/// prefixes accumulate across reads; an invalid or oversized tail disarms and +/// is processed as fresh input, so recovery remains bounded and fails closed. pub fn joinHead(buf: []u8, n: usize) usize { const h = head_len; + if (h == 0 or n == 0) return n; + const dropped = armed; + const progress = if (dropped) pasteProgress(head[0..h], buf[0..n]) else null; + if (completesWithEvidence(head[0..h], buf[0..n], dropped)) { + if (h + n <= buf.len) { + std.mem.copyBackwards(u8, buf[h .. h + n], buf[0..n]); + @memcpy(buf[0..h], head[0..h]); + disarm(); + return n + h; + } + if (recover.completed(head[0..h], buf[0..n])) |recovered| { + std.mem.copyForwards(u8, buf[0 .. n - recovered.tail_len], buf[recovered.tail_len..n]); + disarm(); + recovered_event = recovered.event; + return n - recovered.tail_len; + } + } else if (dropped and ((if (progress) |p| switch (p) { + .partial => true, + .complete => false, + } else false) or validPartial(head[0..h], buf[0..n]))) { + if (h + n <= max_head) { + @memcpy(head[h .. h + n], buf[0..n]); + head_len = h + n; + return 0; + } + } + // These bytes do not finish the saved head. They are fresh input: broad + // recovery must not reinterpret byte-at-a-time prose as a cursor key. head_len = 0; - if (h == 0 or n == 0 or h + n > buf.len) return n; - if (!completes(head[0..h], buf[0..n])) return n; - std.mem.copyBackwards(u8, buf[h .. h + n], buf[0..n]); - @memcpy(buf[0..h], head[0..h]); - return n + h; + if (dropped) disarm(); + return n; } fn at(h: []const u8, t: []const u8, k: usize) u8 { return if (k < h.len) h[k] else t[k - h.len]; } +fn alignedFields(h: []const u8, t: []const u8, start: usize, end_at: usize, semicolons: ?usize, colon: bool) bool { + var semis: usize = 0; + var need_digit = true; + for (start..end_at) |k| { + const c = at(h, t, k); + if (c >= '0' and c <= '9') { + need_digit = false; + } else if (!need_digit and (c == ';' or (colon and c == ':'))) { + if (c == ';') semis += 1; + need_digit = true; + } else return false; + } + return !need_digit and (semicolons == null or semis == semicolons.?); +} + +fn framedDroppedSuffix(h: []const u8, t: []const u8, final_at: usize, final: u8) bool { + if ((final == 'M' or final == 'm') and final_at > 3 and at(h, t, 2) == '<') + return alignedFields(h, t, 3, final_at, 2, false); + if (final == 'u') return alignedFields(h, t, 2, final_at, null, true); + if (final != '~' or final_at != 5) return false; + return at(h, t, 2) == '2' and at(h, t, 3) == '0' and + (at(h, t, 4) == '0' or at(h, t, 4) == '1'); +} + /// Does `t` finish the truncated escape `h`? Only a join that yields a /// COMPLETE sequence is worth making: after a give-up stall the next bytes are /// just as likely to be a human resuming typing, and gluing a stale head onto /// those would eat a keystroke. pub fn completes(h: []const u8, t: []const u8) bool { + return completesWithEvidence(h, t, false); +} + +fn completesWithEvidence(h: []const u8, t: []const u8, dropped: bool) bool { const n = h.len + t.len; if (h.len == 0 or t.len == 0 or h[0] != 0x1b or n < 2) return false; switch (at(h, t, 1)) { 0x1b => return true, // ESC ESC — a real Escape either way - 'O' => return n >= 3, // SS3 is exactly one more byte + 'O' => return n >= 3 and isSs3Final(at(h, t, 2)) and + (n == 3 or at(h, t, 3) < 0x20 or at(h, t, 3) == 0x7f), '[' => { var k: usize = 2; while (k < n) : (k += 1) { const c = at(h, t, k); if (c >= 0x20 and c <= 0x3f) continue; // params + intermediates - return isInputFinal(c); + if (!isInputFinal(c)) return false; + if (k == 2 and c == 'M') { + if (n < 6) return false; // X10 is CSI M plus three bytes + for (3..6) |payload| if (at(h, t, payload) == 0x1b) return false; + return true; + } + // A complete cursor key followed by printable bytes is also + // ordinary text (`[Alice]`, `[Home]`). Favor that reading. + // Bracketed-paste start is the exception: suffix bytes are its + // payload and must be parsed with paste mode already latched. + const next_is_control = k + 1 < n and + (at(h, t, k + 1) < 0x20 or at(h, t, k + 1) == 0x7f); + return k + 1 == n or next_is_control or (dropped and framedDroppedSuffix(h, t, k, c)) or + (k == 5 and c == '~' and at(h, t, 2) == '2' and at(h, t, 3) == '0' and at(h, t, 4) == '0'); } return false; }, @@ -111,9 +280,9 @@ fn isInputFinal(c: u8) bool { } /// Finals that count only while `armed`. Cursor/mode replies (`[2A`, `[H`, -/// `[?1003l`) reach the sweeper whenever the carry window expired before the -/// tail landed, and typing `2A` into the composer is exactly the debris this -/// file exists to eat. Unarmed they stay ordinary text — `2A`, `3H` and `1F` +/// `[?1003l`) reach the sweeper when an oversized head could not be retained; +/// typing `2A` into the composer is exactly the debris this file exists to eat. +/// Unarmed they stay ordinary text — `2A`, `3H` and `1F` /// are all things people write, and the suite pins them. fn isArmedFinal(c: u8) bool { return switch (c) { @@ -126,6 +295,135 @@ fn isFinal(c: u8, armed_now: bool) bool { return c == 'M' or c == 'm' or c == 'u' or c == '~' or (armed_now and isArmedFinal(c)); } +fn isSs3Final(c: u8) bool { + return switch (c) { + 'A'...'D', 'F', 'H', 'M', 'P'...'S' => true, + else => false, + }; +} + +fn delimitedDecimal(bytes: []const u8, colon: bool) bool { + var separators: usize = 0; + var need_digit = true; + for (bytes) |c| { + if (c >= '0' and c <= '9') { + need_digit = false; + continue; + } + if (need_digit or (c != ';' and (!colon or c != ':'))) return false; + separators += 1; + need_digit = true; + } + return !need_digit and separators > 0; +} + +/// Shapes specific enough to recover after the exact ESC carry is gone. +/// `[A`, `[H`, `[3~`, and every SS3 token are intentionally absent: at a read +/// boundary they are indistinguishable from prefixes of human prose. +fn unambiguousLateCsi(params: []const u8, final: u8) bool { + if (final == '~') return std.mem.eql(u8, params, "200") or std.mem.eql(u8, params, "201"); + if ((final == 'M' or final == 'm') and params.len > 1 and params[0] == '<') + return std.mem.count(u8, params[1..], ";") == 2 and delimitedDecimal(params[1..], false); + return final == 'u' and delimitedDecimal(params, true); +} + +/// Recover the unambiguous body of a sequence whose lone ESC was already +/// delivered as a genuine key. Ambiguous short CSI stays ordinary text. +fn takeLateCsi(bytes: []const u8, i: *usize, dropped_head: bool) Orphan { + const start = i.*; // bytes[start] is '[' + var k = start + 1; + while (k < bytes.len) : (k += 1) { + const c = bytes[k]; + if (c == 0x1b) { + i.* = k; + key.abandonSequence("", .none); + return .{ .took = .ignore }; + } + if (c >= 0x20 and c <= 0x3f) continue; + if (!isInputFinal(c)) return .none; + const params = bytes[start + 1 .. k]; + if (c == 'M' and params.len == 0) return takeLateX10(bytes, i, k); + if (!unambiguousLateCsi(params, c)) { + if (!dropped_head) return .none; + const printable_suffix = k + 1 < bytes.len and bytes[k + 1] >= 0x20 and bytes[k + 1] != 0x7f; + if (printable_suffix) return .none; + } + i.* = k + 1; + return .{ .took = key.decodeCsi(params, c) }; + } + return if (bytes.len - start <= max_head) .partial else .none; +} + +fn takeLateX10(bytes: []const u8, i: *usize, final_at: usize) Orphan { + const payload = final_at + 1; + const available_end = @min(payload + 3, bytes.len); + if (std.mem.indexOfScalar(u8, bytes[payload..available_end], 0x1b)) |offset| { + i.* = payload + offset; + key.abandonSequence("", .none); + return .{ .took = .ignore }; + } + if (payload + 3 > bytes.len) return .partial; + i.* = payload + 3; + return .{ .took = key.decodeX10(bytes[payload .. payload + 3]) }; +} + +fn isHex(c: u8) bool { + return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F'); +} + +/// Only the OSC reply this TUI solicits may remain pending across another read. +/// A complete BEL/ST-terminated OSC is exact and can always be discarded. +fn osc11Prefix(body: []const u8) bool { + const lead = "11;rgb:"; + if (body.len <= lead.len) return std.mem.startsWith(u8, lead, body); + if (!std.mem.startsWith(u8, body, lead)) return false; + var digits: usize = 0; + var slashes: usize = 0; + for (body[lead.len..]) |c| { + if (isHex(c)) { + digits += 1; + if (digits > 4) return false; + } else if (c == '/' and (digits == 2 or digits == 4) and slashes < 2) { + digits = 0; + slashes += 1; + } else return false; + } + return true; +} + +fn takeLateOsc(bytes: []const u8, i: *usize) Orphan { + const start = i.*; // bytes[start] is ']' + var j = start + 1; + while (j < bytes.len) : (j += 1) { + if (bytes[j] == 0x07) { + i.* = j + 1; + return .{ .took = key.oscReply(']', bytes[start + 1 .. j]) }; + } + if (bytes[j] == 0x1b) { + if (j + 1 < bytes.len) { + if (bytes[j + 1] != '\\') { + i.* = j; + key.abandonSequence("", .none); + return .{ .took = .ignore }; + } + i.* = j + 2; + return .{ .took = key.oscReply(']', bytes[start + 1 .. j]) }; + } + return if (osc11Prefix(bytes[start + 1 .. j])) .partial else .none; + } + if (bytes[j] == '\r' or bytes[j] == '\n') return .none; + } + return if (bytes.len - start <= 128 and osc11Prefix(bytes[start + 1 ..])) .partial else .none; +} + +fn takeEscapeBody(bytes: []const u8, i: *usize) Orphan { + return switch (bytes[i.*]) { + '[' => takeLateCsi(bytes, i, false), + ']' => takeLateOsc(bytes, i), + else => .none, // every short SS3 body is prose-ambiguous without ESC + }; +} + pub const Orphan = union(enum) { /// Not debris — hand the bytes to the normal parser (typed text). none, @@ -145,23 +443,19 @@ pub const Orphan = union(enum) { /// while `armed` says the loop really did drop a truncated sequence. /// /// `armed` also widens the shape, because it means the loop REALLY lost a head -/// and the carry window expired before the tail landed: an orphaned `[` -/// introducer counts, so do cursor/mode finals (`2A`, `[H`, `[200~`), and a +/// whose exact bytes were unavailable: an orphaned `[` introducer counts, as do +/// cursor/mode finals (`2A`, `[H`, `[200~`) and a /// bare `~` whose parameters went with the head. Unarmed none of that moves — /// every one of those strings is something a human types. pub fn take(bytes: []const u8, i: *usize) Orphan { const start = i.*; if (start >= bytes.len) return .none; + if (escape_armed) return takeEscapeBody(bytes, i); + // A dropped parser head is stronger evidence than a genuine Escape key: + // retain its bounded exact-tail recovery, while a same-read printable + // suffix still makes `[Alice]` prose. + if (armed and bytes[start] == '[') return takeLateCsi(bytes, i, true); var j = start; - // The head that was dropped was a bare `\x1b`, so its `[` now leads the - // read (`[A`, `[200~PASTED`). Only ever debris while armed: unarmed, `[12]` - // is somebody typing. - var lost_csi = false; - if (armed and bytes[j] == '[') { - lost_csi = true; - j += 1; - if (j >= bytes.len) return .partial; - } const lt = bytes[j] == '<'; if (lt) j += 1; var sep = false; @@ -174,13 +468,11 @@ pub fn take(bytes: []const u8, i: *usize) Orphan { headless = true; j += 1; } - if (j >= bytes.len) return if (headless or lost_csi) .partial else .none; + if (j >= bytes.len) return if (headless) .partial else .none; if (bytes[j] < '0' or bytes[j] > '9') { - // No parameters left at all: they went with the head. `[A` (we saw the - // orphaned `[`) and a lone `~` are debris while armed. A bare letter - // with no `[` in front of it is NOT — that is the user typing, and - // eating it would swallow the `h` of `hello`. - if (armed and ((lost_csi and isFinal(bytes[j], true)) or bytes[j] == '~')) { + // With all parameters lost, only `~` is distinctive enough to drop. + // A bare letter is human text, even while recovery is armed. + if (armed and bytes[j] == '~') { i.* = j + 1; return .{ .took = .ignore }; } @@ -197,15 +489,18 @@ pub fn take(bytes: []const u8, i: *usize) Orphan { if (isFinal(c, armed)) { if (!lt and !sep and !armed) return .none; i.* = k + 1; - // A fragment that opened on a separator lost its leading fields: - // the coordinates cannot be reconstructed, and inventing a click - // at (1,1) is worse than dropping the report. - if (!headless and (c == 'M' or c == 'm')) { + // Only `<` proves that this tail starts at SGR field zero. A + // digit/separator-led tail may start at button, x, or y; decoding + // it would fabricate a click or wheel direction. Drop unless all + // three framed fields are present and aligned. + if (lt and (c == 'M' or c == 'm')) { const body = bytes[j..k]; + if (std.mem.count(u8, body, ";") != 2 or !delimitedDecimal(body, false)) + return .{ .took = .ignore }; var it = std.mem.splitScalar(u8, body, ';'); - const btn = key.leadingInt(it.next() orelse "0"); - const x = key.leadingInt(it.next() orelse "1"); - const y = key.leadingInt(it.next() orelse "1"); + const btn = key.leadingInt(it.next().?); + const x = key.leadingInt(it.next().?); + const y = key.leadingInt(it.next().?); return .{ .took = .{ .mouse = .{ .btn = @intCast(@min(btn, 255)), .x = @intCast(@min(x, 999)), @@ -215,26 +510,38 @@ pub fn take(bytes: []const u8, i: *usize) Orphan { } return .{ .took = .ignore }; } + if (c == 0x1b and armed) { + i.* = k; + key.abandonSequence("", .none); + return .{ .took = .ignore }; + } return .none; } - // Ran off the end mid-fragment. Hold it when it already reads as a CSI - // parameter list, or when the loop armed us; a bare unarmed digit run is - // somebody typing, and holding it would strand the keystrokes. - return if (lt or sep or armed or lost_csi) .partial else .none; + // A bare unarmed digit run is somebody typing, not a partial sequence. + return if (lt or sep or armed) .partial else .none; } test "a join only happens when the tail really completes the head" { // The shapes run.zig drops mid-flight, rejoined. try std.testing.expect(completes("\x1b", "[<35;80;24M")); + try std.testing.expect(completes("\x1b", "[M !!")); + try std.testing.expect(completes("\x1b[M ", "!!")); try std.testing.expect(completes("\x1b", "[A")); try std.testing.expect(completes("\x1b", "[3~")); try std.testing.expect(completes("\x1b", "OA")); try std.testing.expect(completes("\x1b", "]11;rgb:14/14/14\x07")); + try std.testing.expect(completes("\x1b", "[200~payload")); try std.testing.expect(completes("\x1b[<35;80", ";24M")); try std.testing.expect(completes("\x1b[201", "~")); try std.testing.expect(completes("\x1b]11;rgb:1c", "1c/1c1c/1c1c\x07")); // ...and never onto a human who simply resumed typing. try std.testing.expect(!completes("\x1b", "hello")); + try std.testing.expect(!completes("\x1b", "[M")); + try std.testing.expect(!completes("\x1b", "[M \x1b[A")); + try std.testing.expect(!completes("\x1b", "[Alice]")); + try std.testing.expect(!completes("\x1b", "[Home]")); + try std.testing.expect(!completes("\x1b", "[Down]")); + try std.testing.expect(!completes("\x1b", "Orange")); try std.testing.expect(!completes("\x1b", "")); try std.testing.expect(!completes("\x1b[<35;80", "hello")); try std.testing.expect(!completes("\x1b[", "3")); // still incomplete diff --git a/TUI/key_recover.zig b/TUI/key_recover.zig new file mode 100644 index 00000000..fc85259e --- /dev/null +++ b/TUI/key_recover.zig @@ -0,0 +1,76 @@ +//! Decode a proven escape sequence split between a saved head and a full read. +//! Kept separate so key_orphan.zig remains below the 600-line ceiling. + +const key = @import("key.zig"); +const Key = key.Key; + +pub const Event = struct { event: Key, tail_len: usize }; +const max_body = 128; + +fn at(head: []const u8, tail: []const u8, i: usize) u8 { + return if (i < head.len) head[i] else tail[i - head.len]; +} + +fn combinedSlice(head: []const u8, tail: []const u8, start: usize, end: usize, out: *[max_body]u8) ?[]const u8 { + const len = end - start; + if (len > out.len) return null; + for (start..end, 0..) |i, j| out[j] = at(head, tail, i); + return out[0..len]; +} + +/// Decode one sequence key_orphan has already proved. Unsupported framed +/// strings become ignore, but their exact completing bytes are still consumed. +pub fn completed(head: []const u8, tail: []const u8) ?Event { + const n = head.len + tail.len; + switch (at(head, tail, 1)) { + 0x1b => return .{ .event = .escape, .tail_len = 0 }, + 'O' => { + if (head.len > 3) return null; + return .{ .event = key.decodeSs3(at(head, tail, 2)), .tail_len = 3 - head.len }; + }, + '[' => { + var final_at: usize = 2; + while (final_at < n and at(head, tail, final_at) >= 0x20 and at(head, tail, final_at) <= 0x3f) : (final_at += 1) {} + if (final_at >= n) return null; + const final = at(head, tail, final_at); + var event: Key = .ignore; + var event_end = final_at + 1; + if (final == 'M' and final_at == 2) { + event_end = 6; + var payload: [3]u8 = undefined; + for (3..6, 0..) |i, j| payload[j] = at(head, tail, i); + event = key.decodeX10(&payload); + } else { + var params_buf: [max_body]u8 = undefined; + if (combinedSlice(head, tail, 2, final_at, ¶ms_buf)) |params| + event = key.decodeCsi(params, final); + } + if (event_end < head.len) return null; + return .{ .event = event, .tail_len = event_end - head.len }; + }, + ']', 'P', '_', '^', 'X' => { + var body_end: usize = 2; + var event_end: usize = 0; + while (body_end < n) : (body_end += 1) { + const c = at(head, tail, body_end); + if (c == 0x07) { + event_end = body_end + 1; + break; + } + if (c == 0x1b and body_end + 1 < n and at(head, tail, body_end + 1) == '\\') { + event_end = body_end + 2; + break; + } + } + if (event_end == 0 or event_end < head.len) return null; + var event: Key = .ignore; + if (at(head, tail, 1) == ']') { + var body_buf: [max_body]u8 = undefined; + if (combinedSlice(head, tail, 2, body_end, &body_buf)) |body| + event = key.oscReply(']', body); + } + return .{ .event = event, .tail_len = event_end - head.len }; + }, + else => return null, + } +} diff --git a/TUI/key_tests.zig b/TUI/key_tests.zig index 9cbbb334..2148b447 100644 --- a/TUI/key_tests.zig +++ b/TUI/key_tests.zig @@ -193,14 +193,15 @@ test "OSC and APC replies on stdin are consumed, never typed" { try std.testing.expectEqual(@as(usize, 0), i); } -test "orphan SGR mouse is never inserted as letters" { +test "orphan SGR needs field-zero framing before it can become a mouse" { var i: usize = 0; + // A digit-led tail may begin at button, x, or y. Consume it as debris, but + // never fabricate a click or wheel direction from guessed alignment. const k = next("39;33;23M", &i).?; - try std.testing.expect(k == .mouse); - try std.testing.expectEqual(@as(u8, 39), k.mouse.btn); - try std.testing.expectEqual(@as(u16, 33), k.mouse.x); + try std.testing.expectEqual(Key.ignore, k); try std.testing.expectEqual(@as(usize, 9), i); i = 0; + // `<` proves this is field zero, so all three exact fields are actionable. const k2 = next("<64;4;8Mhi", &i).?; try std.testing.expect(k2 == .mouse); try std.testing.expectEqual(@as(u8, 64), k2.mouse.btn); @@ -363,3 +364,26 @@ test "next: wrap-less multiline burst is not Enter (#643)" { i = 0; try std.testing.expectEqual(Key.enter, next("\r", &i).?); } + +test "#545: 10+-digit CSI params saturate instead of aborting the TUI" { + try std.testing.expectEqual(std.math.maxInt(u32), key.leadingInt("99999999999999999999")); + try std.testing.expectEqual(std.math.maxInt(u32), key.leadingInt("9999999999")); + try std.testing.expectEqual(@as(u32, 4294967295), key.leadingInt("4294967295")); + try std.testing.expectEqual(@as(u32, 200), key.leadingInt("200~tail")); + key.resetInputState(); + defer key.resetInputState(); + const payloads = [_][]const u8{ + "\x1b[99999999999999999999;1u", + "\x1b[9999999999;1u", + "\x1b[<0;9999999999;5M", + "\x1b[200~hello \x1b[99999999999999999999;1u world\x1b[201~", + }; + for (payloads) |p| { + var i: usize = 0; + while (i < p.len) { + const before = i; + _ = next(p, &i); + if (i <= before) break; + } + } +} diff --git a/TUI/keys.zig b/TUI/keys.zig index 25640974..8c5a98a8 100644 --- a/TUI/keys.zig +++ b/TUI/keys.zig @@ -11,6 +11,7 @@ const layout_cache = @import("layout_cache.zig"); const selection = @import("selection.zig"); const engine = @import("engine.zig"); const key_mod = @import("key.zig"); +const pacing = @import("pacing.zig"); const theme_mod = @import("theme.zig"); const turn = @import("turn.zig"); const Key = key_mod.Key; @@ -19,31 +20,6 @@ const Effect = app.Effect; pub fn handle(self: *Model, k: Key) Effect { if (k == .ignore) return .stay; - // Anything that is not part of the drag gesture drops the selection band - // (#529). The OSC-11 polarity reply is the terminal talking, not the user. - if (k != .mouse and k != .bg_report) { - selection.clear(self); - // A key can only arrive with the button up: a gutter drag whose - // release went missing must not keep the pointer captured (click.zig). - self.click.gutter = false; - } - if (@import("nav.zig").handle(self, k)) |e| return e; - // Job-control wins overlays so Ctrl+C always does something. - if (isCtrl(k, 'c')) return ctrlC(self); - if (k == .undo or isCtrl(k, 'z')) { - if (self.input.undo()) self.setToast("undone"); - return .stay; - } - if (k == .bg_report) { - // Startup OSC 11 reply: adopt the terminal's polarity unless the user - // explicitly picked a theme. Fixed palettes, 1-bit decision (grok). - if (!self.theme_explicit) { - const want: @import("theme.zig").Id = if (@import("theme.zig").classifyLight(k.bg_report[0], k.bg_report[1], k.bg_report[2])) .day else .night; - self.theme_id = want; - } - return .stay; - } - if (k == .mouse) return mouseKey(self, k.mouse); if (k == .paste_start) { self.pasting = true; self.focus = .prompt; @@ -72,11 +48,39 @@ pub fn handle(self: *Model, k: Key) Effect { key_mod.endPaste(); self.setToast("paste ended"); }, + // Editor actions and non-newline controls are not paste text. + .tab, .backspace, .ctrl => {}, else => {}, } return .stay; } + // Anything that is not part of the drag gesture drops the selection band + // (#529). The OSC-11 polarity reply is the terminal talking, not the user. + if (k != .mouse and k != .bg_report) { + selection.clear(self); + // A key can only arrive with the button up: a gutter drag whose + // release went missing must not keep the pointer captured (click.zig). + self.click.gutter = false; + } + if (k == .bg_report) { + // Startup OSC 11 reply: adopt the terminal's polarity unless the user + // explicitly picked a theme. Fixed palettes, 1-bit decision (grok). + if (!self.theme_explicit) { + const want: @import("theme.zig").Id = if (@import("theme.zig").classifyLight(k.bg_report[0], k.bg_report[1], k.bg_report[2])) .day else .night; + self.theme_id = want; + } + return .stay; + } + if (k == .mouse) return mouseKey(self, k.mouse); + + if (@import("nav.zig").handle(self, k)) |e| return e; + // Job-control wins overlays so Ctrl+C always does something. + if (isCtrl(k, 'c')) return ctrlC(self); + if (k == .undo or isCtrl(k, 'z')) { + if (self.input.undo()) self.setToast("undone"); + return .stay; + } if (self.overlay != .none) return @import("overlays.zig").key(self, k); if (slashOpen(self) and slashKey(self, k)) return .stay; if (isChar(k, '@') and self.focus == .prompt and !slashOpen(self)) { @@ -240,11 +244,20 @@ fn scrollbackKey(self: *Model, k: Key) Effect { return .stay; } +/// Production batch dispatch shared with run.zig. Keeping the folded wheel on +/// this door prevents coalescing from bypassing paste-mode key handling. +pub fn handleBatchItem(self: *Model, item: pacing.Item) Effect { + return switch (item) { + .key => |k| handle(self, k), + .wheel => |notches| wheelScroll(self, notches), + }; +} + /// Apply `notches` of wheel scroll. One report and a whole coalesced momentum /// run go through the SAME door (pacing.zig folds consecutive reports into one /// delta), so a storm can never mean something a single report does not. pub fn wheelScroll(self: *Model, notches: i32) Effect { - if (notches == 0) return .stay; + if (self.pasting or notches == 0) return .stay; // An open picker or the completion menu owns the wheel: one notch is one // ITEM there, never a scroll of the transcript underneath. A folded batch // carries N notches, so replay it one item at a time. @@ -450,4 +463,5 @@ test { // The tests live next door (this file is at the line ceiling). Without this // reference they compile for nobody and silently never run. _ = @import("keys_tests.zig"); + _ = @import("issue_537_tests.zig"); } diff --git a/TUI/overlays.zig b/TUI/overlays.zig index a06dc180..c406409d 100644 --- a/TUI/overlays.zig +++ b/TUI/overlays.zig @@ -229,8 +229,11 @@ pub fn activate(self: *Model) Effect { var rows: [models.max_models]engine.ModelEntry = undefined; const n = models.filterModels(engine.g_model_entries, self.overlay_filter, &rows); const sel = if (n == 0) 0 else self.overlay_sel % n; + if (n == 0 or dispatch.refuseProviderMutation(self)) { + self.closeOverlay(); + return .stay; + } self.closeOverlay(); - if (n == 0) return .stay; // THE row the user chose, provider and all. Handing the engine the // name alone let it re-route by first-name-match, so picking the // openai row for a model codex also serves landed on codex. diff --git a/TUI/root.zig b/TUI/root.zig index 7919550e..1e330948 100644 --- a/TUI/root.zig +++ b/TUI/root.zig @@ -81,11 +81,13 @@ test { _ = @import("key_tests.zig"); _ = @import("spec_terminal_modes_conformance.zig"); _ = @import("key_loop_tests.zig"); + _ = @import("issue_537_reviewer_tests.zig"); _ = @import("input.zig"); _ = @import("keys.zig"); _ = @import("nav.zig"); _ = @import("image.zig"); _ = @import("turn.zig"); + _ = @import("turn_spawn_tests.zig"); _ = @import("bgop.zig"); _ = @import("welcome.zig"); _ = @import("glyphs.zig"); @@ -123,6 +125,7 @@ test { _ = @import("files.zig"); _ = @import("overlays.zig"); _ = run_mod; + _ = @import("run_tests.zig"); _ = @import("paint.zig"); _ = @import("paint_tests.zig"); _ = @import("scrollpaint.zig"); diff --git a/TUI/run.zig b/TUI/run.zig index afa919ec..0df1fe05 100644 --- a/TUI/run.zig +++ b/TUI/run.zig @@ -118,6 +118,7 @@ pub fn run( // When the last byte of input arrived — the rate side of the storm test. var last_input_ms: u64 = 0; var esc_stall: u8 = 0; + var esc_live = false; // operation state latched when a lone ESC arrives var zero_reads: u8 = 0; // Clock for the bracketed-paste latch only: refreshed by bytes that could // plausibly BE paste content, never by mouse-motion noise (see below). @@ -271,9 +272,8 @@ pub fn run( // turn and wiping the composer with no user keypress at // all. Carry it for a late tail, arm the sweeper for a // headless one, and never let it become a keystroke. - key_mod.stashOrphanHead(inbuf[0..pending_len]); + key_mod.abandonSequence(inbuf[0..pending_len], .dropped); stash_ms = m.now_ms; - key_mod.armOrphan(true); arm_ms = m.now_ms; pending_len = 0; esc_stall = 0; @@ -282,7 +282,7 @@ pub fn run( if (pending_len > 0) { esc_stall +|= 1; switch (stall.stallVerdict(inbuf[0..pending_len], esc_stall, .{ - .turn_live = m.pending != null, + .operation_live = esc_live or m.pending != null or m.bg != null, .in_paste = key_mod.inPaste(), })) { .wait => {}, @@ -292,8 +292,9 @@ pub fn run( // arrow / mouse report / OSC reply it always was // instead of spraying `[<35;80;24M` into the composer // (#530). - key_mod.stashOrphanHead(inbuf[0..pending_len]); + key_mod.abandonSequence(inbuf[0..pending_len], .escape); stash_ms = m.now_ms; + arm_ms = m.now_ms; pending_len = 0; esc_stall = 0; if (keys.handle(&m, .escape) == .quit) m.running = false; @@ -302,9 +303,8 @@ pub fn run( // A sequence the terminal never finished, waited out. // Carry the head so a late tail can still rejoin it, // and tell key.zig to expect orphan debris otherwise. - key_mod.stashOrphanHead(inbuf[0..pending_len]); + key_mod.abandonSequence(inbuf[0..pending_len], .dropped); stash_ms = m.now_ms; - key_mod.armOrphan(true); arm_ms = m.now_ms; pending_len = 0; esc_stall = 0; @@ -315,6 +315,12 @@ pub fn run( continue; } esc_stall = 0; + if (pending_len == inbuf.len) { + key_mod.abandonSequence(inbuf[0..pending_len], .dropped); + stash_ms = m.now_ms; + arm_ms = m.now_ms; + esc_live = false; + } pending_len = stall.clearFullWedge(pending_len, inbuf.len); var filled = pending_len; const got = tty.readStdin(inbuf[filled..]); @@ -351,13 +357,10 @@ pub fn run( // and a wheel storm is likewise all mouse reports, so it cannot hold a // broken paste open either. if (!stall.onlyMouseReports(inbuf[pending_len..filled])) last_paste_ms = m.now_ms; - // Spends any head the stall path carried: it is glued back on only - // when these bytes really complete it (key_orphan.zig), and only while - // the join can still plausibly be link jitter rather than a human - // resuming typing. The debris ARM is bounded on the same principle — - // left latched it ate the first token of whatever was typed next, at - // any later time. - if (stall.carryExpired(m.now_ms, stash_ms)) key_mod.stashOrphanHead(""); + // A genuine Escape's exact carry is short; a non-lone head actually + // dropped after its stall budget keeps its framing for the full arm + // interval. Either kind is still one-shot on the first new read. + if (stall.escapeCarryExpired(m.now_ms, stash_ms)) key_mod.expireOrphanHead(); if (stall.armExpired(m.now_ms, arm_ms)) key_mod.armOrphan(false); const n = key_mod.joinOrphanHead(&inbuf, filled); // Everything this tick drained is applied as ONE batch, with runs of @@ -377,13 +380,8 @@ pub fn run( if (batch.push(k) == .ok) continue; } else drained = true; for (batch.items()) |item| { - const effect = switch (item) { - .key => |k| keys.handle(&m, k), - .wheel => |d| blk: { - pacing.wheel_batches += 1; - break :blk keys.wheelScroll(&m, d); - }, - }; + if (item == .wheel) pacing.wheel_batches += 1; + const effect = keys.handleBatchItem(&m, item); switch (effect) { .stay => {}, .quit => { @@ -412,6 +410,8 @@ pub fn run( std.mem.copyForwards(u8, inbuf[0..rest], inbuf[i..n]); pending_len = rest; } else pending_len = 0; + // Capture before bgop/turn completion at the top of the next tick. + esc_live = stall.isLoneEscape(inbuf[0..pending_len]) and (m.pending != null or m.bg != null); } // Quitting with a turn still live: cancel FIRST — Ctrl+Q (nav.zig) and the // palette's /quit never did — then wait for the thread here, with the alt @@ -494,107 +494,3 @@ fn parkToShell(io: Io, w: *Io.Writer, raw: *tty.RawState) void { restore_mod.muteStderr(); // fullscreen again: stderr goes back to the log _ = io; } - -test "run loop enables click+hover tracking and bracketed paste" { - const src = @embedFile("run.zig"); - const mouse_on = [_]u8{ '?', '1', '0', '0', '0', 'h' }; - const sgr_on = [_]u8{ '?', '1', '0', '0', '6', 'h' }; - const paste_on = [_]u8{ '?', '2', '0', '0', '4', 'h' }; - // 1003 (motion) is back ON for image-chip hover previews. The v0.0.255 - // leak (raw SGR typed into the thinking line) stays pinned by key.zig's - // flood/orphan tests; the restore seq must pop it so the shell never - // inherits motion tracking. - const hover_on = [_]u8{ '?', '1', '0', '0', '3', 'h' }; - const hover_off = [_]u8{ '?', '1', '0', '0', '3', 'l' }; - const kitty_on = [_]u8{ '>', '1', '1', 'u' }; - const wrap_off = [_]u8{ '?', '7', 'l' }; - try std.testing.expect(std.mem.indexOf(u8, src, &mouse_on) != null); - try std.testing.expect(std.mem.indexOf(u8, src, &sgr_on) != null); - try std.testing.expect(std.mem.indexOf(u8, src, &paste_on) != null); - try std.testing.expect(std.mem.indexOf(u8, src, &hover_on) != null); - try std.testing.expect(std.mem.indexOf(u8, restore_mod.seq, &hover_off) != null); - try std.testing.expect(std.mem.indexOf(u8, src, &[_]u8{ '?', '1', '0', '0', '7', 'h' }) == null); - try std.testing.expect(std.mem.indexOf(u8, src, &kitty_on) != null); - try std.testing.expect(std.mem.indexOf(u8, src, &wrap_off) != null); - try std.testing.expect(std.mem.indexOf(u8, src, "a=d,d=A") != null); - // The idle paste sweep must DISCARD whatever was stuck mid-sequence before - // the stall path below can see it. Leaving it there let a lone pending ESC - // become the Escape KEY the instant `in_paste` cleared, cancelling a live - // turn and wiping the composer with no keypress at all. - const sweep_at = std.mem.indexOf(u8, src, "closePaste(&m);").?; - const stall_at = std.mem.indexOfPos(u8, src, sweep_at, "esc_stall +|= 1").?; - const sweep_block = src[sweep_at..stall_at]; - try std.testing.expect(std.mem.indexOf(u8, sweep_block, "pending_len = 0;") != null); - try std.testing.expect(std.mem.indexOf(u8, sweep_block, "armOrphan(true)") != null); - // Both unbounded holds are bounded: a resting mouse must not keep the paste - // latch alive, and the debris arm must go stale on its own clock. - try std.testing.expect(std.mem.indexOf(u8, src, "if (!stall.onlyMouseReports(") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "if (stall.armExpired(") != null); -} - -// The stall-verdict / carry-window / arm-window battery lives beside the -// policy it pins, in run_stall.zig; the frame painter's own battery (residue, -// glyph torture, row-style isolation, the self-heal) lives in paint.zig. - -test "one tick drains the whole tty, coalesces the wheel, and paints once" { - const src = @embedFile("run.zig"); - // (a) Every byte the tty already holds joins THIS tick before dispatch — - // one read per frame is what made momentum scrolling lag and then jump. - const read_at = std.mem.indexOf(u8, src, "const got = tty.readStdin(").?; - const dispatch_at = std.mem.indexOfPos(u8, src, read_at, "key_mod.next(inbuf[0..n]").?; - const drain = src[read_at..dispatch_at]; - try std.testing.expect(std.mem.indexOf(u8, drain, "while (filled < inbuf.len and tty.poll(0))") != null); - // ...bounded, or an endless flood would be drained and never painted. - try std.testing.expect(std.mem.indexOf(u8, drain, "pacing.drainExpired(") != null); - // (b) The batch is applied as one unit and the wheel run goes through the - // same door a single report does. - try std.testing.expect(std.mem.indexOf(u8, src, "var batch: pacing.Batch") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "keys.wheelScroll(&m, d)") != null); - // (c) The frame is gated on the budget, and the gate sits BEFORE the render - // — gating only the paint would still pay for composing every frame. - const gate_at = std.mem.indexOf(u8, src, "pacing.shouldPaint(").?; - try std.testing.expect(gate_at < std.mem.indexOf(u8, src, "render_mod.render(&m, gpa, cols, rows").?); - // ...and the storm signal is a non-blocking poll OR the arrival RATE, so a - // fast loop that reads each report the instant it lands still paces, and a - // quiet one never waits on the budget for a single flick. - try std.testing.expect(std.mem.indexOf(u8, src, "const more_pending = tty.poll(0);") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "last_input_ms = m.now_ms;") != null); - // A deferred frame comes back through the poll timeout the loop already - // has, and only when one is actually owed. - try std.testing.expect(std.mem.indexOf(u8, src, "if (!painted and pending_len == 0) wait = pacing.waitCap(") != null); -} - -test "a wheel storm cannot be mistaken for paste activity" { - // The drained buffer is handed to onlyMouseReports whole: a storm is all - // complete SGR reports, so it never refreshes the paste clock, and a read - // carrying one real keystroke still does. - var buf: [512]u8 = undefined; - var n: usize = 0; - while (n + 10 <= 400) : (n += 10) @memcpy(buf[n .. n + 10], "\x1b[<65;4;4M"); - try std.testing.expect(stall.onlyMouseReports(buf[0..n])); - buf[n] = 'k'; - try std.testing.expect(!stall.onlyMouseReports(buf[0 .. n + 1])); -} - -test "the loop self-heals: a resize EVENT and a periodic sweep force a repaint" { - const src = @embedFile("run.zig"); - // A SIGWINCH that starts and ends on the same dimensions is invisible to a - // dimension comparison, and one that lands between tty.cols() and the - // paint leaves the diff baseline describing a screen the terminal has - // already reflowed. Both are covered by the EVENT. - try std.testing.expect(std.mem.indexOf(u8, src, "restore_mod.takeResized()") != null); - // ...and anything else that writes over us (an async kitty image delete, - // a terminal-side redraw) is repaired on the heartbeat, which must be able - // to run even when the frame hash has not moved. - try std.testing.expect(std.mem.indexOf(u8, src, "hash != last_hash or heal") != null); - try std.testing.expect(heal_interval_ms > 0); - // ...and the self-heal must never be served by the scroll fast path, whose - // whole point is to SKIP rows that are already correct — which is exactly - // the set of rows a heal exists to rewrite. Same for `full`, which folds in - // kitty graphics (pixels do not move when cells scroll), resize and theme. - try std.testing.expect(std.mem.indexOf(u8, src, "if (full or heal) null else m.paint_hint") != null); - // Theme bg is painted per row, not baked into the frame. Blank rows are - // byte-identical across themes, so a diff paint would strand the old - // canvas — /theme and the startup OSC-11 flip both force a full paint. - try std.testing.expect(std.mem.indexOf(u8, src, "m.theme_id != prev_theme") != null); -} diff --git a/TUI/run_stall.zig b/TUI/run_stall.zig index 3839be68..0c5642f1 100644 --- a/TUI/run_stall.zig +++ b/TUI/run_stall.zig @@ -12,19 +12,21 @@ const std = @import("std"); pub const StallVerdict = enum { wait, escape_key, drop }; pub const StallCtx = struct { - /// A turn is streaming. A phantom Escape here CANCELS it, and this is - /// precisely when the 1003 motion flood makes a split sequence likely, so - /// the lone-ESC grace stretches. Idle, #94's snappy Escape is untouched. - turn_live: bool = false, + /// A model turn or background operation was live when this ESC head + /// arrived. The read loop latches that fact: a fast completion between + /// quiet polls must not shorten the head's ambiguity window. + operation_live: bool = false, /// Inside a bracketed paste: a lone ESC is far more likely to be the head /// of the closing `CSI 201~` than the Escape key, and giving up on that /// marker wedges the composer. in_paste: bool = false, }; -/// ~25ms a stall: 2 polls idle, 8 (~200ms) while a turn streams. -const esc_grace_idle: u8 = 2; -const esc_grace_live: u8 = 8; +/// ~25ms a stall. Twelve idle polls (~300ms) cover the reported 50–250ms +/// splits. A live operation waits the full bounded dropped-head recovery +/// window: cancelling sooner is irreversible if the bytes become `CSI 200~`. +const esc_grace_idle: u8 = 12; +pub const live_escape_stalls: u8 = 40; // 1s; Ctrl-C and CSI-u Esc bypass it /// A paste marker is worth ~2s before we conclude it is never coming. const paste_marker_stalls: u8 = 80; /// ...but a LONE ESC inside that window gets ~300ms, not the full 2s: see @@ -32,17 +34,17 @@ const paste_marker_stalls: u8 = 80; const esc_grace_paste: u8 = 12; /// Input silence that ends a bracketed paste nothing else can close. pub const paste_idle_ms: u64 = 2000; -/// How long a given-up head stays eligible to rejoin its tail. A sequence cut -/// by ssh/tmux jitter finishes within a few hundred ms of the give-up; past -/// that the next bytes are a human typing, and `\x1b[` + `Hello` would eat the -/// H (`CSI H` is a legal Home). -const carry_window_ms: u64 = 400; +/// Short exact-carry window after a lone ESC was delivered as a genuine key. +/// That ambiguity must not let the ESC glue itself onto human input for the +/// whole recovery interval. A non-lone sequence actually dropped after its +/// stall budget is stronger evidence: key_orphan retains and accumulates that +/// exact framing until `arm_window_ms`, bounded by its small head buffer. +const escape_carry_window_ms: u64 = 400; /// How long the orphan-debris sweeper stays armed. The arm says "the loop /// really did drop a head just now", and that claim goes stale: latched with no /// clock at all it ate the first token of whatever the user typed NEXT, at any -/// later time (`3u apples` -> ` apples` twelve seconds after the drop). Slightly -/// longer than the carry window, because a `.partial` fragment legitimately -/// spans a read boundary or two before it completes. +/// later time (`3u apples` -> ` apples` twelve seconds after the drop). This is +/// also the full exact-head interval for a dropped non-lone sequence. const arm_window_ms: u64 = 1000; /// What to do with input bytes stuck mid-sequence after quiet polls (~25ms @@ -53,7 +55,7 @@ const arm_window_ms: u64 = 1000; /// only silently drop once it is clearly never coming. pub fn stallVerdict(pending: []const u8, stalls: u8, ctx: StallCtx) StallVerdict { if (pending.len == 0) return .wait; - const lone_esc = pending.len == 1 and pending[0] == 0x1b; + const lone_esc = isLoneEscape(pending); if (ctx.in_paste and isPasteMarkerPrefix(pending)) { // ESC is a proper prefix of `CSI 201~`, so the marker budget also // swallowed the ONE in-band way out of a wedged paste for ~2s on every @@ -62,24 +64,29 @@ pub fn stallVerdict(pending: []const u8, stalls: u8, ctx: StallCtx) StallVerdict // there through several quiet polls without growing a body is the user // reaching for that hatch, not a terminator in flight; an ESC with // bytes behind it still gets the full marker wait. - if (lone_esc and stalls >= esc_grace_paste) return .escape_key; + const grace = if (ctx.operation_live) live_escape_stalls else esc_grace_paste; + if (lone_esc and stalls >= grace) return .escape_key; return if (stalls >= paste_marker_stalls) .drop else .wait; } if (lone_esc) { - const grace = if (ctx.turn_live) esc_grace_live else esc_grace_idle; + const grace = if (ctx.operation_live) live_escape_stalls else esc_grace_idle; return if (stalls >= grace) .escape_key else .wait; } return if (stalls >= 20) .drop else .wait; } +pub fn isLoneEscape(pending: []const u8) bool { + return pending.len == 1 and pending[0] == 0x1b; +} + /// A proper prefix of either bracketed-paste marker. pub fn isPasteMarkerPrefix(pending: []const u8) bool { return std.mem.startsWith(u8, "\x1b[201~", pending) or std.mem.startsWith(u8, "\x1b[200~", pending); } -pub fn carryExpired(now_ms: u64, stash_ms: u64) bool { - return now_ms -| stash_ms > carry_window_ms; +pub fn escapeCarryExpired(now_ms: u64, stash_ms: u64) bool { + return now_ms -| stash_ms > escape_carry_window_ms; } pub fn armExpired(now_ms: u64, arm_ms: u64) bool { @@ -92,20 +99,26 @@ pub fn clearFullWedge(pending_len: usize, buf_len: usize) usize { return if (pending_len == buf_len) 0 else pending_len; } -/// Is this read nothing but complete SGR mouse reports? +/// Is this read nothing but complete SGR or X10 mouse reports? /// /// ?1003h is on by default for image-chip hover, and a pointer merely RESTING /// over the window makes the terminal emit a motion report roughly twice a /// second. Those bytes are not paste content and must not count as paste /// activity: while they did, a mouse sitting still over the terminal postponed /// the #548 idle recovery indefinitely and an unterminated paste NEVER -/// released. Deliberately strict — only whole `CSI < params M|m` reports, so a -/// read that carries any real pasted byte keeps the latch alive. +/// released. Deliberately strict — only whole SGR or three-byte-body X10 +/// reports, so a read carrying any real pasted byte keeps the latch alive. pub fn onlyMouseReports(bytes: []const u8) bool { if (bytes.len == 0) return false; var i: usize = 0; while (i < bytes.len) { - if (i + 3 > bytes.len or bytes[i] != 0x1b or bytes[i + 1] != '[' or bytes[i + 2] != '<') return false; + if (i + 3 > bytes.len or bytes[i] != 0x1b or bytes[i + 1] != '[') return false; + if (bytes[i + 2] == 'M') { + if (i + 6 > bytes.len or std.mem.indexOfScalar(u8, bytes[i + 3 .. i + 6], 0x1b) != null) return false; + i += 6; + continue; + } + if (bytes[i + 2] != '<') return false; var j = i + 3; while (j < bytes.len and ((bytes[j] >= '0' and bytes[j] <= '9') or bytes[j] == ';')) : (j += 1) {} if (j >= bytes.len or (bytes[j] != 'M' and bytes[j] != 'm')) return false; @@ -116,8 +129,8 @@ pub fn onlyMouseReports(bytes: []const u8) bool { test "a truncated CSI never becomes Escape or typed debris; a lone ESC still does (#94)" { const idle: StallCtx = .{}; - try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 1, idle)); - try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 2, idle)); + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 11, idle)); + try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 12, idle)); // Split SGR mouse / kitty CSI-u: never Escape, wait for the tail... try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[<65;2;3", 2, idle)); try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[5744", 19, idle)); @@ -130,26 +143,43 @@ test "a truncated CSI never becomes Escape or typed debris; a lone ESC still doe try std.testing.expectEqual(StallVerdict.wait, stallVerdict("", 5, idle)); } -test "the lone-ESC grace stretches while a turn streams (#530)" { - // ssh/tmux jitter during a 1003 motion flood cuts right after an ESC. At - // 2 polls that became a phantom Escape and cancelled the live turn; the - // body then typed itself. Idle, #94's latency is unchanged. - const live: StallCtx = .{ .turn_live = true }; - try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 2, live)); - try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 7, live)); - try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 8, live)); - try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 2, .{})); -} - -test "the carried head expires before it can reach a human keystroke (#530)" { - // A tail split off by link jitter lands within a few hundred ms of the - // give-up; anything later is somebody typing and must arrive untouched. - try std.testing.expect(!carryExpired(1000, 1000)); - try std.testing.expect(!carryExpired(1400, 1000)); - try std.testing.expect(carryExpired(1401, 1000)); - try std.testing.expect(carryExpired(9000, 1000)); +test "bounded lone-ESC grace covers splits without preempting live recovery (#537)" { + // Idle Escape remains ~300ms; a live call gets the full documented 1s. + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 2, .{})); + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 10, .{})); + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 11, .{})); + try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 12, .{})); + const live: StallCtx = .{ .operation_live = true }; + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 16, live)); + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", live_escape_stalls - 1, live)); + try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", live_escape_stalls, live)); +} + +test "a late body stays a sequence, never a second Escape decision (#537)" { + // Once a head has a body, the pure policy never classifies it as the + // Escape key. This is the policy half of the orphan join: run.zig/key.zig + // may carry the head until its tail arrives, but a late CSI body cannot + // cancel a turn or become typed debris through this decision point. + const live: StallCtx = .{ .operation_live = true }; + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[", 2, live)); + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[", 8, live)); + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[<35;80;24", 19, live)); + try std.testing.expectEqual(StallVerdict.drop, stallVerdict("\x1b[<35;80;24", 20, live)); + // The same invariant holds while an operation is cancellable in the + // background, and while idle: only a lone ESC can become Escape. + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[", 2, .{ .operation_live = true })); + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[", 2, .{})); +} + +test "the genuine-Escape carry expires before it can reach a human keystroke (#530)" { + // A delivered Escape is weak evidence: its exact carry stays short even + // though narrow, self-identifying recovery remains armed for one second. + try std.testing.expect(!escapeCarryExpired(1000, 1000)); + try std.testing.expect(!escapeCarryExpired(1400, 1000)); + try std.testing.expect(escapeCarryExpired(1401, 1000)); + try std.testing.expect(escapeCarryExpired(9000, 1000)); // A clock that never ran (no stash yet) is expired, not live. - try std.testing.expect(carryExpired(100_000, 0)); + try std.testing.expect(escapeCarryExpired(100_000, 0)); } test "the debris arm expires too, so it can never eat a later keystroke" { @@ -160,8 +190,8 @@ test "the debris arm expires too, so it can never eat a later keystroke" { try std.testing.expect(!armExpired(2000, 1000)); try std.testing.expect(armExpired(2001, 1000)); try std.testing.expect(armExpired(13_000, 1000)); - // Outlives the carry window: a `.partial` fragment may span a read or two. - try std.testing.expect(carryExpired(1600, 1000) and !armExpired(1600, 1000)); + // Outlives the genuine-Escape carry; dropped exact framing remains live. + try std.testing.expect(escapeCarryExpired(1600, 1000) and !armExpired(1600, 1000)); } test "a paste marker is never abandoned on the #94 timescale (#532)" { @@ -185,28 +215,31 @@ test "a resting mouse is not paste activity (#548 starvation)" { try std.testing.expect(onlyMouseReports("\x1b[<35;80;24M")); try std.testing.expect(onlyMouseReports("\x1b[<35;80;24M\x1b[<35;80;24M")); try std.testing.expect(onlyMouseReports("\x1b[<0;4;9m")); + try std.testing.expect(onlyMouseReports("\x1b[M !!\x1b[M#%%")); // Anything that could be paste content keeps the latch's clock running. try std.testing.expect(!onlyMouseReports("")); try std.testing.expect(!onlyMouseReports("hello")); try std.testing.expect(!onlyMouseReports("\x1b[<35;80;24Mhello")); try std.testing.expect(!onlyMouseReports("hello\x1b[<35;80;24M")); try std.testing.expect(!onlyMouseReports("\x1b[<35;80;24")); // split: not complete + try std.testing.expect(!onlyMouseReports("\x1b[M !")); + try std.testing.expect(!onlyMouseReports("\x1b[M \x1b[201~")); try std.testing.expect(!onlyMouseReports("\x1b[201~")); try std.testing.expect(!onlyMouseReports("\x1b[A")); } -test "a lone ESC inside a latched paste is the escape hatch, not a 2s wait" { - // On a non-kitty terminal Escape IS `\x1b`, which is a prefix of the - // terminator — so the hatch out of a wedged paste was dead for ~2s. +test "a lone ESC inside a latched paste stays bounded" { + // Idle paste hatch: ~300ms. With live work, marker ambiguity wins for 1s. const p: StallCtx = .{ .in_paste = true }; try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", 11, p)); try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 12, p)); - try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 79, p)); + const live: StallCtx = .{ .in_paste = true, .operation_live = true }; + try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b", live_escape_stalls - 1, live)); + try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", live_escape_stalls, live)); // An ESC with a body behind it is still a terminator in flight: full wait. try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[", 12, p)); try std.testing.expectEqual(StallVerdict.wait, stallVerdict("\x1b[201", 79, p)); - // Outside a paste nothing moved: #94's 2-stall Escape still fires. - try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 2, .{})); + try std.testing.expectEqual(StallVerdict.escape_key, stallVerdict("\x1b", 12, .{})); } test "a buffer-filling parser wedge is dropped, not treated as hangup (#517)" { diff --git a/TUI/run_tests.zig b/TUI/run_tests.zig new file mode 100644 index 00000000..e36a0fb6 --- /dev/null +++ b/TUI/run_tests.zig @@ -0,0 +1,114 @@ +//! run.zig runtime-loop contract tests. + +const std = @import("std"); + +const restore_mod = @import("restore.zig"); +const run = @import("run.zig"); +const stall = @import("run_stall.zig"); +const heal_interval_ms = run.heal_interval_ms; + +test "run loop enables click+hover tracking and bracketed paste" { + const src = @embedFile("run.zig"); + const mouse_on = [_]u8{ '?', '1', '0', '0', '0', 'h' }; + const sgr_on = [_]u8{ '?', '1', '0', '0', '6', 'h' }; + const paste_on = [_]u8{ '?', '2', '0', '0', '4', 'h' }; + // 1003 (motion) is back ON for image-chip hover previews. The v0.0.255 + // leak (raw SGR typed into the thinking line) stays pinned by key.zig's + // flood/orphan tests; the restore seq must pop it so the shell never + // inherits motion tracking. + const hover_on = [_]u8{ '?', '1', '0', '0', '3', 'h' }; + const hover_off = [_]u8{ '?', '1', '0', '0', '3', 'l' }; + const kitty_on = [_]u8{ '>', '1', '1', 'u' }; + const wrap_off = [_]u8{ '?', '7', 'l' }; + try std.testing.expect(std.mem.indexOf(u8, src, &mouse_on) != null); + try std.testing.expect(std.mem.indexOf(u8, src, &sgr_on) != null); + try std.testing.expect(std.mem.indexOf(u8, src, &paste_on) != null); + try std.testing.expect(std.mem.indexOf(u8, src, &hover_on) != null); + try std.testing.expect(std.mem.indexOf(u8, restore_mod.seq, &hover_off) != null); + try std.testing.expect(std.mem.indexOf(u8, src, &[_]u8{ '?', '1', '0', '0', '7', 'h' }) == null); + try std.testing.expect(std.mem.indexOf(u8, src, &kitty_on) != null); + try std.testing.expect(std.mem.indexOf(u8, src, &wrap_off) != null); + try std.testing.expect(std.mem.indexOf(u8, src, "a=d,d=A") != null); + // The idle paste sweep must DISCARD whatever was stuck mid-sequence before + // the stall path below can see it. Leaving it there let a lone pending ESC + // become the Escape KEY the instant `in_paste` cleared, cancelling a live + // turn and wiping the composer with no keypress at all. + const sweep_at = std.mem.indexOf(u8, src, "closePaste(&m);").?; + const stall_at = std.mem.indexOfPos(u8, src, sweep_at, "esc_stall +|= 1").?; + const sweep_block = src[sweep_at..stall_at]; + try std.testing.expect(std.mem.indexOf(u8, sweep_block, "pending_len = 0;") != null); + try std.testing.expect(std.mem.indexOf(u8, sweep_block, ".dropped") != null); + // Both unbounded holds are bounded: a resting mouse must not keep the paste + // latch alive, and the debris arm must go stale on its own clock. + try std.testing.expect(std.mem.indexOf(u8, src, "if (!stall.onlyMouseReports(") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "if (stall.armExpired(") != null); + const drop_at = std.mem.indexOf(u8, src, ".drop => {").?; + try std.testing.expect(std.mem.indexOfPos(u8, src, drop_at, "key_mod.abandonSequence(") != null); +} + +// The stall-verdict / carry-window / arm-window battery lives beside the +// policy it pins, in run_stall.zig; the frame painter's own battery (residue, +// glyph torture, row-style isolation, the self-heal) lives in paint.zig. + +test "one tick drains the whole tty, coalesces the wheel, and paints once" { + const src = @embedFile("run.zig"); + // (a) Every byte the tty already holds joins THIS tick before dispatch — + // one read per frame is what made momentum scrolling lag and then jump. + const read_at = std.mem.indexOf(u8, src, "const got = tty.readStdin(").?; + const dispatch_at = std.mem.indexOfPos(u8, src, read_at, "key_mod.next(inbuf[0..n]").?; + const drain = src[read_at..dispatch_at]; + try std.testing.expect(std.mem.indexOf(u8, drain, "while (filled < inbuf.len and tty.poll(0))") != null); + // ...bounded, or an endless flood would be drained and never painted. + try std.testing.expect(std.mem.indexOf(u8, drain, "pacing.drainExpired(") != null); + // (b) The batch is applied as one unit and the wheel run goes through the + // same door a single report does. + try std.testing.expect(std.mem.indexOf(u8, src, "var batch: pacing.Batch") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "keys.handleBatchItem(&m, item)") != null); + // (c) The frame is gated on the budget, and the gate sits BEFORE the render + // — gating only the paint would still pay for composing every frame. + const gate_at = std.mem.indexOf(u8, src, "pacing.shouldPaint(").?; + try std.testing.expect(gate_at < std.mem.indexOf(u8, src, "render_mod.render(&m, gpa, cols, rows").?); + // ...and the storm signal is a non-blocking poll OR the arrival RATE, so a + // fast loop that reads each report the instant it lands still paces, and a + // quiet one never waits on the budget for a single flick. + try std.testing.expect(std.mem.indexOf(u8, src, "const more_pending = tty.poll(0);") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "last_input_ms = m.now_ms;") != null); + // A deferred frame comes back through the poll timeout the loop already + // has, and only when one is actually owed. + try std.testing.expect(std.mem.indexOf(u8, src, "if (!painted and pending_len == 0) wait = pacing.waitCap(") != null); +} + +test "a wheel storm cannot be mistaken for paste activity" { + // The drained buffer is handed to onlyMouseReports whole: a storm is all + // complete SGR reports, so it never refreshes the paste clock, and a read + // carrying one real keystroke still does. + var buf: [512]u8 = undefined; + var n: usize = 0; + while (n + 10 <= 400) : (n += 10) @memcpy(buf[n .. n + 10], "\x1b[<65;4;4M"); + try std.testing.expect(stall.onlyMouseReports(buf[0..n])); + buf[n] = 'k'; + try std.testing.expect(!stall.onlyMouseReports(buf[0 .. n + 1])); +} + +test "the loop self-heals: a resize EVENT and a periodic sweep force a repaint" { + const src = @embedFile("run.zig"); + // A SIGWINCH that starts and ends on the same dimensions is invisible to a + // dimension comparison, and one that lands between tty.cols() and the + // paint leaves the diff baseline describing a screen the terminal has + // already reflowed. Both are covered by the EVENT. + try std.testing.expect(std.mem.indexOf(u8, src, "restore_mod.takeResized()") != null); + // ...and anything else that writes over us (an async kitty image delete, + // a terminal-side redraw) is repaired on the heartbeat, which must be able + // to run even when the frame hash has not moved. + try std.testing.expect(std.mem.indexOf(u8, src, "hash != last_hash or heal") != null); + try std.testing.expect(heal_interval_ms > 0); + // ...and the self-heal must never be served by the scroll fast path, whose + // whole point is to SKIP rows that are already correct — which is exactly + // the set of rows a heal exists to rewrite. Same for `full`, which folds in + // kitty graphics (pixels do not move when cells scroll), resize and theme. + try std.testing.expect(std.mem.indexOf(u8, src, "if (full or heal) null else m.paint_hint") != null); + // Theme bg is painted per row, not baked into the frame. Blank rows are + // byte-identical across themes, so a diff paint would strand the old + // canvas — /theme and the startup OSC-11 flip both force a full paint. + try std.testing.expect(std.mem.indexOf(u8, src, "m.theme_id != prev_theme") != null); +} diff --git a/TUI/sim.zig b/TUI/sim.zig index 5d1463af..c96cba69 100644 --- a/TUI/sim.zig +++ b/TUI/sim.zig @@ -24,6 +24,7 @@ const engine_mod = @import("engine.zig"); const key_mod = @import("key.zig"); const keys = @import("keys.zig"); const render_mod = @import("render.zig"); +const stall = @import("run_stall.zig"); const theme_mod = @import("theme.zig"); const turn = @import("turn.zig"); @@ -40,8 +41,12 @@ pub const Term = struct { rows: usize = 24, now_ms: u64 = 0, last_effect: Effect = .stay, - inbuf: [4096]u8 = undefined, + inbuf: [16 * 1024]u8 = undefined, pending: usize = 0, + esc_stall: u8 = 0, + esc_live: bool = false, + stash_ms: u64 = 0, + arm_ms: u64 = 0, pub fn init(self: *Term, alloc: std.mem.Allocator, cols: usize, rows: usize) void { self.alloc = alloc; @@ -50,6 +55,10 @@ pub const Term = struct { self.now_ms = 0; self.last_effect = .stay; self.pending = 0; + self.esc_stall = 0; + self.esc_live = false; + self.stash_ms = 0; + self.arm_ms = 0; key_mod.resetInputState(); self.model.setup(alloc); } @@ -58,15 +67,30 @@ pub const Term = struct { self.model.deinit(); } - /// Raw Ghostty/xterm bytes. An incomplete sequence is CARRIED into the next - /// feed, exactly like run.zig's pending buffer — dropping it here let a - /// split CSI vanish, which is the one thing these harness tests exist to - /// catch. + /// Raw Ghostty/xterm bytes. Incomplete sequences carry between bounded + /// chunks exactly like repeated production reads. A non-stay effect stops + /// this synchronous call: bytes not yet supplied to the parser are + /// discarded because Term has no tty queue; any parser tail already copied + /// into `inbuf` remains pending for the next feed. pub fn feed(self: *Term, bytes: []const u8) Effect { - const room = self.inbuf.len - self.pending; - const take = @min(bytes.len, room); - @memcpy(self.inbuf[self.pending .. self.pending + take], bytes[0..take]); - const n = key_mod.joinOrphanHead(&self.inbuf, self.pending + take); + if (bytes.len == 0) return self.last_effect; + var offset: usize = 0; + while (offset < bytes.len) { + if (self.pending == self.inbuf.len) self.abandonPending(.dropped); + const take = @min(bytes.len - offset, self.inbuf.len - self.pending); + self.feedChunk(bytes[offset .. offset + take]); + if (self.last_effect != .stay) return self.last_effect; + offset += take; + } + return self.last_effect; + } + + fn feedChunk(self: *Term, bytes: []const u8) void { + self.esc_stall = 0; + if (stall.escapeCarryExpired(self.now_ms, self.stash_ms)) key_mod.expireOrphanHead(); + if (stall.armExpired(self.now_ms, self.arm_ms)) key_mod.armOrphan(false); + @memcpy(self.inbuf[self.pending .. self.pending + bytes.len], bytes); + const n = key_mod.joinOrphanHead(&self.inbuf, self.pending + bytes.len); var i: usize = 0; var last: Effect = .stay; while (key_mod.next(self.inbuf[0..n], &i)) |k| { @@ -78,8 +102,27 @@ pub const Term = struct { std.mem.copyForwards(u8, self.inbuf[0..rest], self.inbuf[i..n]); break :blk rest; } else 0; + // Latch before a fast operation can complete on a later quiet tick. + self.esc_live = stall.isLoneEscape(self.inbuf[0..self.pending]) and (self.model.pending != null or self.model.bg != null); self.last_effect = last; - return last; + } + + /// One deterministic ~25ms quiet-poll tick from run.zig. + pub fn stallTimeout(self: *Term) stall.StallVerdict { + self.esc_stall +|= 1; + const verdict = stall.stallVerdict(self.inbuf[0..self.pending], self.esc_stall, .{ + .operation_live = self.esc_live or self.model.pending != null or self.model.bg != null, + .in_paste = key_mod.inPaste(), + }); + switch (verdict) { + .wait => {}, + .escape_key => { + self.abandonPending(.escape); + self.last_effect = keys.handle(&self.model, .escape); + }, + .drop => self.stallDropPending(), + } + return verdict; } /// The live loop gives up on a pending sequence that never finished: it @@ -88,9 +131,7 @@ pub const Term = struct { /// the terminal never sent (run.zig's stall path). pub fn stallDropPending(self: *Term) void { if (self.pending == 0) return; - key_mod.stashOrphanHead(self.inbuf[0..self.pending]); - self.pending = 0; - key_mod.armOrphan(true); + self.abandonPending(.dropped); if (key_mod.inPaste()) { key_mod.endPaste(); _ = keys.handle(&self.model, .paste_end); @@ -107,9 +148,16 @@ pub const Term = struct { key_mod.endPaste(); _ = keys.handle(&self.model, .paste_end); if (self.pending == 0) return; - key_mod.stashOrphanHead(self.inbuf[0..self.pending]); + self.abandonPending(.dropped); + } + + fn abandonPending(self: *Term, recovery: key_mod.SequenceRecovery) void { + key_mod.abandonSequence(self.inbuf[0..self.pending], recovery); + self.stash_ms = self.now_ms; + self.arm_ms = self.now_ms; self.pending = 0; - key_mod.armOrphan(true); + self.esc_stall = 0; + self.esc_live = false; } pub fn press(self: *Term, k: Key) Effect { @@ -421,6 +469,18 @@ test "annotated dump prefixes 1-based rows" { try std.testing.expect(std.mem.indexOf(u8, ann, "abc") != null); } +test "a dropped sequence does not turn bare Backspace into delete-to-start" { + var term: Term = undefined; + term.init(std.testing.allocator, 80, 24); + defer term.deinit(); + _ = term.typeText("draft"); + key_mod.held = 8; // a lost Super release leaves the old latch behind + _ = term.feed("\x1b[57444;1:3"); + term.stallDropPending(); + _ = term.feed("\x7f"); + try std.testing.expectEqualStrings("draf", term.model.input.getValue()); +} + test "a paste that never terminates does not wedge the composer (#536/#548)" { var term: Term = undefined; term.init(std.testing.allocator, 80, 24); @@ -447,8 +507,10 @@ test "giving up on a split paste terminator closes the paste, tail and all (#532 _ = term.feed("\x1b[200~hello"); _ = term.feed("\x1b[201"); try std.testing.expect(key_mod.inPaste()); + key_mod.held = 8; term.stallDropPending(); // the loop waited the marker out try std.testing.expect(!key_mod.inPaste()); + try std.testing.expectEqual(@as(u32, 0), key_mod.held); try std.testing.expect(!term.model.pasting); // The late `~` rejoins its carried head instead of typing itself. _ = term.feed("~"); @@ -487,8 +549,10 @@ test "the idle paste sweep never fires a phantom Escape at a live turn" { // Escape KEY the instant `in_paste` cleared, cancelling the live turn at // t=2.03s with no user keypress at all — and the same path wiped a // composer that had a draft in it. + key_mod.held = 8; term.idlePasteSweep(); try std.testing.expect(!key_mod.inPaste()); + try std.testing.expectEqual(@as(u32, 0), key_mod.held); try std.testing.expect(!term.model.pasting); try std.testing.expect(!term.model.cancel_requested); try std.testing.expectEqual(@as(usize, 0), term.pending); diff --git a/TUI/turn.zig b/TUI/turn.zig index ef68b224..76b03598 100644 --- a/TUI/turn.zig +++ b/TUI/turn.zig @@ -7,7 +7,6 @@ const app = @import("app.zig"); const engine = @import("engine.zig"); const Model = app.Model; const Effect = app.Effect; - /// What the session is currently asking the engine to do. Shared by a model /// turn and by `!cmd` (bgop), because both run under the SAME policy — a /plan /// that only reached one of them is the #551 bug in miniature. @@ -22,7 +21,6 @@ pub fn paramsOf(self: *const Model) engine.Params { .goal = self.goal orelse "", }; } - pub fn startJob(self: *Model) void { var turns = std.array_list.Managed(engine.Turn).init(self.alloc); for (self.history.items) |e| { @@ -61,14 +59,16 @@ pub fn startJob(self: *Model) void { job.events.attach(self.alloc); self.push(.pending, "") catch {}; self.pending = job; - if (std.Thread.spawn(.{}, engine.jobRun, .{job})) |th| { + if (engine.spawnJob(job)) |th| { job.thread = th; } else |_| { + // Never move provider, tool, or automatic-compaction work onto the + // render/input thread. The normal finish path reports and reaps it. job.threaded = false; - engine.jobRun(job); + job.start_failed = true; + job.done.store(true, .release); } } - /// grok-build notify: a finished background job starts a turn while idle. pub fn maybeJobWake(self: *Model) void { if (self.pending != null or self.bg != null) return; @@ -78,18 +78,19 @@ pub fn maybeJobWake(self: *Model) void { self.push(.user, text) catch return; startJob(self); } - pub fn finishJob(self: *Model) void { const job = self.pending orelse return; if (!job.done.load(.acquire)) return; - if (job.threaded) job.thread.join(); + engine.joinJob(job); // Whatever the engine emitted after the last frame — the tail of the tool // run, a closing notice — before the answer row goes in, so the transcript // keeps its order. drainEvents(self); _ = removePendingRows(self); - if (job.result) |r| { + if (job.start_failed) { + self.push(.err, "model turn failed to start — retry your prompt") catch {}; + } else if (job.result) |r| { self.push(.assistant, r) catch {}; self.alloc.free(r); } else if (self.cancel_requested) { diff --git a/TUI/turn_spawn_tests.zig b/TUI/turn_spawn_tests.zig new file mode 100644 index 00000000..e5b11923 --- /dev/null +++ b/TUI/turn_spawn_tests.zig @@ -0,0 +1,142 @@ +//! Spawn-failure coverage for every fullscreen-TUI turn entry path. + +const std = @import("std"); + +const app = @import("app.zig"); +const engine = @import("engine.zig"); +const turn = @import("turn.zig"); +const Term = @import("sim.zig").Term; + +const Fake = struct { + var turn_calls: usize = 0; + var join_calls: usize = 0; + var wake_calls: usize = 0; + + fn reset() void { + turn_calls = 0; + join_calls = 0; + wake_calls = 0; + } + + fn spawn(_: *engine.Job) anyerror!std.Thread { + return error.InjectedSpawnFailure; + } + + fn joined() void { + join_calls += 1; + } + + fn modelWork(_: ?*anyopaque, _: std.mem.Allocator, _: []const engine.Turn, _: engine.Params, _: *engine.StreamBuf, _: *engine.EventQueue) ?[]const u8 { + // This callback encloses the TUI's provider, tools, and automatic + // compaction. A spawn failure must not enter any of it. + turn_calls += 1; + return null; + } + + fn wake(_: ?*anyopaque, buf: []u8) ?[]const u8 { + if (wake_calls != 0) return null; + wake_calls += 1; + const text = "automatic follow-up"; + @memcpy(buf[0..text.len], text); + return buf[0..text.len]; + } +}; + +fn install() void { + Fake.reset(); + engine.g_turn_fn = Fake.modelWork; + engine.setJobThreadHooksForTesting(Fake.spawn, Fake.joined); +} + +fn uninstall() void { + engine.setJobThreadHooksForTesting(null, null); + engine.g_turn_fn = null; + engine.g_idle_wake_fn = null; +} + +fn expectFailedStart(term: *Term) !void { + const job = term.model.pending orelse return error.NoPendingJob; + try std.testing.expect(job.start_failed); + try std.testing.expect(!job.threaded); + try std.testing.expect(job.done.load(.acquire)); + try std.testing.expectEqual(turn.QuitStep.reap, turn.quitStep(&term.model, 0)); + try std.testing.expectEqual(@as(usize, 0), Fake.turn_calls); + try std.testing.expectEqual(@as(usize, 0), Fake.join_calls); +} + +fn finishFailedStart(term: *Term) !void { + turn.finishJob(&term.model); + try std.testing.expect(term.model.pending == null); + try std.testing.expect(engine.g_raw == null); + try std.testing.expect(!term.model.cancel_requested); + const last = term.model.history.items[term.model.history.items.len - 1]; + try std.testing.expectEqual(app.EntryKind.err, last.kind); + try std.testing.expectEqualStrings("model turn failed to start — retry your prompt", last.text); + for (term.model.history.items) |entry| try std.testing.expect(entry.kind != .pending); + try std.testing.expectEqual(@as(usize, 0), Fake.turn_calls); + try std.testing.expectEqual(@as(usize, 0), Fake.join_calls); +} + +test "turn spawn failure stays off-thread for initial, queued, and subsequent TUI prompts (#537)" { + install(); + defer uninstall(); + var term: Term = undefined; + term.init(std.testing.allocator, 80, 24); + defer term.deinit(); + + // Initial prompt takes dispatch.applyLine -> turn.startJob. + _ = term.typeText("initial prompt"); + _ = term.enter(); + try expectFailedStart(&term); + const first = term.model.pending.?; + + // Before the next poll reaps the failed start, paint and input still work; + // Enter follows the normal live-turn path and queues this draft. + _ = term.typeText("queued follow-up"); + try std.testing.expectEqualStrings("queued follow-up", term.model.input.getValue()); + const visible = try term.screen(); + defer std.testing.allocator.free(visible); + try std.testing.expect(std.mem.indexOf(u8, visible, "queued follow-up") != null); + _ = term.enter(); + try std.testing.expect(term.model.pending.? == first); + try std.testing.expectEqual(@as(usize, 1), term.model.steer_queue.items.len); + + // The render-loop poll reports and frees the first Job, then its FIFO drain + // starts the queued turn through the same failing spawn seam. + try finishFailedStart(&term); + try std.testing.expectEqual(app.Effect.stay, turn.drainSteer(&term.model)); + try std.testing.expectEqual(@as(usize, 0), term.model.steer_queue.items.len); + try expectFailedStart(&term); + try finishFailedStart(&term); + + // A later ordinary prompt is the same trajectory after pending cleared. + _ = term.typeText("subsequent prompt"); + _ = term.enter(); + try expectFailedStart(&term); + try finishFailedStart(&term); + try std.testing.expectEqual(@as(usize, 0), Fake.turn_calls); + try std.testing.expectEqual(@as(usize, 0), Fake.join_calls); +} + +test "idle wake spawn failure is reapable without a thread or model work (#537)" { + install(); + defer uninstall(); + engine.g_idle_wake_fn = Fake.wake; + var term: Term = undefined; + term.init(std.testing.allocator, 80, 24); + var deinited = false; + defer if (!deinited) term.deinit(); + + turn.maybeJobWake(&term.model); + try std.testing.expectEqual(@as(usize, 1), Fake.wake_calls); + try expectFailedStart(&term); + try std.testing.expectEqualStrings("automatic follow-up", term.model.history.items[0].text); + + // Model.deinit's alternate cleanup path also sees threaded=false: the + // undefined thread handle is never joined, and all Job ownership is freed. + term.deinit(); + deinited = true; + try std.testing.expect(engine.g_raw == null); + try std.testing.expectEqual(@as(usize, 0), Fake.turn_calls); + try std.testing.expectEqual(@as(usize, 0), Fake.join_calls); +} diff --git a/scripts/eval-tier1.sh b/scripts/eval-tier1.sh index 44ef7f50..f0e19207 100755 --- a/scripts/eval-tier1.sh +++ b/scripts/eval-tier1.sh @@ -241,8 +241,8 @@ if wanted tuiguard; then if ((!build_ok)); then skip_dependent tuiguard else - announce tuiguard "17 PTY probes in a 4–8 process pool (#641)" - # Same 17 scripts as the old serial loop (pty-guard through model-picker). + announce tuiguard "18 PTY probes in a 4–8 process pool (#641)" + # The original 17 probes plus #537's provider-free ESC-split regression. # Each owns its pty/tmp/mock; the pool is the wall-time win. if python3 scripts/eval/tier1_tuiguard.py zig-out/bin/graff; then :; else record_fail tuiguard diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index 84dd57a9..b1da957d 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -8,14 +8,15 @@ "It is a FLOOR and nothing raises it automatically - bump it by hand at each release cut.", "test_count_slack is how far the real suite may run ahead of that floor before the tests", "check starts WARNING that the ratchet has stalled: it sat 350 behind for months (#439),", - "which is a floor low enough to catch nothing." + "which is a floor low enough to catch nothing.", + "On macOS, origin/main and this revision run 1860 tests with one deterministic platform skip; 1861 is the non-skipped-platform total." ], "test_roots": [ "src/main.zig", "src/repl.zig", "TUI/root.zig" ], - "test_count_baseline": 1861, + "test_count_baseline": 1860, "test_count_slack": 25, "required_invariants": [ { diff --git a/scripts/eval/tier1_tuiguard.py b/scripts/eval/tier1_tuiguard.py index 86825a4d..dc9b0c96 100755 --- a/scripts/eval/tier1_tuiguard.py +++ b/scripts/eval/tier1_tuiguard.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run the 17 tuiguard PTY probes as a process pool (#641). +"""Run the 18 tuiguard PTY probes as a process pool (#641). Each probe owns its own pty/tmp/mock and is independent of the others. The serial loop in eval-tier1.sh was the dominant post-src wall (2–4 min). A 4–8 @@ -23,6 +23,7 @@ PROBES = ( "tui-pty-guard.py", + "test-tui-escape-split.py", "test-tui-selection.py", "test-tui-typed-events.py", "test-tui-painter.py", diff --git a/scripts/test-tui-escape-split.py b/scripts/test-tui-escape-split.py new file mode 100755 index 00000000..9360871a --- /dev/null +++ b/scripts/test-tui-escape-split.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Real-PTY regression for #537's split Escape ambiguity, fully offline. + +A 70ms exact-CSI gap and 250ms X10 gap stay terminal sequences inside the main +grace. A live bash op gives a possible paste-start Escape the bounded one-second +window, while controls in the completed paste stay inert. A dropped non-lone +head accumulates a secondary late read without losing its exact framing. +Ambiguous CSI/SS3 and byte-read prose stay text. Every split is verified in the +TUI trajectory log as a separate input read. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ptyharness import PtyHarness, PtyTimeout # noqa: E402 + +BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "zig-out/bin/graff") +MARKER = "ESC_SPLIT_DRAFT" +DROPPED_BODY = "DROPPED_HEAD_OK" +DROPPED_CONTROL = "DROPPED_CONTROL_SAFE" +LATE_BODY = "LATE_BODY_OK" +LATE_CONTROL = "LATE_CONTROL_SAFE" +LIVE_BASH = "LIVE_BASH_ESCAPE_SAFE" +HUMAN_TEXT = "HUMAN:[Alice] [Home] [Down] 3u apples" + + +def trajectory_reads(tmp: str) -> list[bytes]: + path = os.path.join(tmp, ".graff", "tui-traj.jsonl") + try: + with open(path, encoding="utf-8") as fh: + rows = [json.loads(line) for line in fh if line.strip()] + except (FileNotFoundError, json.JSONDecodeError): + return [] + return [bytes.fromhex(row["hex"]) for row in rows if "hex" in row] + + +class ReadEvidence: + """Synchronize on traj.note: the next write was its own TUI input batch.""" + + def __init__(self, pty: PtyHarness, tmp: str): + self.pty = pty + self.tmp = tmp + self.seen = len(trajectory_reads(tmp)) + + def inject(self, data: bytes, gap: float = 0.0) -> None: + self.pty.inject_keys(data) + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + self.pty.pump(0.02) + reads = trajectory_reads(self.tmp) + if len(reads) <= self.seen: + continue + fresh = reads[self.seen :] + self.seen = len(reads) + if fresh != [data]: + raise PtyTimeout( + f"expected one TUI read {data.hex()}, got {[part.hex() for part in fresh]}" + ) + if gap: + self.pty.pump(gap) + return + raise PtyTimeout(f"TUI trajectory never recorded input read {data.hex()}") + + +def workspace(tmp: str) -> dict[str, str | None]: + empty = os.path.join(tmp, "empty-mcp.json") + with open(empty, "w", encoding="utf-8") as fh: + json.dump({"mcpServers": {}}, fh) + harness = os.path.join(tmp, ".harness") + os.makedirs(harness) + with open(os.path.join(harness, "settings.json"), "w", encoding="utf-8") as fh: + json.dump({"ai_title": False, "skills": {"codedbpro": False}}, fh) + env = { + name: None + for name in os.environ + if name.startswith(("GRAFF_", "CODEX_")) + or name.endswith("_API_KEY") + } + env.update( + { + "HOME": tmp, + "GRAFF_MCP_CONFIG": empty, + "GRAFF_LEARN_AUTO": "off", + "GRAFF_FLEET": "off", + "GRAFF_NO_TELEMETRY": "1", + # Baked catalog + loopback endpoint: startup and any accidental + # request have no real-provider or remote catalog route. + "LMSTUDIO_API_KEY": "offline-escape-split-probe", + } + ) + return env + + +def run() -> str | None: + with tempfile.TemporaryDirectory(prefix="tui-escape-split-") as tmp: + with PtyHarness( + [BIN, "tui", "--yolo", "--model", "lmstudio", "--no-telemetry"], + cols=90, + rows=28, + cwd=tmp, + env=workspace(tmp), + ) as pty: + if not pty.wait_for_boot(): + return "TUI did not enter the alternate screen" + reads = ReadEvidence(pty, tmp) + reads.inject(MARKER.encode()) + pty.wait_for_text(MARKER, timeout=5.0) + + # Arm two-Escape clear. Trajectory synchronization proves the + # split head and body reached different TUI input batches. + reads.inject(b"\x1b", 0.80) + reads.inject(b"\x1b", 0.070) + reads.inject(b"[A", 0.4) + if MARKER not in pty.screen_contents(): + return "70ms ESC/arrow split dispatched a phantom Escape\n" + pty.screen_contents() + + # X10 is CSI M plus three raw bytes; protect the reported 250ms gap. + reads.inject(b"\x1b", 0.80) + reads.inject(b"\x1b", 0.250) + reads.inject(b"[M !!", 0.4) + screen = pty.screen_contents() + if MARKER not in screen or "[M !!" in screen: + return "250ms ESC/X10 split escaped or typed its body\n" + screen + + # A lone ESC during real background work may still be paste start. + # At 800ms the live op must remain uncancelled; explicit C0 controls + # in the completed paste are then inert. + reads.inject(b"\x15", 0.2) + reads.inject(b"!sleep 2\n", 0.2) + pty.wait_for_text("$ sleep 2", timeout=5.0) + reads.inject(b"\x1b", 0.80) + reads.inject(b"[200~" + LIVE_BASH.encode() + b"\x11\x03\nSAFE") + pty.wait_for_text(LIVE_BASH, timeout=5.0, settle=0.2) + reads.inject(b"\x1b[201~\x15", 1.3) + if "interrupted" in pty.screen_contents().lower(): + return "800ms possible paste-start Escape cancelled live bash\n" + pty.screen_contents() + + # A non-lone paste head is dropped after ~500ms, then its tail + # lands as TWO reads inside the one-second dropped-head interval. + # SGR and X10 wheels share the reconstructed marker's production + # batch and stay paste-inert. + reads.inject(b"\x1b[20", 1.10) + reads.inject(b"0", 0.10) + dropped = ( + b"~" + + DROPPED_BODY.encode() + + 2 * b"\x1b[<64;4;4M" + + 2 * b"\x1b[M`$$" + + b"\x11\x03\n" + + DROPPED_CONTROL.encode() + ) + reads.inject(dropped) + pty.wait_for_text(DROPPED_CONTROL, timeout=5.0, settle=0.2) + screen = pty.screen_contents() + if "0~" in screen or DROPPED_BODY not in screen: + return "dropped paste head lost its exact 401-999ms framing\n" + screen + + # A genuine Escape remains different: its exact head expires at + # 400ms, while narrow self-identifying recovery remains for 1s. + reads.inject(b"\x1b[201~\x05\x15", 0.25) + reads.inject(b"\x1b", 0.80) + paste = b"[200~" + LATE_BODY.encode() + b"\x11\x03\n" + LATE_CONTROL.encode() + reads.inject(paste) + pty.wait_for_text(LATE_CONTROL, timeout=5.0, settle=0.2) + screen = pty.screen_contents() + if "[200~" in screen: + return "late paste marker typed after the carried ESC expired" + if LATE_BODY not in screen: + return "late paste payload was not retained" + + # Beyond carry, exact CSI/SS3 is prose-ambiguous and remains text. + reads.inject(b"\x1b[201~\x05\x15", 0.25) + reads.inject(b"EXACT:\x1b", 0.80) + reads.inject(b"[D") + reads.inject(b" \x1b", 0.80) + reads.inject(b"OD", 0.2) + pty.wait_for_text("EXACT:[D OD", timeout=5.0, settle=0.2) + + # Parameterized kitty remains distinguishable and is a real Left. + reads.inject(b"\x05\x15", 0.25) + reads.inject(b"ab\x1b", 0.80) + reads.inject(b"[57350;1u") + reads.inject(b"X\x05") + pty.wait_for_text("aXb", timeout=5.0, settle=0.2) + + # BEL makes the OSC body self-identifying even with same-read text. + reads.inject(b"\x15", 0.4) + reads.inject(b"OSC:\x1b", 0.80) + reads.inject(b"]11;rgb:f6/f6/f6\x07_OK") + pty.wait_for_text("OSC:_OK", timeout=5.0, settle=0.2) + if "]11;rgb:" in pty.screen_contents(): + return "terminated late OSC tail typed into the composer" + + # Give every ambiguous spelling its own expired genuine Escape, + # and force every prose byte through a distinct read with >=50ms. + reads.inject(b"\x05\x15", 0.4) + reads.inject(b"HUMAN:") + tokens = (b"[Alice]", b"[Home]", b"[Down]", b"3u apples") + for index, token in enumerate(tokens): + reads.inject(b"\x1b", 0.80) + for byte in token: + reads.inject(bytes([byte]), 0.050) + if index + 1 < len(tokens): + reads.inject(b" ") + pty.wait_for_text(HUMAN_TEXT, timeout=5.0, settle=0.2) + if HUMAN_TEXT not in pty.screen_contents(): + return "50ms byte-read human text after genuine Escape was changed" + + if pty.quit() is None: + return "TUI did not quit cleanly" + return None + + +def main() -> int: + if not os.path.isfile(BIN): + print(f"tui-escape-split: {BIN} not built — skipping") + return 0 + try: + err = run() + except PtyTimeout as exc: + err = str(exc) + except OSError as exc: + print(f"tui-escape-split: pty unavailable ({exc}) — skipping") + return 0 + if err: + print(f"tui-escape-split: FAIL: {err}") + return 1 + print("tui-escape-split: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())