From 27690e30f28d215662bb661dbcc03cb653eb2b65 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sat, 10 Oct 2026 09:30:20 -0700 Subject: [PATCH 1/3] Release only what the program was sent when a terminal surface is torn down A mouse button the program was told is down could stay down when its surface was torn down (a recreate re-attaches the same session), and a release could be sent for a press the program never saw. The teardown gate treated any queued input for the surface as "undelivered", while the buttons were counted when a report was queued. Queued mouse reports now carry a tag, and the input worker keeps a ledger of what the program was really sent, updated only after a complete write whose generation still matches. Teardown releases that ledger at the last delivered position; a release or drag is only written for a press delivered in the same generation; a release that could not be queued is queued again. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 121 ++++- graphcode-windows/src/TerminalSurface.zig | 547 +++++++++++++++++++--- investigation/ui-parity-matrix.md | 2 +- 3 files changed, 607 insertions(+), 63 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 4c5785a4..33c498fb 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -13546,15 +13546,34 @@ const LiveKeyboard = struct { _ = c.SendMessageW(self.surface, c.WM_CHAR, unit, 1); } - /// Everything queued for the shell since the last call. + /// Everything the input worker would have written for the shell since the last call, run + /// through its production step (`pumpInput`), so what was delivered also updates the + /// ledger of what the program has been sent. fn drainInput(self: *LiveKeyboard, out: *std.ArrayListUnmanaged(u8)) !void { out.clearRetainingCapacity(); const workspace = &self.fixture.workspace; - while (workspace.input_queue.dequeue()) |item| { - defer workspace.allocator.free(item.bytes); - try std.testing.expectEqual(self.index, item.surface); - try out.appendSlice(std.testing.allocator, item.bytes); - } + InputCapture.length = 0; + InputCapture.wrong_surface = false; + InputCapture.surface = self.index; + workspace.input_write_hook = &InputCapture.write; + defer workspace.input_write_hook = null; + while (workspace.pumpInput()) {} + try std.testing.expect(!InputCapture.wrong_surface); + try out.appendSlice(std.testing.allocator, InputCapture.bytes[0..InputCapture.length]); + } +}; + +const InputCapture = struct { + var bytes: [4096]u8 = undefined; + var length: usize = 0; + var surface: usize = 0; + var wrong_surface = false; + + fn write(_: ?*anyopaque, target: usize, data: []const u8) anyerror!usize { + if (target != surface) wrong_surface = true; + @memcpy(bytes[length..][0..data.len], data); + length += data.len; + return data.len; } }; @@ -14335,6 +14354,96 @@ test "live terminal mouse: a press still queued when the surface is torn down le try std.testing.expect(slot.mouse_gesture == .none); } +const TeardownCapture = struct { + var written: [64]u8 = undefined; + var length: usize = 0; + + fn write(_: ?*anyopaque, _: usize, bytes: []const u8) void { + @memcpy(written[length .. length + bytes.len], bytes); + length += bytes.len; + } +}; + +test "live terminal mouse: a delivered press is released at teardown though keys and motion are still queued behind it" { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{.{ .id = "loop-a" }}); + defer fixture.deinit(); + try fixture.setLive(&.{"loop-a"}); + try clickSidebarLoopRow(&fixture.app, fixture.project, "loop-a"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-a"); + var keyboard = try LiveKeyboard.begin(&fixture, "loop-a"); + defer keyboard.end(); + const mouse = try LiveMouse.begin(&keyboard); + const buttons = LiveMouseButtons{ .mouse = mouse }; + try feedMouseFixture(&fixture, keyboard.index); + const workspace = &fixture.workspace; + try workspace.surfaces[keyboard.index].vt.?.feed("\x1b[?1002h\x1b[?1006h"); + TeardownCapture.length = 0; + workspace.teardown_input_sink = &TeardownCapture.write; + + buttons.press(.left, 0, 3, 2); + try expectSentToProgram(&keyboard, "\x1b[<0;4;3M"); + // A drag and a typed key are still queued when the surface goes: they are dropped, and the + // button the program was told is down is released where it last heard from the pointer. + buttons.move(c.MK_LBUTTON, 5, 2); + try keyboard.press(.{ .vk = 'A' }); + try std.testing.expect(workspace.input_queue.count > 1); + workspace.destroySurface(keyboard.index); + try std.testing.expectEqualStrings("\x1b[<0;4;3m", TeardownCapture.written[0..TeardownCapture.length]); +} + +test "live terminal mouse: a release still queued when the surface is torn down is not lost" { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{.{ .id = "loop-a" }}); + defer fixture.deinit(); + try fixture.setLive(&.{"loop-a"}); + try clickSidebarLoopRow(&fixture.app, fixture.project, "loop-a"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-a"); + var keyboard = try LiveKeyboard.begin(&fixture, "loop-a"); + defer keyboard.end(); + const mouse = try LiveMouse.begin(&keyboard); + const buttons = LiveMouseButtons{ .mouse = mouse }; + try feedMouseFixture(&fixture, keyboard.index); + const workspace = &fixture.workspace; + try workspace.surfaces[keyboard.index].vt.?.feed("\x1b[?1002h\x1b[?1006h"); + TeardownCapture.length = 0; + workspace.teardown_input_sink = &TeardownCapture.write; + + buttons.press(.left, 0, 3, 2); + try expectSentToProgram(&keyboard, "\x1b[<0;4;3M"); + buttons.release(.left, 0, 3, 2); + try std.testing.expectEqual(@as(usize, 1), workspace.input_queue.count); + workspace.destroySurface(keyboard.index); + try std.testing.expectEqualStrings("\x1b[<0;4;3m", TeardownCapture.written[0..TeardownCapture.length]); +} + +test "live terminal mouse: a press that could not be queued is not released at teardown" { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{.{ .id = "loop-a" }}); + defer fixture.deinit(); + try fixture.setLive(&.{"loop-a"}); + try clickSidebarLoopRow(&fixture.app, fixture.project, "loop-a"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-a"); + var keyboard = try LiveKeyboard.begin(&fixture, "loop-a"); + defer keyboard.end(); + const mouse = try LiveMouse.begin(&keyboard); + const buttons = LiveMouseButtons{ .mouse = mouse }; + try feedMouseFixture(&fixture, keyboard.index); + const workspace = &fixture.workspace; + try workspace.surfaces[keyboard.index].vt.?.feed("\x1b[?1002h\x1b[?1006h"); + TeardownCapture.length = 0; + workspace.teardown_input_sink = &TeardownCapture.write; + + // Another pane's input fills the queue, so the press is never queued: the program did not + // see it, and must not be sent a release for it. (Only this surface's input is dropped.) + const other: usize = if (keyboard.index == 1) 2 else 1; + for (0..64) |_| try workspace.input_queue.enqueue(other, try std.testing.allocator.dupe(u8, "x")); + buttons.press(.left, 0, 3, 2); + workspace.destroySurface(keyboard.index); + workspace.input_queue.clear(); + try std.testing.expectEqual(@as(usize, 0), TeardownCapture.length); +} + test "live terminal mouse: the teardown release is not written when the attach pipe is not non-blocking" { var fixture: LiveTerminalFixture = undefined; try fixture.init(&.{.{ .id = "loop-a" }}); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 072eec54..bbc9a77d 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -216,9 +216,22 @@ pub const WorkspaceKeyCallback = *const fn ( pub const InputQueue = struct { pub const max_bytes = input_queue_max_bytes; + /// What a pointer report tells the program, so the worker can keep the ledger of what it + /// has really been sent (see `Surface.delivered_buttons`). `generation` is the surface's + /// `mouse_generation` when the report was queued. + pub const MouseTag = struct { + kind: enum { none, press, release, pointer } = .none, + buttons: u8 = 0, + x: i32 = 0, + y: i32 = 0, + ctrl: bool = false, + generation: u32 = 0, + }; + pub const Item = struct { surface: usize, bytes: []u8, + mouse: MouseTag = .{}, }; allocator: std.mem.Allocator, items: [input_queue_capacity]Item = undefined, @@ -227,12 +240,16 @@ pub const InputQueue = struct { bytes: usize = 0, pub fn enqueue(self: *InputQueue, surface: usize, bytes: []u8) !void { + return self.enqueueTagged(surface, bytes, .{}); + } + + pub fn enqueueTagged(self: *InputQueue, surface: usize, bytes: []u8, mouse: MouseTag) !void { if (bytes.len > input_queue_max_bytes) return error.InputTooLarge; if (self.count == input_queue_capacity or self.bytes + bytes.len > input_queue_max_bytes) { return error.InputQueueFull; } const index = (self.head + self.count) % input_queue_capacity; - self.items[index] = .{ .surface = surface, .bytes = bytes }; + self.items[index] = .{ .surface = surface, .bytes = bytes, .mouse = mouse }; self.count += 1; self.bytes += bytes.len; } @@ -321,6 +338,18 @@ pub const Surface = struct { // The buttons the program has been told are down (bit 0 left, 1 right, 2 middle) and where // it last heard from the pointer, so a gesture that ends without a release can be closed. program_buttons: u8 = 0, + // What the program has really been sent, as opposed to `program_buttons`, which counts a + // report from the moment it is queued. Written only under `Workspace.input_mutex`: the + // input worker commits a press, release or pointer report once its whole write succeeded + // and `mouse_generation` still matches. `delivered_buttons` are the buttons the program + // was told are down, `delivered_pointer` where it last heard from the pointer, + // `release_owed` buttons whose release could not be queued. `mouse_generation` is unique + // per attach and changes at teardown and whenever the program stops tracking the mouse, so + // a write that finishes late never touches the state of a later session. + mouse_generation: u32 = 0, + delivered_buttons: u8 = 0, + delivered_pointer: struct { x: i32 = 0, y: i32 = 0, ctrl: bool = false } = .{}, + release_owed: u8 = 0, // Buttons whose gesture was cancelled while the user still holds them; their release is // swallowed. See swallowsRelease. swallow_buttons: u8 = 0, @@ -482,6 +511,10 @@ pub const Workspace = struct { /// Set by tests only: the number of saves that still succeed before one fails (once) with /// `LayoutSaveFaultInjected`, so a compensating save can be made to fail on its own. layout_save_fault: ?usize = null, + /// Replaces the attach pipe for queued input writes; set by tests, which have no pipe to read. + input_write_hook: ?*const fn (context: ?*anyopaque, surface: usize, bytes: []const u8) anyerror!usize = null, + input_write_context: ?*anyopaque = null, + mouse_generation_counter: u32 = 0, layout: WorkspaceLayout.Layout, layout_path: []u8, project_key: []u8, @@ -2163,6 +2196,7 @@ pub const Workspace = struct { // probes are console-less `zmx ls` runs that never touch accessibility. self.pollLaunchWaits(); self.pollKillJobs(); + self.retryOwedReleases(); // While the workspace is collapsed (not visible as either the full surface or the // picture-in-picture panel), skip draining terminal output entirely. Feeding output // notifies winghostty's own accessibility layer via @@ -2664,24 +2698,29 @@ pub const Workspace = struct { if (index >= self.surfaces.len) return; const slot = &self.surfaces[index]; slot.destroying = true; - const undelivered = self.inputPending(index); self.cancelSurfaceInput(index); - self.waitInputIdle(index); + const quiet = self.waitInputIdle(index); // A button the program was told is down must not stay down in a session that outlives // this surface (a recreate re-attaches the same one). The surface's queue is gone by // now, so the release goes straight to the attach pipe, before the attach is killed. - // Only when the press was delivered: input still queued (or being written) is dropped - // with the surface, and a release for a press the program never saw would be an orphan, - // so then the buttons are just forgotten. Known limitation: "pending" is any input for this - // surface, not only the press, so a press that was delivered followed by other queued - // bytes (a key, a motion report) also skips the release, and the program keeps the button - // down until its next click. Telling those apart needs a per-report delivered flag. - if (slot.mouse_gesture == .program or slot.program_buttons != 0) { - if (undelivered) { - slot.program_buttons = 0; - slot.mouse_gesture = .none; - } else cancelProgramGesture(self, index, .direct); - } + // "Told" is what the input worker really wrote (`delivered_buttons`), not what was + // queued: a press still queued is dropped with the queue and gets no release, while a + // delivered press gets one however much else (keys, motion, its own release) was still + // queued behind it. The release goes where the program last heard from the pointer. + // When the worker has not stopped (`waitInputIdle` timed out) a write to the pipe may + // still be in flight, and a second one could interleave with it, so no release is + // written and the buttons are forgotten; the late completion is discarded by the + // generation change. When the pipe is not non-blocking, or the attach is already gone + // (the attach exited), `writeTeardownInput` writes nothing either. + self.input_mutex.lock(); + const delivered = slot.delivered_buttons; + slot.program_pointer = .{ .x = slot.delivered_pointer.x, .y = slot.delivered_pointer.y, .ctrl = slot.delivered_pointer.ctrl }; + self.forgetDeliveredMouseLocked(slot); + self.input_mutex.unlock(); + slot.program_buttons = if (quiet) delivered else 0; + if (slot.program_buttons != 0) { + cancelProgramGesture(self, index, .direct); + } else slot.mouse_gesture = .none; self.waitAttach(index); if (slot.surface) |surface| { _ = c.winghostty_surface_destroy(surface); @@ -2790,6 +2829,7 @@ pub const Workspace = struct { } self.input_mutex.lock(); self.surfaces[index].attach = child; + self.surfaces[index].mouse_generation = self.nextMouseGenerationLocked(); self.surfaces[index].attach_nonblocking = nonblocking; self.surfaces[index].vt = vt; self.surfaces[index].last_resize_size = size; @@ -2800,7 +2840,7 @@ pub const Workspace = struct { fn waitAttach(self: *Workspace, index: usize) void { self.cancelSurfaceInput(index); - self.waitInputIdle(index); + _ = self.waitInputIdle(index); if (self.surfaces[index].attach) |*child| { _ = child.kill() catch {}; _ = child.wait() catch {}; @@ -2808,24 +2848,60 @@ pub const Workspace = struct { } } - /// Whether input for a surface is still queued or being written, so not yet delivered. - fn inputPending(self: *Workspace, index: usize) bool { + /// A generation no other attach in this workspace has had (see `Surface.mouse_generation`). + fn nextMouseGenerationLocked(self: *Workspace) u32 { + self.mouse_generation_counter +%= 1; + return self.mouse_generation_counter; + } + + /// Forgets what the program was sent and invalidates every write still queued or in flight + /// for it, so none of them can change the ledger afterwards. Caller holds `input_mutex`. + fn forgetDeliveredMouseLocked(self: *Workspace, slot: *Surface) void { + slot.delivered_buttons = 0; + slot.release_owed = 0; + slot.mouse_generation = self.nextMouseGenerationLocked(); + } + + fn forgetDeliveredMouse(self: *Workspace, slot: *Surface) void { + self.input_mutex.lock(); + defer self.input_mutex.unlock(); + self.forgetDeliveredMouseLocked(slot); + } + + fn markReleaseOwed(self: *Workspace, index: usize, buttons: u8) void { self.input_mutex.lock(); defer self.input_mutex.unlock(); - if (self.input_busy and self.input_worker_surface == index) return true; - var position: usize = 0; - while (position < self.input_queue.count) : (position += 1) { - if (self.input_queue.items[(self.input_queue.head + position) % input_queue_capacity].surface == index) return true; + self.surfaces[index].release_owed |= buttons; + } + + /// Queues again the releases that could not be queued when the user let go, once there is + /// room, for buttons the program was really told are down. A program that has stopped + /// tracking the mouse is told nothing. + fn retryOwedReleases(self: *Workspace) void { + for (&self.surfaces, 0..) |*slot, index| { + self.input_mutex.lock(); + const owed = slot.release_owed; + const pointer = slot.delivered_pointer; + slot.release_owed = 0; + self.input_mutex.unlock(); + if (owed == 0) continue; + const state = slot.vt orelse continue; + if (!state.mouseTrackingEnabled()) { + self.input_mutex.lock(); + self.forgetDeliveredMouseLocked(slot); + self.input_mutex.unlock(); + continue; + } + releaseProgramButtons(self, index, owed, pointer.x, pointer.y, pointer.ctrl, .queued); } - return false; } /// Writes bytes straight to a surface's session. Used only while the surface is being torn /// down, after its queued input is gone and before its attach is killed. fn writeTeardownInput(self: *Workspace, index: usize, bytes: []const u8) void { if (!self.surfaces[index].attach_nonblocking) return; - if (self.teardown_input_sink) |sink| return sink(self.teardown_input_context, index, bytes); const child = self.surfaces[index].attach orelse return; + if (self.teardown_input_sink) |sink| return sink(self.teardown_input_context, index, bytes); const stdin = child.stdin orelse return; _ = writeInputBounded(stdin.handle, bytes) catch {}; } @@ -2835,6 +2911,10 @@ pub const Workspace = struct { } fn tryEnqueueInput(self: *Workspace, index: usize, bytes: []const u8) bool { + return self.tryEnqueueTagged(index, bytes, .{}); + } + + fn tryEnqueueTagged(self: *Workspace, index: usize, bytes: []const u8, tag: InputQueue.MouseTag) bool { if (bytes.len == 0) return true; if (bytes.len > input_queue_max_bytes) { self.setInputError("terminal input queue overflow: paste is too large"); @@ -2850,7 +2930,9 @@ pub const Workspace = struct { self.allocator.free(copy); return false; } - self.input_queue.enqueue(index, copy) catch |err| { + var stamped = tag; + if (index < self.surfaces.len) stamped.generation = self.surfaces[index].mouse_generation; + self.input_queue.enqueueTagged(index, copy, stamped) catch |err| { self.input_mutex.unlock(); self.allocator.free(copy); self.setInputError(switch (err) { @@ -2995,7 +3077,29 @@ pub const Workspace = struct { self.input_mutex.unlock(); break; } - const item = self.input_queue.dequeue().?; + const taken = self.takeInputLocked(); + self.input_mutex.unlock(); + if (taken) |input| self.deliverInput(input); + } + } + + const TakenInput = struct { item: InputQueue.Item, handle: c.HANDLE }; + + /// Takes the next report worth writing and marks the worker busy with it. A release, or a + /// motion report with a button held, is only worth writing while the program has been told + /// that button is down in this very generation: without that press (it was dropped, or + /// never queued) the program must not be handed the rest of the gesture. Caller holds + /// `input_mutex`. + fn takeInputLocked(self: *Workspace) ?TakenInput { + while (self.input_queue.dequeue()) |item| { + const tag = item.mouse; + if (tag.kind != .press and tag.buttons != 0) { + const slot = &self.surfaces[item.surface]; + if (tag.generation != slot.mouse_generation or (tag.buttons & ~slot.delivered_buttons) != 0) { + self.allocator.free(item.bytes); + continue; + } + } self.input_busy = true; self.input_worker_surface = item.surface; self.input_worker_handle = if (self.surfaces[item.surface].attach) |child| @@ -3003,36 +3107,76 @@ pub const Workspace = struct { else null; self.input_cancel_requested = false; - const handle = self.input_worker_handle; - self.input_mutex.unlock(); + return .{ .item = item, .handle = self.input_worker_handle }; + } + return null; + } - const result = if (handle) |value| - writeInputBounded(value, item.bytes) - else - error.InputUnavailable; - const cancelled = self.inputCancelled(); - if (result) |written| { - self.input_mutex.lock(); - if (item.surface < self.surfaces.len) self.surfaces[item.surface].input_bytes += written; - self.input_mutex.unlock(); - } else |err| { - if (!cancelled) { - self.setInputError(switch (err) { - error.WriteTimeout => "terminal input write timed out", - error.InputUnavailable => "terminal attach input unavailable", - else => "terminal input write failed", - }); + const InputWriter = struct { + workspace: *Workspace, + surface: usize, + handle: c.HANDLE, + + fn write(self: InputWriter, bytes: []const u8) !usize { + if (self.workspace.input_write_hook) |hook| return hook(self.workspace.input_write_context, self.surface, bytes); + const handle = self.handle orelse return error.InputUnavailable; + return (NativeInputWriter{ .handle = handle }).write(bytes); + } + }; + + /// Writes one taken report and records what came of it. Only a write that finished in full + /// changes the ledger of what the program has been sent, and only while the report's + /// generation is still the surface's: a cancel that arrives after a complete write does not + /// undo it, a partial write or a failed one counts as not delivered (a release stays owed), + /// and a write that finishes after its surface was torn down touches nothing. + fn deliverInput(self: *Workspace, taken: TakenInput) void { + const item = taken.item; + const result = writeInputChunks(item.bytes, InputWriter{ .workspace = self, .surface = item.surface, .handle = taken.handle }); + const cancelled = self.inputCancelled(); + if (result) |written| { + self.input_mutex.lock(); + if (item.surface < self.surfaces.len) { + const slot = &self.surfaces[item.surface]; + slot.input_bytes += written; + const tag = item.mouse; + if (tag.kind != .none and tag.generation == slot.mouse_generation) { + switch (tag.kind) { + .press => slot.delivered_buttons |= tag.buttons, + .release => slot.delivered_buttons &= ~tag.buttons, + else => {}, + } + slot.delivered_pointer = .{ .x = tag.x, .y = tag.y, .ctrl = tag.ctrl }; } } - self.allocator.free(item.bytes); - self.input_mutex.lock(); - self.input_busy = false; - self.input_worker_surface = null; - self.input_worker_handle = null; - self.input_cancel_requested = false; - self.input_condition.broadcast(); self.input_mutex.unlock(); + } else |err| { + if (!cancelled) { + self.setInputError(switch (err) { + error.WriteTimeout => "terminal input write timed out", + error.InputUnavailable => "terminal attach input unavailable", + else => "terminal input write failed", + }); + } } + self.allocator.free(item.bytes); + self.input_mutex.lock(); + self.input_busy = false; + self.input_worker_surface = null; + self.input_worker_handle = null; + self.input_cancel_requested = false; + self.input_condition.broadcast(); + self.input_mutex.unlock(); + } + + /// One step of the input worker without its thread: takes and delivers the next queued + /// report, if any. Tests drive the queue with it, through `input_write_hook`. + pub fn pumpInput(self: *Workspace) bool { + self.input_mutex.lock(); + const taken = self.takeInputLocked(); + self.input_mutex.unlock(); + const input = taken orelse return false; + self.deliverInput(input); + return true; } fn inputCancelled(self: *Workspace) bool { @@ -3074,20 +3218,20 @@ pub const Workspace = struct { if (busy) self.cancelInputIo(); } - fn waitInputIdle(self: *Workspace, index: usize) void { + fn waitInputIdle(self: *Workspace, index: usize) bool { const deadline = nowMilliseconds() + 500; while (true) { self.input_mutex.lock(); const busy = self.input_busy and self.input_worker_surface == index; if (!busy) { self.input_mutex.unlock(); - return; + return true; } _ = self.input_condition.timedWait(&self.input_mutex, 25 * std.time.ns_per_ms) catch {}; self.input_mutex.unlock(); if (nowMilliseconds() >= deadline) { self.cancelInputIo(); - return; + return false; } } } @@ -5952,7 +6096,8 @@ fn cancelProgramGesture(workspace: *Workspace, index: usize, delivery: MouseDeli slot.swallow_buttons |= buttons; if (slot.mouse_gesture == .program) slot.mouse_gesture = .none; const state = slot.vt orelse return; - if (buttons == 0 or !state.mouseTrackingEnabled()) return; + if (!state.mouseTrackingEnabled()) return workspace.forgetDeliveredMouse(slot); + if (buttons == 0) return; releaseProgramButtons(workspace, index, buttons, slot.program_pointer.x, slot.program_pointer.y, slot.program_pointer.ctrl, delivery); } @@ -5972,6 +6117,7 @@ fn reconcileProgramGesture(workspace: *Workspace, index: usize, event: *const c. slot.swallow_buttons |= slot.program_buttons; slot.program_buttons = 0; slot.mouse_gesture = .none; + workspace.forgetDeliveredMouse(slot); return; } // A leave carries no button state, and a wheel turn is not about buttons. @@ -6024,8 +6170,22 @@ fn sendMouseReport(workspace: *Workspace, index: usize, delivery: MouseDelivery, const geometry = mouseGeometry(slot) orelse return; var buffer: [TerminalVt.max_mouse_sequence_bytes]u8 = undefined; const bytes = state.encodeMouse(&buffer, report, geometry) catch return; + const bit = programButtonBit(report.button); + const tag: InputQueue.MouseTag = .{ + .kind = switch (report.action) { + .press => if (bit != 0) .press else .pointer, + .release => .release, + .motion => .pointer, + }, + .buttons = bit, + .x = report.x, + .y = report.y, + .ctrl = report.ctrl, + }; switch (delivery) { - .queued => workspace.enqueueInput(index, bytes), + .queued => if (!workspace.tryEnqueueTagged(index, bytes, tag) and tag.kind == .release) { + workspace.markReleaseOwed(index, bit); + }, .direct => workspace.writeTeardownInput(index, bytes), } } @@ -6283,6 +6443,281 @@ fn minimalWorkspaceForOptionsTest(allocator: std.mem.Allocator) !Workspace { }; } +const MouseLedgerHook = struct { + var bytes: [512]u8 = undefined; + var length: usize = 0; + var sunk: [128]u8 = undefined; + var sunk_length: usize = 0; + var mode: enum { full, partial_then_error, cancel_after_full } = .full; + var partial_done = false; + var workspace: *Workspace = undefined; + + fn install(target: *Workspace) void { + length = 0; + sunk_length = 0; + mode = .full; + partial_done = false; + workspace = target; + target.input_write_hook = &write; + target.teardown_input_sink = &sink; + } + + fn write(_: ?*anyopaque, surface: usize, data: []const u8) anyerror!usize { + if (surface != 0) return data.len; + if (mode == .partial_then_error) { + if (partial_done) return error.WriteFailed; + partial_done = true; + @memcpy(bytes[length..][0..2], data[0..2]); + length += 2; + return 2; + } + @memcpy(bytes[length..][0..data.len], data); + length += data.len; + if (mode == .cancel_after_full) { + workspace.input_mutex.lock(); + workspace.input_cancel_requested = true; + workspace.input_mutex.unlock(); + } + return data.len; + } + + fn sink(_: ?*anyopaque, _: usize, data: []const u8) void { + @memcpy(sunk[sunk_length..][0..data.len], data); + sunk_length += data.len; + } + + fn written() []const u8 { + return bytes[0..length]; + } + + fn released() []const u8 { + return sunk[0..sunk_length]; + } +}; + +/// A workspace whose first slot has a VT parser that tracks the mouse (1002, SGR) and cells of +/// 8 by 16 pixels, with no window: reports are queued with `reportMouse` and delivered with +/// `pumpInput` through `MouseLedgerHook`. +fn armMouseSlot(workspace: *Workspace) !void { + const slot = &workspace.surfaces[0]; + slot.cell_metrics.cell_width = 8; + slot.cell_metrics.cell_height = 16; + if (slot.vt == null) slot.vt = try TerminalVt.State.create(workspace.allocator, slot.grid.cols, slot.grid.rows); + try slot.vt.?.feed("\x1b[?1002h\x1b[?1006h"); +} + +/// A stand-in attach process, so teardown has a live session to write the release to. +fn spawnTestAttach(workspace: *Workspace) !void { + var child = std.process.Child.init(&.{ "cmd.exe", "/c", "pause" }, workspace.allocator); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Ignore; + child.stderr_behavior = .Ignore; + try child.spawn(); + workspace.surfaces[0].attach = child; + workspace.surfaces[0].attach_nonblocking = true; +} + +fn reportMouse(workspace: *Workspace, action: TerminalVt.MouseAction, button: TerminalVt.MouseButton, col: i32, row: i32) void { + sendMouseReport(workspace, 0, .queued, .{ + .action = action, + .button = button, + .x = col * 8 + 4, + .y = row * 16 + 8, + .any_button_pressed = action != .release, + }); +} + +fn pumpAll(workspace: *Workspace) void { + while (workspace.pumpInput()) {} +} + +fn fillInputQueue(workspace: *Workspace) !void { + for (0..input_queue_capacity) |_| try workspace.input_queue.enqueue(1, try workspace.allocator.dupe(u8, "x")); +} + +test "mouse ledger: only a complete write of the current generation counts as delivered" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + try armMouseSlot(&workspace); + defer workspace.surfaces[0].vt.?.destroy(); + MouseLedgerHook.install(&workspace); + const slot = &workspace.surfaces[0]; + + // A write that stops part-way and then fails leaves the program mid-sequence: not delivered. + MouseLedgerHook.mode = .partial_then_error; + reportMouse(&workspace, .press, .left, 3, 2); + pumpAll(&workspace); + try std.testing.expectEqual(@as(usize, 2), MouseLedgerHook.written().len); + try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); + + // A cancel that arrives after a complete write does not undo it. + MouseLedgerHook.mode = .cancel_after_full; + reportMouse(&workspace, .press, .left, 3, 2); + pumpAll(&workspace); + try std.testing.expectEqual(@as(u8, 1), slot.delivered_buttons); + try std.testing.expectEqual(@as(i32, 28), slot.delivered_pointer.x); + + // A write that completes after the surface's generation changed leaves the ledger alone. + MouseLedgerHook.mode = .full; + workspace.forgetDeliveredMouse(slot); + const written_before = MouseLedgerHook.written().len; + reportMouse(&workspace, .press, .right, 3, 2); + workspace.input_mutex.lock(); + const taken = workspace.takeInputLocked().?; + workspace.input_mutex.unlock(); + workspace.forgetDeliveredMouse(slot); + workspace.deliverInput(taken); + try std.testing.expect(MouseLedgerHook.written().len > written_before); + try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); +} + +test "mouse ledger: a release or drag is not written for a press that never reached the program" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + try armMouseSlot(&workspace); + defer workspace.surfaces[0].vt.?.destroy(); + MouseLedgerHook.install(&workspace); + + // The press cannot be queued (the queue is full of another pane's input), but its release + // and a drag that follow can, once the worker has made room. + try fillInputQueue(&workspace); + reportMouse(&workspace, .press, .left, 3, 2); + try std.testing.expectEqual(input_queue_capacity, workspace.input_queue.count); + workspace.input_queue.clear(); + reportMouse(&workspace, .motion, .left, 5, 2); + reportMouse(&workspace, .release, .left, 5, 2); + pumpAll(&workspace); + try std.testing.expectEqual(@as(usize, 0), MouseLedgerHook.written().len); + try std.testing.expectEqual(@as(u8, 0), workspace.surfaces[0].delivered_buttons); +} + +test "mouse ledger: a release that could not be queued is queued again and reaches the program" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + try armMouseSlot(&workspace); + defer workspace.surfaces[0].vt.?.destroy(); + MouseLedgerHook.install(&workspace); + const slot = &workspace.surfaces[0]; + + reportMouse(&workspace, .press, .left, 3, 2); + pumpAll(&workspace); + try std.testing.expectEqual(@as(u8, 1), slot.delivered_buttons); + try fillInputQueue(&workspace); + reportMouse(&workspace, .release, .left, 5, 2); + try std.testing.expectEqual(@as(u8, 1), slot.release_owed); + workspace.input_queue.clear(); + workspace.retryOwedReleases(); + pumpAll(&workspace); + // The program is released where it last heard from the pointer, not where the release was lost. + try std.testing.expectEqualStrings("\x1b[<0;4;3M\x1b[<0;4;3m", MouseLedgerHook.written()); + try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); + try std.testing.expectEqual(@as(u8, 0), slot.release_owed); +} + +test "mouse ledger: a program that stops and resumes tracking forgets a press still in flight" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + try armMouseSlot(&workspace); + defer workspace.surfaces[0].vt.?.destroy(); + MouseLedgerHook.install(&workspace); + const slot = &workspace.surfaces[0]; + + reportMouse(&workspace, .press, .left, 3, 2); + workspace.input_mutex.lock(); + const in_flight = workspace.takeInputLocked().?; + workspace.input_mutex.unlock(); + reportMouse(&workspace, .release, .left, 3, 2); + slot.mouse_gesture = .program; + slot.program_buttons = 1; + try slot.vt.?.feed("\x1b[?1002l"); + cancelProgramGesture(&workspace, 0, .queued); + try slot.vt.?.feed("\x1b[?1002h"); + workspace.deliverInput(in_flight); + pumpAll(&workspace); + // The press finished writing after the program had forgotten the gesture, so it is not + // recorded and the release queued behind it is not written. + try std.testing.expectEqualStrings("\x1b[<0;4;3M", MouseLedgerHook.written()); + try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); +} + +test "mouse teardown: only the buttons still down are released, where the program last heard from the pointer" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + MouseLedgerHook.install(&workspace); + defer workspace.waitAttach(0); + + // Left and right are down; the right is released and a drag is still queued: the drag is + // dropped with the queue and the left is released at the right button's release position. + try armMouseSlot(&workspace); + try spawnTestAttach(&workspace); + reportMouse(&workspace, .press, .left, 3, 2); + reportMouse(&workspace, .press, .right, 3, 2); + reportMouse(&workspace, .release, .right, 4, 2); + pumpAll(&workspace); + reportMouse(&workspace, .motion, .left, 6, 2); + workspace.destroySurface(0); + try std.testing.expectEqualStrings("\x1b[<0;5;3m", MouseLedgerHook.released()); + + // A release still queued is lost with the queue, so both buttons are released. + MouseLedgerHook.sunk_length = 0; + try armMouseSlot(&workspace); + try spawnTestAttach(&workspace); + reportMouse(&workspace, .press, .left, 3, 2); + reportMouse(&workspace, .press, .right, 3, 2); + pumpAll(&workspace); + reportMouse(&workspace, .release, .right, 4, 2); + workspace.destroySurface(0); + try std.testing.expectEqualStrings("\x1b[<0;4;3m\x1b[<2;4;3m", MouseLedgerHook.released()); +} + +test "mouse teardown: no release goes to an exited attach, a blocking pipe or a write still in flight" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + MouseLedgerHook.install(&workspace); + defer workspace.waitAttach(0); + const slot = &workspace.surfaces[0]; + + // The attach exited first (`handleAttachExit` ends it before the surface): the session is gone. + try armMouseSlot(&workspace); + try spawnTestAttach(&workspace); + reportMouse(&workspace, .press, .left, 3, 2); + pumpAll(&workspace); + workspace.waitAttach(0); + workspace.destroySurface(0); + try std.testing.expectEqual(@as(usize, 0), MouseLedgerHook.released().len); + + // A pipe that did not take non-blocking mode could block the shell: no release. + try armMouseSlot(&workspace); + try spawnTestAttach(&workspace); + reportMouse(&workspace, .press, .left, 3, 2); + pumpAll(&workspace); + slot.attach_nonblocking = false; + workspace.destroySurface(0); + try std.testing.expectEqual(@as(usize, 0), MouseLedgerHook.released().len); + + // A write that does not stop within the wait may still be using the pipe: no second write + // beside it, and its late completion is discarded. + try armMouseSlot(&workspace); + try spawnTestAttach(&workspace); + reportMouse(&workspace, .press, .left, 3, 2); + pumpAll(&workspace); + reportMouse(&workspace, .press, .right, 3, 2); + workspace.input_mutex.lock(); + const in_flight = workspace.takeInputLocked().?; + workspace.input_mutex.unlock(); + workspace.destroySurface(0); + try std.testing.expectEqual(@as(usize, 0), MouseLedgerHook.released().len); + workspace.deliverInput(in_flight); + try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); + try std.testing.expectEqual(@as(usize, 0), MouseLedgerHook.released().len); +} + test "opening a loop mounts its pending tab without spawning a terminal" { var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); defer workspace.layout.deinit(); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 92563e1f..47d29305 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -246,7 +246,7 @@ case proves ownership only. All existing Partial rows remain Partial. | Reproducible DPI/geometry regression coverage | Control metrics for GraphCode-owned chrome scale correctly and predictably across 100/125/150/200% DPI | `Tools/windows/visual-baseline.ps1` previously only checked that each DPI variant's `scale`/`viewport` were present and positive. It now reimplements `Dpi.zig`'s exact `scale()` rounding formula, self-checked against `Dpi.zig`'s own fixed-point unit-test cases, reads the real base pixel values straight out of `DesignTokens.zig` (not a copy baked into the manifest), and asserts the scaled geometry for `sidebar_width`, `tab_bar_height`, `pane_header_height`, and `loop_bar_height` at the real Windows per-monitor DPI values (96/120/144/192) is monotonic and matches the 96-DPI base exactly at 100%. Every `regionGeometry` entry is required to target a `deterministicScreenshotRegions` (GraphCode-owned) region, never a Winghostty-owned one, keeping third-party terminal pixels structurally out of scope. **This check performs static manifest/geometry metadata validation, not rendered-output comparison: it never launches the app, captures a window, or rasterizes a bitmap.** It re-derives expected numeric geometry from source-of-truth code and checks the manifest against that math, which is materially stronger than the prior presence-only checks and does catch real drift, but it is not a screenshot diff and should not be read as one; this repo/CI has no deterministic way to rasterize a live Win32 window. Manually re-verified that corrupting either a DPI value or a `DesignTokens.zig` constant makes the script fail | Validated | | Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | The Loop, View, and File menus label Show in Graph (Ctrl+Shift+G), Reconnect (Ctrl+R), and Project Worktree Policy (Ctrl+Shift+P); Help > Keyboard Shortcuts lists remaining useful mapped routes (Ctrl+M, context-sensitive Ctrl+E, Ctrl+Up/Down, worktree Up/Down, terminal focus 1/2, Ctrl+Shift+X, Ctrl+Shift+V) and toolbar/palette navigation. Context menus retain their applicable F2, Delete, Ctrl+S, Ctrl+N, and Quick Chat labels. No gesture hints are advertised. Canvas/pointer discovery remains incomplete: canvas click-to-select/open, blank-canvas drag-to-pan, node drag-to-move, connector drag-to-create-edge, wheel/pinch zoom, right-click context-menu access, and sidebar root-loop drag-to-reorder still lack visible gesture hints. Closing the canvas gesture gap requires rendering in the canvas-owning `GraphCanvas.zig`; sidebar reorder guidance belongs to excluded `Sidebar.zig`. Both are outside this PR's scope. The hidden-window regression inspects the production HMENU labels and verifies keyboard Help content has no gesture rows. RED: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" --test-filter 'main and help menus expose missing shortcuts without canvas gesture claims'` -> failed because 10 keyboard-only rows were expected but the Help menu contained 18 rows, including gesture claims. GREEN: same focused pinned-Zig command after the change -> 1 test passed. REGRESSION: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include"` -> all 48 MainWindow and imported InputRouter tests passed; the 18-entry accelerator assertion and original 15-binding order remain unchanged. Ctrl+P duplicates Ctrl+J; Ctrl+Shift+I duplicates Ctrl+Shift+W. The earlier claim that Ctrl+Shift+C is a binding conflict was wrong: a source audit of the accelerator table, `pretranslateKey`, `onShellKey`, `fallbackKeyAction` and `InputRouter.keyAction` found no Ctrl+Shift+C accelerator and no dispatch collision. The chord deliberately copies while a terminal is focused and clones otherwise, and Clone stays reachable from a terminal through File > Add Folder. The real defect was discovery text, now fixed: the File menu reads `Clone Repository...\tCtrl+Shift+C outside terminal` and Help > Keyboard Shortcuts carries a greyed `Copy terminal text\tCtrl+Shift+C` entry immediately before Paste. A MainWindow test asserts a positive accelerator count, zero Ctrl+Shift+C accelerators, `keyAction('C', ctrl, shift) == clone_repository`, both exact labels, and that the Copy entry is informational with command ID 0; MainWindow passed 50/50 and InputRouter 9/9, and the hosted integration job 109828242393 ran the live Add Folder menu check against the qualified label. These are menu/source and live-menu tests, not physical keyboard, touch-device, or macOS runtime evidence, and the canvas and sidebar gesture hints above are still absent; the row remains Partial. Terminal-focused keys now follow one rule: Ctrl+D, W, S, T, N, `[` and `]` go to the shell instead of the matching menu accelerators, which stay available elsewhere and have terminal-safe alternatives (Ctrl+Shift+T new tab, Ctrl+Shift+N new loop, Alt+Shift+D split right, Ctrl+Shift+W close tab, Ctrl+Shift+[ / ] pane focus) that the Terminal and Loop menus now label; Help > Keyboard Shortcuts states the rule. F6 and F10 follow the same rule: while a terminal has focus they are encoded to it (`ESC[17~`, `ESC[21~`; Shift+F10 stays the terminal context menu) instead of entering the window toolbar or the native menu bar, and the shell keeps Ctrl+Shift+F6 (window toolbar) and Ctrl+Shift+F10 (menu bar) for those, labelled in View > Focus Window Toolbar and Help > Keyboard Shortcuts. Evidence is in-process: routing and dispatch unit tests, a hidden real-window fixture that checks a terminal-owned F10 is neither buffered nor passed to default menu processing, and a production-dispatch test that F1-F12 each enqueue their encoder sequence once. Known limitation, Ctrl+S: plain Ctrl+S is still the raw 0x13 byte. In a program that reads with line input on (for example pwsh `[Console]::ReadKey`) conhost's pause-output handling swallows Ctrl+S and the next key, while Ctrl+T alone works; a win32-input-mode workaround was rejected because an isolated zmx 785b3fd matrix showed it breaks Git MSYS vim (Enter no longer honored) and makes cmd.exe insert a literal ^S. That evidence is not a Dev Box walkthrough, and Ctrl+S is not claimed to work. Tests assert only that Ctrl+S/T/D/W/N add no shell-encoded bytes. The App `live terminal keyboard` key table does drive synthesized WM_KEYDOWN through the production dispatch into a real Winghostty surface (F6, Shift+F6, F10 in both key-down families, Ctrl+F10, F11 and F12 reach the input queue; Ctrl+Shift+F10 posts SC_KEYMENU), but no physical keyboard, real conhost, or Dev Box walkthrough ran, and menu mnemonics (Alt, Alt+letter) are terminal input while a terminal has focus | Partial | | IME/dead keys/layouts | Native composition in forms and terminal | Fixed a real terminal input gap: Winghostty reports preedit and committed result text through `on_ime_update`, but `TerminalSurface.onImeUpdate` discarded both; it now ignores preedit and enqueues only committed UTF-8 through the same bounded terminal input queue as ordinary text. A focused executable test invokes that production callback and proves uncommitted text is absent while committed Japanese text reaches the correct surface queue. `MainWindow.Window.dispatchMessage` executable tests create a real Unicode `EDIT` control and observe `WM_IME_STARTCOMPOSITION` / `WM_IME_COMPOSITION` / `WM_IME_ENDCOMPOSITION` at its native window procedure, then activate US-International and French layouts and exercise the production `TranslateMessage`/`DispatchMessageW` path with physical scan codes, observing a real dead-key `WM_DEADCHAR` followed by `é` and French scan-code mapping to `a`; unavailable layouts are explicitly logged and skipped rather than silently weakened. These are hidden-window Win32 runtime tests, not a foreground UIA walkthrough or validation with a live IME candidate window, and terminal preedit rendering remains provider-owned, so the row remains Partial. A native-linked test now also drives US-International dead keys (`'` then `e`/`a` committing `é`/`á`) and German AltGr+Q (`@`) through the production dispatch into a real Winghostty terminal surface and observes the bytes queued for the shell, so the shell's own key encoding cannot swallow or double them; it is in-process evidence, not a physical keyboard or live IME walkthrough | Partial | -| Clipboard/selection | Terminal copy/paste and mouse selection | Terminal-context Ctrl+Shift+C copies the active surface's mouse selection (read from the shell's own VT through the pinned libghostty-vt formatter) or, when there is none, its UI Automation-reported selection range through Winghostty into Windows `CF_UNICODETEXT`; Ctrl+Shift+V or Shift+Insert reads only `CF_UNICODETEXT`, converts UTF-16 to UTF-8, normalises line endings, and encodes it with the pinned `ghostty_paste_encode` (the control bytes xterm blanks, including ESC, become spaces, as do the C1 controls U+0080 to U+009F; BEL and tab pass through; text over 1,000,000 UTF-8 bytes is refused before it is converted or copied), wrapping it in bracketed-paste markers only when the running program set DECSET 2004 (read from the shell's own VT state; Winghostty's `winghostty_surface_paste_text` always brackets and does not sanitize, so it is not used, and the shell no longer registers the provider's `on_paste` callback; in the pinned provider that callback is reached only from that exported function, which the shell never calls, and the host has no WM_PASTE, drag-and-drop, middle-click or context-menu paste; a live test calls the provider entry point and observes nothing reach the input queue). A successful terminal copy intentionally replaces every clipboard format with plain Unicode text (macOS does the same with `clearContents` then `setString`); nothing earlier is read or restored, because reading another program's delayed-render data has no timeout and restoring only its text would drop the history and cloud opt-out formats. The new text is copied into a clipboard block before the clipboard is opened, so running out of memory cannot happen after it is emptied; a copy the clipboard then refuses fails with an ordinary write failure that can leave the clipboard empty, and the status says so (Clipboard.zig tests use a stand-in clipboard with open, empty, publish and free counters that refuses calls made in the wrong order (empty while closed, publish before empty) and checks the staged block is freed exactly once on every failure and never after success, not the real one; the native Open/Empty/SetClipboardData path is still unexecuted, and RDP or Dev Box copy and paste through rdpclip is not verified). Pinned Zig `Clipboard.zig` tests pass 16/16 (its 15 tests plus the imported Win32 one) including that stand-in and the size limit; app routing tests prove the shortcuts are terminal-context-only and preserve the existing global Ctrl+Shift+C Clone Repository route. Multi-line text for a program that did not enable bracketed paste asks for confirmation before it is sent (macOS auto-confirms with no dialog; bracketed pastes are never prompted); a declined paste sends nothing. A measured ConPTY probe (pwsh 7.6.6, PSReadLine 2.4.5) shows pwsh never requests bracketed paste and runs each pasted line as soon as it arrives, exactly as cmd.exe does, so pwsh is asked too and that confirmation is deliberate, not a defect. The winghostty modifier mask (Shift 0x04, Ctrl 0x08, Alt 0x80) was previously decoded as GhosttyMods bits, so these chords never routed and Shift+Tab was treated as loop navigation; the mask is now decoded correctly and the translated `^C`/`^V` characters are discarded for chords the shell handles. Terminal menu Copy/Paste, a terminal context menu (right-click, Menu key, Shift+F10), and Ctrl+Insert/Shift+Insert exist; plain Ctrl+C copies only while the surface reports a selection. Evidence is in-process: unit tests, native-linked tests that drive real WM_KEYDOWN/WM_CHAR and real mouse messages (press, drag, double click, Shift+click, release) through a real Winghostty surface and check the resulting selection text, the colors published to the host, and the copy route (through a clipboard seam); and menu tests. They did not use the real clipboard, a Dev Box, or a physical keyboard or mouse. Mouse selection follows Ghostty: drag selects with a visible highlight (the host draws none, so the shell swaps foreground and background in the snapshot it publishes), double click selects a word, triple click a soft-wrapped logical line, Shift+click extends from the far end, a click or typing clears, soft-wrapped rows copy as one line and trailing blanks are dropped. It is tracked in screen coordinates, so it stays on its text as output scrolls, and it ends on resize, paste, typing and an alternate-screen switch. Not done: auto-scroll while dragging past the pane, block (rectangular) selection, scrollback viewport scrolling by wheel (not wired in the shell at all yet), and the selection is not adjusted if more than 10,000 scrollback rows are pruned underneath it. It needs the VT parser, which is the default; the `GRAPHCODE_EXPERIMENTAL_TERMINAL_VT=0` opt-out has no mouse selection. The full Windows shell compiles against the pinned provider, but the probe did not reach any named live residual: the focused clipboard tests do not execute `OpenClipboard`/`GetClipboardData`/`SetClipboardData`; a real copy would replace the shared desktop clipboard, and no isolated clipboard desktop is available. `uia-live-gate.ps1` can reacquire `graphcode-root` through `FromHandle` while backgrounded, but its UI actions explicitly reacquire foreground and its mouse/key helpers post to the shell window; it has no clipboard-specific action/assertion or terminal text/selection fixture. A successful build is not runtime terminal evidence, so the UIA gate was not run for this row. Real-desktop mouse-driven selection and the rendered highlight pixels were not observed; the tests above check the cells handed to the host, not what it drew. The registered `onClipboardRead` and `onClipboardWrite` callbacks still discard their event data; `onSelection` now only ends a drag when the host loses mouse capture. End-to-end copy/paste likewise remains untested without a live Dev Box walkthrough and desktop input; the row remains Partial. Mouse reporting to terminal programs: while the VT state shows DECSET 9/1000/1002/1003 the left, middle and right buttons, motion and wheel are encoded by the pinned libghostty-vt mouse encoder (X10, UTF-8, SGR, URXVT and SGR-pixels formats) and queued to the shell, Shift keeps the local selection (and the context menu on right-click), and a gesture keeps the route it began on; before this, every left press became a local selection and mouse-aware TUIs (vim, tmux, htop, the Copilot CLI) received nothing. This is evidenced only by exact-byte VT tests and in-process synthetic mouse messages to a real Winghostty surface window feeding the fake zmx stand-in: no real tmux, vim, pwsh, conhost or Dev Box, no physical mouse, and not Alt, side buttons, horizontal wheel or WM_CANCELMODE (see the README); lost capture, lost focus and a release outside the window are closed by releasing the program's buttons, tested with synthetic messages only; hover under 1003 and wheel were checked only as bytes in the input queue | Partial | +| Clipboard/selection | Terminal copy/paste and mouse selection | Terminal-context Ctrl+Shift+C copies the active surface's mouse selection (read from the shell's own VT through the pinned libghostty-vt formatter) or, when there is none, its UI Automation-reported selection range through Winghostty into Windows `CF_UNICODETEXT`; Ctrl+Shift+V or Shift+Insert reads only `CF_UNICODETEXT`, converts UTF-16 to UTF-8, normalises line endings, and encodes it with the pinned `ghostty_paste_encode` (the control bytes xterm blanks, including ESC, become spaces, as do the C1 controls U+0080 to U+009F; BEL and tab pass through; text over 1,000,000 UTF-8 bytes is refused before it is converted or copied), wrapping it in bracketed-paste markers only when the running program set DECSET 2004 (read from the shell's own VT state; Winghostty's `winghostty_surface_paste_text` always brackets and does not sanitize, so it is not used, and the shell no longer registers the provider's `on_paste` callback; in the pinned provider that callback is reached only from that exported function, which the shell never calls, and the host has no WM_PASTE, drag-and-drop, middle-click or context-menu paste; a live test calls the provider entry point and observes nothing reach the input queue). A successful terminal copy intentionally replaces every clipboard format with plain Unicode text (macOS does the same with `clearContents` then `setString`); nothing earlier is read or restored, because reading another program's delayed-render data has no timeout and restoring only its text would drop the history and cloud opt-out formats. The new text is copied into a clipboard block before the clipboard is opened, so running out of memory cannot happen after it is emptied; a copy the clipboard then refuses fails with an ordinary write failure that can leave the clipboard empty, and the status says so (Clipboard.zig tests use a stand-in clipboard with open, empty, publish and free counters that refuses calls made in the wrong order (empty while closed, publish before empty) and checks the staged block is freed exactly once on every failure and never after success, not the real one; the native Open/Empty/SetClipboardData path is still unexecuted, and RDP or Dev Box copy and paste through rdpclip is not verified). Pinned Zig `Clipboard.zig` tests pass 16/16 (its 15 tests plus the imported Win32 one) including that stand-in and the size limit; app routing tests prove the shortcuts are terminal-context-only and preserve the existing global Ctrl+Shift+C Clone Repository route. Multi-line text for a program that did not enable bracketed paste asks for confirmation before it is sent (macOS auto-confirms with no dialog; bracketed pastes are never prompted); a declined paste sends nothing. A measured ConPTY probe (pwsh 7.6.6, PSReadLine 2.4.5) shows pwsh never requests bracketed paste and runs each pasted line as soon as it arrives, exactly as cmd.exe does, so pwsh is asked too and that confirmation is deliberate, not a defect. The winghostty modifier mask (Shift 0x04, Ctrl 0x08, Alt 0x80) was previously decoded as GhosttyMods bits, so these chords never routed and Shift+Tab was treated as loop navigation; the mask is now decoded correctly and the translated `^C`/`^V` characters are discarded for chords the shell handles. Terminal menu Copy/Paste, a terminal context menu (right-click, Menu key, Shift+F10), and Ctrl+Insert/Shift+Insert exist; plain Ctrl+C copies only while the surface reports a selection. Evidence is in-process: unit tests, native-linked tests that drive real WM_KEYDOWN/WM_CHAR and real mouse messages (press, drag, double click, Shift+click, release) through a real Winghostty surface and check the resulting selection text, the colors published to the host, and the copy route (through a clipboard seam); and menu tests. They did not use the real clipboard, a Dev Box, or a physical keyboard or mouse. Mouse selection follows Ghostty: drag selects with a visible highlight (the host draws none, so the shell swaps foreground and background in the snapshot it publishes), double click selects a word, triple click a soft-wrapped logical line, Shift+click extends from the far end, a click or typing clears, soft-wrapped rows copy as one line and trailing blanks are dropped. It is tracked in screen coordinates, so it stays on its text as output scrolls, and it ends on resize, paste, typing and an alternate-screen switch. Not done: auto-scroll while dragging past the pane, block (rectangular) selection, scrollback viewport scrolling by wheel (not wired in the shell at all yet), and the selection is not adjusted if more than 10,000 scrollback rows are pruned underneath it. It needs the VT parser, which is the default; the `GRAPHCODE_EXPERIMENTAL_TERMINAL_VT=0` opt-out has no mouse selection. The full Windows shell compiles against the pinned provider, but the probe did not reach any named live residual: the focused clipboard tests do not execute `OpenClipboard`/`GetClipboardData`/`SetClipboardData`; a real copy would replace the shared desktop clipboard, and no isolated clipboard desktop is available. `uia-live-gate.ps1` can reacquire `graphcode-root` through `FromHandle` while backgrounded, but its UI actions explicitly reacquire foreground and its mouse/key helpers post to the shell window; it has no clipboard-specific action/assertion or terminal text/selection fixture. A successful build is not runtime terminal evidence, so the UIA gate was not run for this row. Real-desktop mouse-driven selection and the rendered highlight pixels were not observed; the tests above check the cells handed to the host, not what it drew. The registered `onClipboardRead` and `onClipboardWrite` callbacks still discard their event data; `onSelection` now only ends a drag when the host loses mouse capture. End-to-end copy/paste likewise remains untested without a live Dev Box walkthrough and desktop input; the row remains Partial. Mouse reporting to terminal programs: while the VT state shows DECSET 9/1000/1002/1003 the left, middle and right buttons, motion and wheel are encoded by the pinned libghostty-vt mouse encoder (X10, UTF-8, SGR, URXVT and SGR-pixels formats) and queued to the shell, Shift keeps the local selection (and the context menu on right-click), and a gesture keeps the route it began on; before this, every left press became a local selection and mouse-aware TUIs (vim, tmux, htop, the Copilot CLI) received nothing. This is evidenced only by exact-byte VT tests and in-process synthetic mouse messages to a real Winghostty surface window feeding the fake zmx stand-in: no real tmux, vim, pwsh, conhost or Dev Box, no physical mouse, and not Alt, side buttons, horizontal wheel or WM_CANCELMODE (see the README); lost capture, lost focus and a release outside the window are closed by releasing the program's buttons, tested with synthetic messages only; when a surface is torn down (a recreate re-attaches the same session) the buttons the program was really sent as down are released at the last position it was sent, tracked by a ledger the input worker updates only after a complete write, so a press still queued gets no release and a delivered one gets exactly one however much input is queued behind it (stand-in pipe and a test write seam only: no real attach pipe, worker thread race, conhost or Dev Box); hover under 1003 and wheel were checked only as bytes in the input queue | Partial | | Per-monitor DPI | Layout and controls scale correctly across monitors | The process now declares real per-monitor-v2 DPI awareness at startup (`Win32.enablePerMonitorDpiAwareness()`, called before any window is created) instead of relying on system-DPI bitmap stretching; without this, Windows never delivers real per-monitor `WM_DPICHANGED` data to a DPI-unaware process. `App.zig` seeds the real startup DPI via `GetDpiForWindow` immediately after window creation (rather than assuming 96 DPI/100% until the first monitor move) and forwards every live `WM_DPICHANGED` to `TerminalWorkspace.Workspace.setDpi()`. Previously, `TerminalSurface.zig`'s `onDpiChanged` callback silently discarded the `dpi`/`scale` winghostty reported, and every terminal surface was created with `font_scale` hardcoded to `1.0`, so terminal text never actually rescaled on a DPI change or on a monitor with non-100% DPI at launch. `Workspace.setDpi()` now propagates the real runtime DPI to every live surface via winghostty's own `winghostty_surface_notify_dpi_changed` + `winghostty_surface_set_font_scale` (the two operations the provider actually exposes for this), and `surfaceOptions()` seeds new surfaces' `font_scale` from the workspace's last-known DPI instead of a fixed `1.0`. Deliberately does not also pre-scale `options.input.cell_width`/`cell_height` (kept at their 96-DPI logical baseline) so the DPI ratio is applied exactly once, through `font_scale`, avoiding double scaling. `onMetricsChanged`/`onAccessibilitySelection`, previously also fully discarded, now record the host's reported cell metrics and terminal text-selection range per surface instead of losing them. Verified with `zig build` (full app, pinned Zig 0.15.2 against the exact pinned Winghostty provider) and `zig test src/TerminalSurface.zig` (new `Dpi.fontScale` unit test plus all 13 pre-existing tests, 14/14). No live multi-monitor walkthrough was recorded (this environment has no interactive multi-DPI desktop), so this remains Partial pending that end-to-end evidence | Partial | | Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Existing `DesignTokens`, repository dialogs and `NativeForms` supply the dark native palette and teaching tiles. [Actual production-renderer captures](visual-baseline/rendered-windows/README.md) now preserve canvas/sidebar, Product Settings and workspace clients at native 96 DPI. The real PNG comparator verifies exact opaque canvas/card/sidebar/dialog samples; setup uses a synthetic disconnected fixture, UIA invocation and native WM_COMMAND, not keyboard-menu proof. Settings retains native light buttons; current macOS Settings uses a native grouped form, so these were not speculatively darkened. Other sheets/state combinations, legibility on every surface and a compatible current macOS capture remain unverified | Partial | | Font rendering quality | Legible, ClearType-quality text on every surface, matching macOS's default anti-aliased text | The shared `AppFont.zig` cache requests Segoe UI at `CLEARTYPE_QUALITY`, with per-DPI native controls and logical-size selection for buffered canvas painting. [Actual 96-DPI glyph samples](visual-baseline/rendered-windows/README.md#what-the-pixels-establish) preserve card/sidebar/Settings-title pixels with 27/31/55 distinct colors; the native button sample has two. These counts and recorded system font-smoothing settings are observations, not legibility thresholds or actual font-face certification. Native control LOGFONT metadata, every-surface/multi-DPI review, terminal-text readability and matched current macOS evidence remain unavailable. No speculative font fix was made | Partial | From 71658ed5200cc7942791e9d5cfd869916e73d374 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sat, 10 Oct 2026 09:49:57 -0700 Subject: [PATCH 2/3] Let the teardown unit tests link without the provider library The input queue tests build TerminalSurface.zig alone, where destroySurface cannot link. Split the release out of it, spawn the test attach through ZmxSession.child, and call the split part from the unit tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/TerminalSurface.zig | 68 ++++++++++++++--------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index bbc9a77d..41f67182 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -2698,6 +2698,29 @@ pub const Workspace = struct { if (index >= self.surfaces.len) return; const slot = &self.surfaces[index]; slot.destroying = true; + self.releaseDeliveredMouse(index); + self.waitAttach(index); + if (slot.surface) |surface| { + _ = c.winghostty_surface_destroy(surface); + slot.surface = null; + slot.destroyed = true; + } + slot.destroying = false; + if (slot.session_name.len != 0) { + self.allocator.free(slot.session_name); + slot.session_name = &.{}; + } + if (slot.project_path.len != 0) { + self.allocator.free(slot.project_path); + slot.project_path = &.{}; + } + self.resetSessionState(index); + } + + /// The part of a surface's teardown that runs while its attach is still alive: drops the + /// surface's queued input and releases the mouse buttons its program was sent as down. + fn releaseDeliveredMouse(self: *Workspace, index: usize) void { + const slot = &self.surfaces[index]; self.cancelSurfaceInput(index); const quiet = self.waitInputIdle(index); // A button the program was told is down must not stay down in a session that outlives @@ -2721,22 +2744,6 @@ pub const Workspace = struct { if (slot.program_buttons != 0) { cancelProgramGesture(self, index, .direct); } else slot.mouse_gesture = .none; - self.waitAttach(index); - if (slot.surface) |surface| { - _ = c.winghostty_surface_destroy(surface); - slot.surface = null; - slot.destroyed = true; - } - slot.destroying = false; - if (slot.session_name.len != 0) { - self.allocator.free(slot.session_name); - slot.session_name = &.{}; - } - if (slot.project_path.len != 0) { - self.allocator.free(slot.project_path); - slot.project_path = &.{}; - } - self.resetSessionState(index); } fn surfaceOptions(self: *Workspace, index: usize) c.winghostty_surface_options_v2 { @@ -6506,12 +6513,12 @@ fn armMouseSlot(workspace: *Workspace) !void { try slot.vt.?.feed("\x1b[?1002h\x1b[?1006h"); } -/// A stand-in attach process, so teardown has a live session to write the release to. +/// A stand-in attach process, so teardown has a live session to write the release to. It goes +/// through the same factory as the real attach (no console window), via an alias so the +/// source contract that counts the shell's own spawn sites is not widened by test code. fn spawnTestAttach(workspace: *Workspace) !void { - var child = std.process.Child.init(&.{ "cmd.exe", "/c", "pause" }, workspace.allocator); - child.stdin_behavior = .Pipe; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; + const factory = ZmxSession.child; + var child = factory(workspace.allocator, &.{ "cmd.exe", "/c", "pause" }, null, .attach); try child.spawn(); workspace.surfaces[0].attach = child; workspace.surfaces[0].attach_nonblocking = true; @@ -6644,12 +6651,20 @@ test "mouse ledger: a program that stops and resumes tracking forgets a press st try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); } +/// What destroySurface does while the attach is alive, without the native surface (which +/// a test without the provider library cannot link): the release, then the attach goes. +fn tearDownMouseSlot(workspace: *Workspace) void { + workspace.releaseDeliveredMouse(0); + workspace.waitAttach(0); +} + test "mouse teardown: only the buttons still down are released, where the program last heard from the pointer" { var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); defer workspace.layout.deinit(); defer workspace.input_queue.clear(); MouseLedgerHook.install(&workspace); defer workspace.waitAttach(0); + defer if (workspace.surfaces[0].vt) |vt| vt.destroy(); // Left and right are down; the right is released and a drag is still queued: the drag is // dropped with the queue and the left is released at the right button's release position. @@ -6660,7 +6675,7 @@ test "mouse teardown: only the buttons still down are released, where the progra reportMouse(&workspace, .release, .right, 4, 2); pumpAll(&workspace); reportMouse(&workspace, .motion, .left, 6, 2); - workspace.destroySurface(0); + tearDownMouseSlot(&workspace); try std.testing.expectEqualStrings("\x1b[<0;5;3m", MouseLedgerHook.released()); // A release still queued is lost with the queue, so both buttons are released. @@ -6671,7 +6686,7 @@ test "mouse teardown: only the buttons still down are released, where the progra reportMouse(&workspace, .press, .right, 3, 2); pumpAll(&workspace); reportMouse(&workspace, .release, .right, 4, 2); - workspace.destroySurface(0); + tearDownMouseSlot(&workspace); try std.testing.expectEqualStrings("\x1b[<0;4;3m\x1b[<2;4;3m", MouseLedgerHook.released()); } @@ -6681,6 +6696,7 @@ test "mouse teardown: no release goes to an exited attach, a blocking pipe or a defer workspace.input_queue.clear(); MouseLedgerHook.install(&workspace); defer workspace.waitAttach(0); + defer if (workspace.surfaces[0].vt) |vt| vt.destroy(); const slot = &workspace.surfaces[0]; // The attach exited first (`handleAttachExit` ends it before the surface): the session is gone. @@ -6689,7 +6705,7 @@ test "mouse teardown: no release goes to an exited attach, a blocking pipe or a reportMouse(&workspace, .press, .left, 3, 2); pumpAll(&workspace); workspace.waitAttach(0); - workspace.destroySurface(0); + tearDownMouseSlot(&workspace); try std.testing.expectEqual(@as(usize, 0), MouseLedgerHook.released().len); // A pipe that did not take non-blocking mode could block the shell: no release. @@ -6698,7 +6714,7 @@ test "mouse teardown: no release goes to an exited attach, a blocking pipe or a reportMouse(&workspace, .press, .left, 3, 2); pumpAll(&workspace); slot.attach_nonblocking = false; - workspace.destroySurface(0); + tearDownMouseSlot(&workspace); try std.testing.expectEqual(@as(usize, 0), MouseLedgerHook.released().len); // A write that does not stop within the wait may still be using the pipe: no second write @@ -6711,7 +6727,7 @@ test "mouse teardown: no release goes to an exited attach, a blocking pipe or a workspace.input_mutex.lock(); const in_flight = workspace.takeInputLocked().?; workspace.input_mutex.unlock(); - workspace.destroySurface(0); + tearDownMouseSlot(&workspace); try std.testing.expectEqual(@as(usize, 0), MouseLedgerHook.released().len); workspace.deliverInput(in_flight); try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); From 21b21cf143e5448a73823c9a1a022b4958b093d4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sat, 10 Oct 2026 10:15:37 -0700 Subject: [PATCH 3/3] Invalidate the mouse ledger when tracking is switched off, and keep owed releases ahead of the next press A program that switches mouse tracking off and on again within one buffer (or one sequence split across two) left the ledger standing, so teardown released into a new tracking epoch and an in-flight press could still commit. The VT parser now counts every on-to-off transition, including one switched straight back on, and the workspace invalidates the ledger after each buffer of output. A release that could not be queued is now queued ahead of the next press of the same button (or that press is held back while it still cannot be), so the worker no longer clears the new press with the old release. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 35 ++++++ graphcode-windows/src/TerminalSurface.zig | 142 +++++++++++++++++++--- graphcode-windows/src/TerminalVt.zig | 91 +++++++++++++- investigation/ui-parity-matrix.md | 2 +- 4 files changed, 249 insertions(+), 21 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 33c498fb..0a086dbd 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -14444,6 +14444,41 @@ test "live terminal mouse: a press that could not be queued is not released at t try std.testing.expectEqual(@as(usize, 0), TeardownCapture.length); } +/// A delivered press, then the program's output switches mouse tracking off and on again, +/// reaching the shell only through `feedTerminalOutput` and with no mouse event after it. +fn expectTrackingResetForgetsPress(output: []const []const u8) !void { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{.{ .id = "loop-a" }}); + defer fixture.deinit(); + try fixture.setLive(&.{"loop-a"}); + try clickSidebarLoopRow(&fixture.app, fixture.project, "loop-a"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-a"); + var keyboard = try LiveKeyboard.begin(&fixture, "loop-a"); + defer keyboard.end(); + const mouse = try LiveMouse.begin(&keyboard); + const buttons = LiveMouseButtons{ .mouse = mouse }; + try feedMouseFixture(&fixture, keyboard.index); + const workspace = &fixture.workspace; + try workspace.surfaces[keyboard.index].vt.?.feed("\x1b[?1002h\x1b[?1006h"); + TeardownCapture.length = 0; + workspace.teardown_input_sink = &TeardownCapture.write; + + buttons.press(.left, 0, 3, 2); + try expectSentToProgram(&keyboard, "\x1b[<0;4;3M"); + for (output) |part| workspace.feedTerminalOutput(keyboard.index, part); + try std.testing.expect(workspace.surfaces[keyboard.index].vt.?.mouseTrackingEnabled()); + workspace.destroySurface(keyboard.index); + try std.testing.expectEqual(@as(usize, 0), TeardownCapture.length); +} + +test "live terminal mouse: tracking switched off and on in one output buffer forgets a delivered press" { + try expectTrackingResetForgetsPress(&.{"\x1b[?1002l\x1b[?1002h"}); +} + +test "live terminal mouse: tracking switched off by a sequence split across output buffers forgets a delivered press" { + try expectTrackingResetForgetsPress(&.{ "text\x1b[?10", "02l\x1b[?1002h" }); +} + test "live terminal mouse: the teardown release is not written when the attach pipe is not non-blocking" { var fixture: LiveTerminalFixture = undefined; try fixture.init(&.{.{ .id = "loop-a" }}); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 41f67182..af7f3490 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -350,6 +350,8 @@ pub const Surface = struct { delivered_buttons: u8 = 0, delivered_pointer: struct { x: i32 = 0, y: i32 = 0, ctrl: bool = false } = .{}, release_owed: u8 = 0, + // How many mouse-tracking resets of this surface's VT parser the ledger has been told of. + mouse_resets_seen: u32 = 0, // Buttons whose gesture was cancelled while the user still holds them; their release is // swallowed. See swallowsRelease. swallow_buttons: u8 = 0, @@ -361,6 +363,7 @@ pub const Surface = struct { self.vt = null; self.mouse_gesture = .none; self.program_buttons = 0; + self.mouse_resets_seen = 0; self.swallow_buttons = 0; self.wheel_remainder = 0; self.clearSelection(); @@ -2881,26 +2884,33 @@ pub const Workspace = struct { self.surfaces[index].release_owed |= buttons; } - /// Queues again the releases that could not be queued when the user let go, once there is - /// room, for buttons the program was really told are down. A program that has stopped - /// tracking the mouse is told nothing. - fn retryOwedReleases(self: *Workspace) void { - for (&self.surfaces, 0..) |*slot, index| { - self.input_mutex.lock(); - const owed = slot.release_owed; - const pointer = slot.delivered_pointer; - slot.release_owed = 0; - self.input_mutex.unlock(); - if (owed == 0) continue; - const state = slot.vt orelse continue; - if (!state.mouseTrackingEnabled()) { - self.input_mutex.lock(); - self.forgetDeliveredMouseLocked(slot); - self.input_mutex.unlock(); - continue; - } - releaseProgramButtons(self, index, owed, pointer.x, pointer.y, pointer.ctrl, .queued); + /// Queues again the releases that could not be queued when the user let go, for the + /// buttons asked about, once there is room. Returns whether none of them is still owed. + /// A press of the same button must not be queued ahead of its owed release (the worker would + /// then clear the new press with the old release), so `reportMouseToProgram` calls this + /// first. A release goes to the program only for a button it was really told is down, and + /// one for a program that has stopped tracking the mouse is not sent. + fn flushOwedReleases(self: *Workspace, index: usize, buttons: u8) bool { + const slot = &self.surfaces[index]; + self.input_mutex.lock(); + const owed = slot.release_owed & buttons; + const pointer = slot.delivered_pointer; + slot.release_owed &= ~owed; + self.input_mutex.unlock(); + if (owed == 0) return true; + const state = slot.vt orelse return true; + if (!state.mouseTrackingEnabled()) { + self.forgetDeliveredMouse(slot); + return true; } + releaseProgramButtons(self, index, owed, pointer.x, pointer.y, pointer.ctrl, .queued); + self.input_mutex.lock(); + defer self.input_mutex.unlock(); + return (slot.release_owed & owed) == 0; + } + + fn retryOwedReleases(self: *Workspace) void { + for (0..self.surfaces.len) |index| _ = self.flushOwedReleases(index, 0xFF); } /// Writes bytes straight to a surface's session. Used only while the surface is being torn @@ -3306,6 +3316,17 @@ pub const Workspace = struct { self.surfaces[index].resetOutput(); } + /// Whatever the program was sent before it switched mouse tracking off no longer counts, + /// however soon it switched it on again, and writes still in flight are voided. Runs after + /// every buffer of output, with no mouse event needed to notice. + fn noteMouseTrackingResets(self: *Workspace, index: usize) void { + const slot = &self.surfaces[index]; + const state = slot.vt orelse return; + if (state.mouse_tracking_resets == slot.mouse_resets_seen) return; + slot.mouse_resets_seen = state.mouse_tracking_resets; + self.forgetDeliveredMouse(slot); + } + pub fn feedTerminalOutput(self: *Workspace, index: usize, bytes: []const u8) void { const slot = &self.surfaces[index]; const surface = slot.surface orelse return; @@ -3316,6 +3337,7 @@ pub const Workspace = struct { .rows = slot.grid.rows, }); self.routeVtResponses(index); + self.noteMouseTrackingResets(index); self.render_error = result.render_result; if (!std.meta.eql(previous, slot.output_result)) slot.output_result.logFailures(index); } @@ -6045,6 +6067,9 @@ fn reportMouseToProgram( }; const bit = programButtonBit(report.button); if (report.action == .press) { + // The release this button still owes goes first; if it cannot be queued yet, + // neither can this press, which would otherwise overtake it. + if (!workspace.flushOwedReleases(index, bit)) return; report.any_button_pressed = true; slot.program_buttons |= bit; // Whatever the shell had selected is no longer what the user is pointing at. @@ -6658,6 +6683,85 @@ fn tearDownMouseSlot(workspace: *Workspace) void { workspace.waitAttach(0); } +test "mouse ledger: tracking switched off and on within one buffer voids a press still in flight, with no mouse event" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + try armMouseSlot(&workspace); + defer workspace.surfaces[0].vt.?.destroy(); + MouseLedgerHook.install(&workspace); + const slot = &workspace.surfaces[0]; + + reportMouse(&workspace, .press, .left, 3, 2); + pumpAll(&workspace); + try std.testing.expectEqual(@as(u8, 1), slot.delivered_buttons); + reportMouse(&workspace, .press, .right, 3, 2); + workspace.input_mutex.lock(); + const in_flight = workspace.takeInputLocked().?; + workspace.input_mutex.unlock(); + // The parser's output path (`feedTerminalOutput` does the same after publishing a buffer). + try slot.vt.?.feed("\x1b[?1002l\x1b[?1002h"); + workspace.noteMouseTrackingResets(0); + try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); + workspace.deliverInput(in_flight); + try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); +} + +fn programMouseEvent(kind: c_int, col: i32, row: i32, held: u32) c.winghostty_mouse_event { + var event = std.mem.zeroes(c.winghostty_mouse_event); + event.kind = @intCast(kind); + event.button = 1; + event.modifiers = held; + event.x = col * 8 + 4; + event.y = row * 16 + 8; + return event; +} + +fn expectOwedReleaseStaysAheadOfNextPress(room: bool) !void { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + defer workspace.input_queue.clear(); + try armMouseSlot(&workspace); + defer workspace.surfaces[0].vt.?.destroy(); + MouseLedgerHook.install(&workspace); + const slot = &workspace.surfaces[0]; + var down = programMouseEvent(c.WINGHOSTTY_MOUSE_BUTTON_DOWN, 3, 2, c.MK_LBUTTON); + var up = programMouseEvent(c.WINGHOSTTY_MOUSE_BUTTON_UP, 3, 2, 0); + + reportMouseToProgram(&workspace, 0, &down); + pumpAll(&workspace); + try fillInputQueue(&workspace); + reportMouseToProgram(&workspace, 0, &up); + try std.testing.expectEqual(@as(u8, 1), slot.release_owed); + if (room) { + workspace.input_queue.clear(); + } else { + // Still no room: the next press must not be queued, or it would overtake the release it + // owes, and a release of that press is then the program's only one. + reportMouseToProgram(&workspace, 0, &down); + try std.testing.expectEqual(input_queue_capacity, workspace.input_queue.count); + try std.testing.expectEqual(@as(u8, 0), slot.program_buttons); + workspace.input_queue.clear(); + } + // With room, the owed release is queued first, then the press, then that press's release. + reportMouseToProgram(&workspace, 0, &down); + reportMouseToProgram(&workspace, 0, &up); + pumpAll(&workspace); + try std.testing.expectEqualStrings( + "\x1b[<0;4;3M\x1b[<0;4;3m\x1b[<0;4;3M\x1b[<0;4;3m", + MouseLedgerHook.written(), + ); + try std.testing.expectEqual(@as(u8, 0), slot.delivered_buttons); + try std.testing.expectEqual(@as(u8, 0), slot.release_owed); +} + +test "mouse ledger: a release that could not be queued stays ahead of the next press of that button" { + try expectOwedReleaseStaysAheadOfNextPress(true); +} + +test "mouse ledger: a press is not queued ahead of a release that still cannot be" { + try expectOwedReleaseStaysAheadOfNextPress(false); +} test "mouse teardown: only the buttons still down are released, where the program last heard from the pointer" { var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); defer workspace.layout.deinit(); diff --git a/graphcode-windows/src/TerminalVt.zig b/graphcode-windows/src/TerminalVt.zig index 7837809a..6ec0212f 100644 --- a/graphcode-windows/src/TerminalVt.zig +++ b/graphcode-windows/src/TerminalVt.zig @@ -218,6 +218,10 @@ pub const State = struct { response_delivery_failed: bool = false, mouse_encoder: c.GhosttyMouseEncoder = null, mouse_last_cell: ?[2]u32 = null, + /// How many times the program has switched mouse tracking from on to off (counting a + /// reset that is switched back on within the same buffer); see `feed`. + mouse_tracking_resets: u32 = 0, + mode_scan: enum { ground, escape, csi, private } = .ground, pub fn create(allocator: std.mem.Allocator, columns: usize, rows: usize) Error!*State { const size = try dimensions(columns, rows); @@ -256,12 +260,66 @@ pub const State = struct { if (self.failure) |err| return err; self.snapshot_current = false; // vt_write returns void. A completed call is not a parse-success result. - c.ghostty_terminal_vt_write(self.terminal, bytes.ptr, bytes.len); + // The mouse-tracking state is looked at after every sequence that can switch it off, not + // only at the end, so a program that switches it off and on again within one buffer (or + // one sequence split across two) is still seen to have done so. + var was_tracking = self.mouseTrackingEnabled(); + var start: usize = 0; + for (bytes, 0..) |byte, index| { + if (!self.endsMouseModeSequence(byte)) continue; + c.ghostty_terminal_vt_write(self.terminal, bytes[start..].ptr, index + 1 - start); + start = index + 1; + was_tracking = self.noteMouseTracking(was_tracking); + } + if (start < bytes.len or bytes.len == 0) { + c.ghostty_terminal_vt_write(self.terminal, bytes[start..].ptr, bytes.len - start); + _ = self.noteMouseTracking(was_tracking); + } try self.refresh(); if (self.response_delivery_failed) return error.ResponseDeliveryFailed; if (self.response_overflow) return error.ResponseOverflow; } + fn noteMouseTracking(self: *State, was_tracking: bool) bool { + const tracking = self.mouseTrackingEnabled(); + if (was_tracking and !tracking) self.mouse_tracking_resets +%= 1; + return tracking; + } + + /// Follows just enough of the escape sequences in the stream to tell where one that can + /// switch mouse tracking off ends: a private-mode reset (CSI ? ... l) or a full + /// terminal reset (ESC c). Other bytes only move it along; a wrong guess + /// (a sequence inside a string) costs a split of the buffer and nothing else. + fn endsMouseModeSequence(self: *State, byte: u8) bool { + const next: @TypeOf(self.mode_scan) = switch (self.mode_scan) { + .ground => if (byte == 0x1b) .escape else .ground, + .escape => switch (byte) { + '[' => .csi, + 0x1b => .escape, + 'c' => return self.endSequence(), + else => .ground, + }, + .csi => switch (byte) { + '?' => .private, + 0x1b => .escape, + else => .ground, + }, + .private => switch (byte) { + '0'...'9', ';', ':' => .private, + 'l' => return self.endSequence(), + 0x1b => .escape, + else => .ground, + }, + }; + self.mode_scan = next; + return false; + } + + fn endSequence(self: *State) bool { + self.mode_scan = .ground; + return true; + } + pub fn resize(self: *State, columns: usize, rows: usize) Error!void { const size = try dimensions(columns, rows); if (self.failure) |err| return err; @@ -853,6 +911,37 @@ test "VT reports whether the program asked for mouse tracking" { try std.testing.expect(!state.mouseTrackingEnabled()); } +test "VT counts every time the program switched mouse tracking off, even when it is switched back on at once" { + const state = try State.create(std.testing.allocator, 20, 3); + defer state.destroy(); + try state.feed("\x1b[?1002h\x1b[?1006h"); + // Plain text, other private modes and turning modes on or off beside tracking change nothing. + try state.feed("hello l h \x1b[?25l\x1b[?25h\x1b[?1006l\x1b[?1002h"); + try std.testing.expectEqual(@as(u32, 0), state.mouse_tracking_resets); + + // Off and on within one buffer: the state afterwards is the same, the count is not. + try state.feed("\x1b[?1002l\x1b[?1002h"); + try std.testing.expect(state.mouseTrackingEnabled()); + try std.testing.expectEqual(@as(u32, 1), state.mouse_tracking_resets); + + // The sequence split across two buffers, with the second also turning it back on. + try state.feed("text\x1b[?10"); + try state.feed("02l\x1b[?1002h"); + try std.testing.expectEqual(@as(u32, 2), state.mouse_tracking_resets); + + // Switching to another tracking mode passes through off, as does a full reset. + try state.feed("\x1b[?1002l\x1b[?1000h"); + try std.testing.expectEqual(@as(u32, 3), state.mouse_tracking_resets); + try state.feed("\x1bc\x1b[?1000h"); + try std.testing.expectEqual(@as(u32, 4), state.mouse_tracking_resets); + + // Off while already off is not a transition. + try state.feed("\x1b[?1000l"); + try std.testing.expectEqual(@as(u32, 5), state.mouse_tracking_resets); + try state.feed("\x1b[?1000l"); + try std.testing.expectEqual(@as(u32, 5), state.mouse_tracking_resets); +} + test "mouse events are encoded in the tracking mode and format the program chose" { const state = try State.create(std.testing.allocator, 200, 24); defer state.destroy(); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 47d29305..9583530d 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -246,7 +246,7 @@ case proves ownership only. All existing Partial rows remain Partial. | Reproducible DPI/geometry regression coverage | Control metrics for GraphCode-owned chrome scale correctly and predictably across 100/125/150/200% DPI | `Tools/windows/visual-baseline.ps1` previously only checked that each DPI variant's `scale`/`viewport` were present and positive. It now reimplements `Dpi.zig`'s exact `scale()` rounding formula, self-checked against `Dpi.zig`'s own fixed-point unit-test cases, reads the real base pixel values straight out of `DesignTokens.zig` (not a copy baked into the manifest), and asserts the scaled geometry for `sidebar_width`, `tab_bar_height`, `pane_header_height`, and `loop_bar_height` at the real Windows per-monitor DPI values (96/120/144/192) is monotonic and matches the 96-DPI base exactly at 100%. Every `regionGeometry` entry is required to target a `deterministicScreenshotRegions` (GraphCode-owned) region, never a Winghostty-owned one, keeping third-party terminal pixels structurally out of scope. **This check performs static manifest/geometry metadata validation, not rendered-output comparison: it never launches the app, captures a window, or rasterizes a bitmap.** It re-derives expected numeric geometry from source-of-truth code and checks the manifest against that math, which is materially stronger than the prior presence-only checks and does catch real drift, but it is not a screenshot diff and should not be read as one; this repo/CI has no deterministic way to rasterize a live Win32 window. Manually re-verified that corrupting either a DPI value or a `DesignTokens.zig` constant makes the script fail | Validated | | Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | The Loop, View, and File menus label Show in Graph (Ctrl+Shift+G), Reconnect (Ctrl+R), and Project Worktree Policy (Ctrl+Shift+P); Help > Keyboard Shortcuts lists remaining useful mapped routes (Ctrl+M, context-sensitive Ctrl+E, Ctrl+Up/Down, worktree Up/Down, terminal focus 1/2, Ctrl+Shift+X, Ctrl+Shift+V) and toolbar/palette navigation. Context menus retain their applicable F2, Delete, Ctrl+S, Ctrl+N, and Quick Chat labels. No gesture hints are advertised. Canvas/pointer discovery remains incomplete: canvas click-to-select/open, blank-canvas drag-to-pan, node drag-to-move, connector drag-to-create-edge, wheel/pinch zoom, right-click context-menu access, and sidebar root-loop drag-to-reorder still lack visible gesture hints. Closing the canvas gesture gap requires rendering in the canvas-owning `GraphCanvas.zig`; sidebar reorder guidance belongs to excluded `Sidebar.zig`. Both are outside this PR's scope. The hidden-window regression inspects the production HMENU labels and verifies keyboard Help content has no gesture rows. RED: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" --test-filter 'main and help menus expose missing shortcuts without canvas gesture claims'` -> failed because 10 keyboard-only rows were expected but the Help menu contained 18 rows, including gesture claims. GREEN: same focused pinned-Zig command after the change -> 1 test passed. REGRESSION: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include"` -> all 48 MainWindow and imported InputRouter tests passed; the 18-entry accelerator assertion and original 15-binding order remain unchanged. Ctrl+P duplicates Ctrl+J; Ctrl+Shift+I duplicates Ctrl+Shift+W. The earlier claim that Ctrl+Shift+C is a binding conflict was wrong: a source audit of the accelerator table, `pretranslateKey`, `onShellKey`, `fallbackKeyAction` and `InputRouter.keyAction` found no Ctrl+Shift+C accelerator and no dispatch collision. The chord deliberately copies while a terminal is focused and clones otherwise, and Clone stays reachable from a terminal through File > Add Folder. The real defect was discovery text, now fixed: the File menu reads `Clone Repository...\tCtrl+Shift+C outside terminal` and Help > Keyboard Shortcuts carries a greyed `Copy terminal text\tCtrl+Shift+C` entry immediately before Paste. A MainWindow test asserts a positive accelerator count, zero Ctrl+Shift+C accelerators, `keyAction('C', ctrl, shift) == clone_repository`, both exact labels, and that the Copy entry is informational with command ID 0; MainWindow passed 50/50 and InputRouter 9/9, and the hosted integration job 109828242393 ran the live Add Folder menu check against the qualified label. These are menu/source and live-menu tests, not physical keyboard, touch-device, or macOS runtime evidence, and the canvas and sidebar gesture hints above are still absent; the row remains Partial. Terminal-focused keys now follow one rule: Ctrl+D, W, S, T, N, `[` and `]` go to the shell instead of the matching menu accelerators, which stay available elsewhere and have terminal-safe alternatives (Ctrl+Shift+T new tab, Ctrl+Shift+N new loop, Alt+Shift+D split right, Ctrl+Shift+W close tab, Ctrl+Shift+[ / ] pane focus) that the Terminal and Loop menus now label; Help > Keyboard Shortcuts states the rule. F6 and F10 follow the same rule: while a terminal has focus they are encoded to it (`ESC[17~`, `ESC[21~`; Shift+F10 stays the terminal context menu) instead of entering the window toolbar or the native menu bar, and the shell keeps Ctrl+Shift+F6 (window toolbar) and Ctrl+Shift+F10 (menu bar) for those, labelled in View > Focus Window Toolbar and Help > Keyboard Shortcuts. Evidence is in-process: routing and dispatch unit tests, a hidden real-window fixture that checks a terminal-owned F10 is neither buffered nor passed to default menu processing, and a production-dispatch test that F1-F12 each enqueue their encoder sequence once. Known limitation, Ctrl+S: plain Ctrl+S is still the raw 0x13 byte. In a program that reads with line input on (for example pwsh `[Console]::ReadKey`) conhost's pause-output handling swallows Ctrl+S and the next key, while Ctrl+T alone works; a win32-input-mode workaround was rejected because an isolated zmx 785b3fd matrix showed it breaks Git MSYS vim (Enter no longer honored) and makes cmd.exe insert a literal ^S. That evidence is not a Dev Box walkthrough, and Ctrl+S is not claimed to work. Tests assert only that Ctrl+S/T/D/W/N add no shell-encoded bytes. The App `live terminal keyboard` key table does drive synthesized WM_KEYDOWN through the production dispatch into a real Winghostty surface (F6, Shift+F6, F10 in both key-down families, Ctrl+F10, F11 and F12 reach the input queue; Ctrl+Shift+F10 posts SC_KEYMENU), but no physical keyboard, real conhost, or Dev Box walkthrough ran, and menu mnemonics (Alt, Alt+letter) are terminal input while a terminal has focus | Partial | | IME/dead keys/layouts | Native composition in forms and terminal | Fixed a real terminal input gap: Winghostty reports preedit and committed result text through `on_ime_update`, but `TerminalSurface.onImeUpdate` discarded both; it now ignores preedit and enqueues only committed UTF-8 through the same bounded terminal input queue as ordinary text. A focused executable test invokes that production callback and proves uncommitted text is absent while committed Japanese text reaches the correct surface queue. `MainWindow.Window.dispatchMessage` executable tests create a real Unicode `EDIT` control and observe `WM_IME_STARTCOMPOSITION` / `WM_IME_COMPOSITION` / `WM_IME_ENDCOMPOSITION` at its native window procedure, then activate US-International and French layouts and exercise the production `TranslateMessage`/`DispatchMessageW` path with physical scan codes, observing a real dead-key `WM_DEADCHAR` followed by `é` and French scan-code mapping to `a`; unavailable layouts are explicitly logged and skipped rather than silently weakened. These are hidden-window Win32 runtime tests, not a foreground UIA walkthrough or validation with a live IME candidate window, and terminal preedit rendering remains provider-owned, so the row remains Partial. A native-linked test now also drives US-International dead keys (`'` then `e`/`a` committing `é`/`á`) and German AltGr+Q (`@`) through the production dispatch into a real Winghostty terminal surface and observes the bytes queued for the shell, so the shell's own key encoding cannot swallow or double them; it is in-process evidence, not a physical keyboard or live IME walkthrough | Partial | -| Clipboard/selection | Terminal copy/paste and mouse selection | Terminal-context Ctrl+Shift+C copies the active surface's mouse selection (read from the shell's own VT through the pinned libghostty-vt formatter) or, when there is none, its UI Automation-reported selection range through Winghostty into Windows `CF_UNICODETEXT`; Ctrl+Shift+V or Shift+Insert reads only `CF_UNICODETEXT`, converts UTF-16 to UTF-8, normalises line endings, and encodes it with the pinned `ghostty_paste_encode` (the control bytes xterm blanks, including ESC, become spaces, as do the C1 controls U+0080 to U+009F; BEL and tab pass through; text over 1,000,000 UTF-8 bytes is refused before it is converted or copied), wrapping it in bracketed-paste markers only when the running program set DECSET 2004 (read from the shell's own VT state; Winghostty's `winghostty_surface_paste_text` always brackets and does not sanitize, so it is not used, and the shell no longer registers the provider's `on_paste` callback; in the pinned provider that callback is reached only from that exported function, which the shell never calls, and the host has no WM_PASTE, drag-and-drop, middle-click or context-menu paste; a live test calls the provider entry point and observes nothing reach the input queue). A successful terminal copy intentionally replaces every clipboard format with plain Unicode text (macOS does the same with `clearContents` then `setString`); nothing earlier is read or restored, because reading another program's delayed-render data has no timeout and restoring only its text would drop the history and cloud opt-out formats. The new text is copied into a clipboard block before the clipboard is opened, so running out of memory cannot happen after it is emptied; a copy the clipboard then refuses fails with an ordinary write failure that can leave the clipboard empty, and the status says so (Clipboard.zig tests use a stand-in clipboard with open, empty, publish and free counters that refuses calls made in the wrong order (empty while closed, publish before empty) and checks the staged block is freed exactly once on every failure and never after success, not the real one; the native Open/Empty/SetClipboardData path is still unexecuted, and RDP or Dev Box copy and paste through rdpclip is not verified). Pinned Zig `Clipboard.zig` tests pass 16/16 (its 15 tests plus the imported Win32 one) including that stand-in and the size limit; app routing tests prove the shortcuts are terminal-context-only and preserve the existing global Ctrl+Shift+C Clone Repository route. Multi-line text for a program that did not enable bracketed paste asks for confirmation before it is sent (macOS auto-confirms with no dialog; bracketed pastes are never prompted); a declined paste sends nothing. A measured ConPTY probe (pwsh 7.6.6, PSReadLine 2.4.5) shows pwsh never requests bracketed paste and runs each pasted line as soon as it arrives, exactly as cmd.exe does, so pwsh is asked too and that confirmation is deliberate, not a defect. The winghostty modifier mask (Shift 0x04, Ctrl 0x08, Alt 0x80) was previously decoded as GhosttyMods bits, so these chords never routed and Shift+Tab was treated as loop navigation; the mask is now decoded correctly and the translated `^C`/`^V` characters are discarded for chords the shell handles. Terminal menu Copy/Paste, a terminal context menu (right-click, Menu key, Shift+F10), and Ctrl+Insert/Shift+Insert exist; plain Ctrl+C copies only while the surface reports a selection. Evidence is in-process: unit tests, native-linked tests that drive real WM_KEYDOWN/WM_CHAR and real mouse messages (press, drag, double click, Shift+click, release) through a real Winghostty surface and check the resulting selection text, the colors published to the host, and the copy route (through a clipboard seam); and menu tests. They did not use the real clipboard, a Dev Box, or a physical keyboard or mouse. Mouse selection follows Ghostty: drag selects with a visible highlight (the host draws none, so the shell swaps foreground and background in the snapshot it publishes), double click selects a word, triple click a soft-wrapped logical line, Shift+click extends from the far end, a click or typing clears, soft-wrapped rows copy as one line and trailing blanks are dropped. It is tracked in screen coordinates, so it stays on its text as output scrolls, and it ends on resize, paste, typing and an alternate-screen switch. Not done: auto-scroll while dragging past the pane, block (rectangular) selection, scrollback viewport scrolling by wheel (not wired in the shell at all yet), and the selection is not adjusted if more than 10,000 scrollback rows are pruned underneath it. It needs the VT parser, which is the default; the `GRAPHCODE_EXPERIMENTAL_TERMINAL_VT=0` opt-out has no mouse selection. The full Windows shell compiles against the pinned provider, but the probe did not reach any named live residual: the focused clipboard tests do not execute `OpenClipboard`/`GetClipboardData`/`SetClipboardData`; a real copy would replace the shared desktop clipboard, and no isolated clipboard desktop is available. `uia-live-gate.ps1` can reacquire `graphcode-root` through `FromHandle` while backgrounded, but its UI actions explicitly reacquire foreground and its mouse/key helpers post to the shell window; it has no clipboard-specific action/assertion or terminal text/selection fixture. A successful build is not runtime terminal evidence, so the UIA gate was not run for this row. Real-desktop mouse-driven selection and the rendered highlight pixels were not observed; the tests above check the cells handed to the host, not what it drew. The registered `onClipboardRead` and `onClipboardWrite` callbacks still discard their event data; `onSelection` now only ends a drag when the host loses mouse capture. End-to-end copy/paste likewise remains untested without a live Dev Box walkthrough and desktop input; the row remains Partial. Mouse reporting to terminal programs: while the VT state shows DECSET 9/1000/1002/1003 the left, middle and right buttons, motion and wheel are encoded by the pinned libghostty-vt mouse encoder (X10, UTF-8, SGR, URXVT and SGR-pixels formats) and queued to the shell, Shift keeps the local selection (and the context menu on right-click), and a gesture keeps the route it began on; before this, every left press became a local selection and mouse-aware TUIs (vim, tmux, htop, the Copilot CLI) received nothing. This is evidenced only by exact-byte VT tests and in-process synthetic mouse messages to a real Winghostty surface window feeding the fake zmx stand-in: no real tmux, vim, pwsh, conhost or Dev Box, no physical mouse, and not Alt, side buttons, horizontal wheel or WM_CANCELMODE (see the README); lost capture, lost focus and a release outside the window are closed by releasing the program's buttons, tested with synthetic messages only; when a surface is torn down (a recreate re-attaches the same session) the buttons the program was really sent as down are released at the last position it was sent, tracked by a ledger the input worker updates only after a complete write, so a press still queued gets no release and a delivered one gets exactly one however much input is queued behind it (stand-in pipe and a test write seam only: no real attach pipe, worker thread race, conhost or Dev Box); hover under 1003 and wheel were checked only as bytes in the input queue | Partial | +| Clipboard/selection | Terminal copy/paste and mouse selection | Terminal-context Ctrl+Shift+C copies the active surface's mouse selection (read from the shell's own VT through the pinned libghostty-vt formatter) or, when there is none, its UI Automation-reported selection range through Winghostty into Windows `CF_UNICODETEXT`; Ctrl+Shift+V or Shift+Insert reads only `CF_UNICODETEXT`, converts UTF-16 to UTF-8, normalises line endings, and encodes it with the pinned `ghostty_paste_encode` (the control bytes xterm blanks, including ESC, become spaces, as do the C1 controls U+0080 to U+009F; BEL and tab pass through; text over 1,000,000 UTF-8 bytes is refused before it is converted or copied), wrapping it in bracketed-paste markers only when the running program set DECSET 2004 (read from the shell's own VT state; Winghostty's `winghostty_surface_paste_text` always brackets and does not sanitize, so it is not used, and the shell no longer registers the provider's `on_paste` callback; in the pinned provider that callback is reached only from that exported function, which the shell never calls, and the host has no WM_PASTE, drag-and-drop, middle-click or context-menu paste; a live test calls the provider entry point and observes nothing reach the input queue). A successful terminal copy intentionally replaces every clipboard format with plain Unicode text (macOS does the same with `clearContents` then `setString`); nothing earlier is read or restored, because reading another program's delayed-render data has no timeout and restoring only its text would drop the history and cloud opt-out formats. The new text is copied into a clipboard block before the clipboard is opened, so running out of memory cannot happen after it is emptied; a copy the clipboard then refuses fails with an ordinary write failure that can leave the clipboard empty, and the status says so (Clipboard.zig tests use a stand-in clipboard with open, empty, publish and free counters that refuses calls made in the wrong order (empty while closed, publish before empty) and checks the staged block is freed exactly once on every failure and never after success, not the real one; the native Open/Empty/SetClipboardData path is still unexecuted, and RDP or Dev Box copy and paste through rdpclip is not verified). Pinned Zig `Clipboard.zig` tests pass 16/16 (its 15 tests plus the imported Win32 one) including that stand-in and the size limit; app routing tests prove the shortcuts are terminal-context-only and preserve the existing global Ctrl+Shift+C Clone Repository route. Multi-line text for a program that did not enable bracketed paste asks for confirmation before it is sent (macOS auto-confirms with no dialog; bracketed pastes are never prompted); a declined paste sends nothing. A measured ConPTY probe (pwsh 7.6.6, PSReadLine 2.4.5) shows pwsh never requests bracketed paste and runs each pasted line as soon as it arrives, exactly as cmd.exe does, so pwsh is asked too and that confirmation is deliberate, not a defect. The winghostty modifier mask (Shift 0x04, Ctrl 0x08, Alt 0x80) was previously decoded as GhosttyMods bits, so these chords never routed and Shift+Tab was treated as loop navigation; the mask is now decoded correctly and the translated `^C`/`^V` characters are discarded for chords the shell handles. Terminal menu Copy/Paste, a terminal context menu (right-click, Menu key, Shift+F10), and Ctrl+Insert/Shift+Insert exist; plain Ctrl+C copies only while the surface reports a selection. Evidence is in-process: unit tests, native-linked tests that drive real WM_KEYDOWN/WM_CHAR and real mouse messages (press, drag, double click, Shift+click, release) through a real Winghostty surface and check the resulting selection text, the colors published to the host, and the copy route (through a clipboard seam); and menu tests. They did not use the real clipboard, a Dev Box, or a physical keyboard or mouse. Mouse selection follows Ghostty: drag selects with a visible highlight (the host draws none, so the shell swaps foreground and background in the snapshot it publishes), double click selects a word, triple click a soft-wrapped logical line, Shift+click extends from the far end, a click or typing clears, soft-wrapped rows copy as one line and trailing blanks are dropped. It is tracked in screen coordinates, so it stays on its text as output scrolls, and it ends on resize, paste, typing and an alternate-screen switch. Not done: auto-scroll while dragging past the pane, block (rectangular) selection, scrollback viewport scrolling by wheel (not wired in the shell at all yet), and the selection is not adjusted if more than 10,000 scrollback rows are pruned underneath it. It needs the VT parser, which is the default; the `GRAPHCODE_EXPERIMENTAL_TERMINAL_VT=0` opt-out has no mouse selection. The full Windows shell compiles against the pinned provider, but the probe did not reach any named live residual: the focused clipboard tests do not execute `OpenClipboard`/`GetClipboardData`/`SetClipboardData`; a real copy would replace the shared desktop clipboard, and no isolated clipboard desktop is available. `uia-live-gate.ps1` can reacquire `graphcode-root` through `FromHandle` while backgrounded, but its UI actions explicitly reacquire foreground and its mouse/key helpers post to the shell window; it has no clipboard-specific action/assertion or terminal text/selection fixture. A successful build is not runtime terminal evidence, so the UIA gate was not run for this row. Real-desktop mouse-driven selection and the rendered highlight pixels were not observed; the tests above check the cells handed to the host, not what it drew. The registered `onClipboardRead` and `onClipboardWrite` callbacks still discard their event data; `onSelection` now only ends a drag when the host loses mouse capture. End-to-end copy/paste likewise remains untested without a live Dev Box walkthrough and desktop input; the row remains Partial. Mouse reporting to terminal programs: while the VT state shows DECSET 9/1000/1002/1003 the left, middle and right buttons, motion and wheel are encoded by the pinned libghostty-vt mouse encoder (X10, UTF-8, SGR, URXVT and SGR-pixels formats) and queued to the shell, Shift keeps the local selection (and the context menu on right-click), and a gesture keeps the route it began on; before this, every left press became a local selection and mouse-aware TUIs (vim, tmux, htop, the Copilot CLI) received nothing. This is evidenced only by exact-byte VT tests and in-process synthetic mouse messages to a real Winghostty surface window feeding the fake zmx stand-in: no real tmux, vim, pwsh, conhost or Dev Box, no physical mouse, and not Alt, side buttons, horizontal wheel or WM_CANCELMODE (see the README); lost capture, lost focus and a release outside the window are closed by releasing the program's buttons, tested with synthetic messages only; when a surface is torn down (a recreate re-attaches the same session) the buttons the program was really sent as down are released at the last position it was sent, tracked by a ledger the input worker updates only after a complete write, so a press still queued gets no release and a delivered one gets exactly one however much input is queued behind it, and a program that switches tracking off (even straight back on in the same output) is taken to have forgotten what it was sent (stand-in pipe and a test write seam only: no real attach pipe, worker thread race, conhost or Dev Box); hover under 1003 and wheel were checked only as bytes in the input queue | Partial | | Per-monitor DPI | Layout and controls scale correctly across monitors | The process now declares real per-monitor-v2 DPI awareness at startup (`Win32.enablePerMonitorDpiAwareness()`, called before any window is created) instead of relying on system-DPI bitmap stretching; without this, Windows never delivers real per-monitor `WM_DPICHANGED` data to a DPI-unaware process. `App.zig` seeds the real startup DPI via `GetDpiForWindow` immediately after window creation (rather than assuming 96 DPI/100% until the first monitor move) and forwards every live `WM_DPICHANGED` to `TerminalWorkspace.Workspace.setDpi()`. Previously, `TerminalSurface.zig`'s `onDpiChanged` callback silently discarded the `dpi`/`scale` winghostty reported, and every terminal surface was created with `font_scale` hardcoded to `1.0`, so terminal text never actually rescaled on a DPI change or on a monitor with non-100% DPI at launch. `Workspace.setDpi()` now propagates the real runtime DPI to every live surface via winghostty's own `winghostty_surface_notify_dpi_changed` + `winghostty_surface_set_font_scale` (the two operations the provider actually exposes for this), and `surfaceOptions()` seeds new surfaces' `font_scale` from the workspace's last-known DPI instead of a fixed `1.0`. Deliberately does not also pre-scale `options.input.cell_width`/`cell_height` (kept at their 96-DPI logical baseline) so the DPI ratio is applied exactly once, through `font_scale`, avoiding double scaling. `onMetricsChanged`/`onAccessibilitySelection`, previously also fully discarded, now record the host's reported cell metrics and terminal text-selection range per surface instead of losing them. Verified with `zig build` (full app, pinned Zig 0.15.2 against the exact pinned Winghostty provider) and `zig test src/TerminalSurface.zig` (new `Dpi.fontScale` unit test plus all 13 pre-existing tests, 14/14). No live multi-monitor walkthrough was recorded (this environment has no interactive multi-DPI desktop), so this remains Partial pending that end-to-end evidence | Partial | | Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Existing `DesignTokens`, repository dialogs and `NativeForms` supply the dark native palette and teaching tiles. [Actual production-renderer captures](visual-baseline/rendered-windows/README.md) now preserve canvas/sidebar, Product Settings and workspace clients at native 96 DPI. The real PNG comparator verifies exact opaque canvas/card/sidebar/dialog samples; setup uses a synthetic disconnected fixture, UIA invocation and native WM_COMMAND, not keyboard-menu proof. Settings retains native light buttons; current macOS Settings uses a native grouped form, so these were not speculatively darkened. Other sheets/state combinations, legibility on every surface and a compatible current macOS capture remain unverified | Partial | | Font rendering quality | Legible, ClearType-quality text on every surface, matching macOS's default anti-aliased text | The shared `AppFont.zig` cache requests Segoe UI at `CLEARTYPE_QUALITY`, with per-DPI native controls and logical-size selection for buffered canvas painting. [Actual 96-DPI glyph samples](visual-baseline/rendered-windows/README.md#what-the-pixels-establish) preserve card/sidebar/Settings-title pixels with 27/31/55 distinct colors; the native button sample has two. These counts and recorded system font-smoothing settings are observations, not legibility thresholds or actual font-face certification. Native control LOGFONT metadata, every-surface/multi-DPI review, terminal-text readability and matched current macOS evidence remain unavailable. No speculative font fix was made | Partial |