From 8f45d90b6398b3bbd6c6889aaef4c2195909c1ca Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 6 Oct 2026 11:10:11 -0700 Subject: [PATCH] Hide the console window of zmx children spawned by the Windows shell graphcode-windows.exe is a GUI-subsystem process, so every console-subsystem zmx.exe child it spawns without CREATE_NO_WINDOW gets a brand-new console. On hosts where Windows Terminal is the default console host, the zmx attach child opened a visible, empty window titled .\zmx.exe that stole foreground. Route the attach, resize, and kill children through ZmxSession.child, which keeps each child's existing stdio contract (attach pipes stdin and stdout) and always sets create_no_window. The resize child already set the flag; attach and kill did not. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 11 ++++++ graphcode-windows/src/TerminalSurface.zig | 13 +----- graphcode-windows/src/WorkspaceTeardown.zig | 5 +-- graphcode-windows/src/ZmxSession.zig | 44 +++++++++++++++++++++ 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index a1129956..aedc101b 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -599,6 +599,17 @@ Assert-Contract ($codespaceDialogSource -match 'WM_CTLCOLORLISTBOX' -and $codesp Assert-Contract ($codespaceDialogSource -match 'IsDialogMessageW') ` "the codespace sheet must remain keyboard navigable" +$zmxSessionSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\ZmxSession.zig") -Raw +$terminalSurfaceSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\TerminalSurface.zig") -Raw +$workspaceTeardownSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\WorkspaceTeardown.zig") -Raw +Assert-Contract ($zmxSessionSource -match '(?s)pub fn child\(.*?\.create_no_window = true;') ` + "zmx children must be created without a console window" +Assert-Contract ($terminalSurfaceSource -notmatch 'std\.process\.Child\.init\(' -and + $workspaceTeardownSource -notmatch 'std\.process\.Child\.init\(' -and + [regex]::Matches($terminalSurfaceSource, 'ZmxSession\.child\(').Count -eq 2 -and + [regex]::Matches($workspaceTeardownSource, 'ZmxSession\.child\(').Count -eq 1) ` + "zmx attach, resize, and kill must spawn through ZmxSession.child so the GUI shell never opens a console window" + $zig = Resolve-TestZig Invoke-Native "Accessibility contract executable tests" { Push-Location $shellRoot diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 3f3e1b57..4f8dfa98 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -1564,11 +1564,7 @@ pub const Workspace = struct { &session_buffer, )).len; } - var child = std.process.Child.init(attach_args[0..attach_len], self.allocator); - child.cwd = self.cwd; - child.stdin_behavior = .Pipe; - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Ignore; + var child = ZmxSession.child(self.allocator, attach_args[0..attach_len], self.cwd, .attach); try child.spawn(); if (child.stdin) |stdin| { var mode: c.DWORD = c.PIPE_NOWAIT; @@ -1704,12 +1700,7 @@ pub const Workspace = struct { self.setInputError("terminal PTY resize tracking allocation failed"); return; }; - var child = std.process.Child.init(command, self.allocator); - child.cwd = self.cwd; - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - child.create_no_window = true; + var child = ZmxSession.child(self.allocator, command, self.cwd, .control); child.spawn() catch { self.allocator.free(session); slot.attempted_resize_size = size; diff --git a/graphcode-windows/src/WorkspaceTeardown.zig b/graphcode-windows/src/WorkspaceTeardown.zig index 5f507585..234333b4 100644 --- a/graphcode-windows/src/WorkspaceTeardown.zig +++ b/graphcode-windows/src/WorkspaceTeardown.zig @@ -342,10 +342,7 @@ pub const Live = struct { defer allocator.free(zmx); const argv = killArgv(allocator, zmx, ids) catch return 0; defer freeKillArgv(allocator, argv); - var child = std.process.Child.init(argv, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; + var child = ZmxSession.child(allocator, argv, null, .control); _ = child.spawnAndWait() catch return 0; return ids.len; } diff --git a/graphcode-windows/src/ZmxSession.zig b/graphcode-windows/src/ZmxSession.zig index 8bfde974..154d99ef 100644 --- a/graphcode-windows/src/ZmxSession.zig +++ b/graphcode-windows/src/ZmxSession.zig @@ -12,6 +12,50 @@ pub fn allocName(allocator: std.mem.Allocator, session: []const u8) ![]u8 { return std.fmt.allocPrint(allocator, prefix ++ "{s}", .{session}); } +pub const ChildStdio = enum { + /// `zmx attach`: the shell writes terminal input and reads terminal output. + attach, + /// One-shot control commands such as `resize` and `kill`. + control, +}; + +/// zmx.exe is a console-subsystem program. Spawned from the GUI shell without +/// CREATE_NO_WINDOW, Windows allocates it a new console, which the default +/// terminal host shows as a visible window that steals foreground. +pub fn child( + allocator: std.mem.Allocator, + argv: []const []const u8, + cwd: ?[]const u8, + stdio: ChildStdio, +) std.process.Child { + var result = std.process.Child.init(argv, allocator); + result.cwd = cwd; + result.stdin_behavior = if (stdio == .attach) .Pipe else .Ignore; + result.stdout_behavior = if (stdio == .attach) .Pipe else .Ignore; + result.stderr_behavior = .Ignore; + result.create_no_window = true; + return result; +} + +test "zmx children keep their stdio contract and never open a console window" { + const attach_argv = [_][]const u8{ "zmx.exe", "attach", "graphcode-alpha", "--size", "80x24" }; + const attach = child(std.testing.allocator, &attach_argv, "C:\\GraphCode\\bin", .attach); + try std.testing.expectEqual(std.process.Child.StdIo.Pipe, attach.stdin_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Pipe, attach.stdout_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, attach.stderr_behavior); + try std.testing.expectEqualStrings("C:\\GraphCode\\bin", attach.cwd.?); + try std.testing.expectEqual(attach_argv.len, attach.argv.len); + try std.testing.expectEqual(true, attach.create_no_window); + + const kill_argv = [_][]const u8{ "zmx.exe", "kill", "graphcode-alpha" }; + const control = child(std.testing.allocator, &kill_argv, null, .control); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stdin_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stdout_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stderr_behavior); + try std.testing.expect(control.cwd == null); + try std.testing.expectEqual(true, control.create_no_window); +} + test "canonical zmx session names add the ownership prefix exactly once" { var storage: [64]u8 = undefined; try std.testing.expectEqualStrings("graphcode-alpha", try nameBuffer("alpha", &storage));