diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index cc61bb7e..f4c39447 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -11226,6 +11226,14 @@ const fake_zmx_script = "pause >nul\r\n" ++ "exit /b 0\r\n"; +/// The installed shell names zmx bare (`zmx.exe`, no GRAPHCODE_ZMX) and finds it beside +/// itself through its working directory, `...\GraphCode\current\bin`, which PATH need not +/// name. This forwarder stands in for that copy; the stand-in it calls keeps its state. +const bare_zmx_forwarder = + "@echo off\r\n" ++ + "call \"%~dp0..\\zmx.cmd\" %*\r\n" ++ + "exit /b %errorlevel%\r\n"; + const LiveLoop = struct { id: []const u8, state: []const u8 = "idle", worktree: ?[]const u8 = null }; const TerminalChild = struct { visible: bool, rect: c.RECT }; @@ -11251,6 +11259,7 @@ const LiveTerminalFixture = struct { try self.tmp.dir.makePath("wt-a"); try self.tmp.dir.makePath("bin"); try self.tmp.dir.writeFile(.{ .sub_path = "zmx.cmd", .data = fake_zmx_script }); + try self.tmp.dir.writeFile(.{ .sub_path = "bin\\zmx.cmd", .data = bare_zmx_forwarder }); self.root = try self.tmp.dir.realpathAlloc(allocator, "."); self.project = try std.fs.path.join(allocator, &.{ self.root, "project" }); self.worktree = try std.fs.path.join(allocator, &.{ self.root, "wt-a" }); @@ -11261,7 +11270,8 @@ const LiveTerminalFixture = struct { self.workspace = .{ .parent = app.window.hwnd, .allocator = allocator, - .zmx_path = try std.fs.path.join(allocator, &.{ self.root, "zmx.cmd" }), + // As installed: a bare name that only the shell's working directory resolves. + .zmx_path = try allocator.dupe(u8, "zmx.cmd"), .cwd = try allocator.dupe(u8, self.bin), .input_queue = .{ .allocator = allocator }, .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, self.project), @@ -11591,6 +11601,71 @@ test "workspace surface: deleting the attached loop while the graph shows leaves } } +/// Every terminal action must report something other than its failure status. +fn expectNoTerminalFailure(app: *App) !void { + for ([_][]const u8{ "Unable to open selected loop", "Unable to create tab", "Unable to split workspace" }) |failure| { + if (std.mem.eql(u8, app.status(), failure)) { + std.debug.print("terminal action failed: status \"{s}\"\n", .{app.status()}); + return error.TestTerminalActionFailed; + } + } +} + +test "workspace surface: sidebar loop opens, New Tab, and Split Right attach visible terminals when only the shell's directory holds zmx" { + var fixture: LiveTerminalFixture = undefined; + try fixture.init(&.{ .{ .id = "loop-fresh" }, .{ .id = "loop-live" } }); + defer fixture.deinit(); + const app = &fixture.app; + const workspace = &fixture.workspace; + // Both loops have a directory of their own, so every attach starts outside the shell's. + try fixture.deliver(&.{ .{ .id = "loop-fresh" }, .{ .id = "loop-live", .worktree = fixture.worktree } }); + + // A fresh loop: the daemon starts its session only after the sidebar click. + try clickSidebarLoopRow(app, fixture.project, "loop-fresh"); + try std.testing.expectEqualStrings("Starting loop", app.status()); + for (0..5) |_| fixture.tick(); + try fixture.setLive(&.{"loop-fresh"}); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-fresh"); + for (0..5) |_| fixture.tick(); + try std.testing.expectEqualStrings("Loop opened", app.status()); + try fixture.expectSelectedPane("loop-fresh"); + try fixture.expectTerminalsInWorkspace(1); + try fixture.showGraph(); + try fixture.expectNoVisibleTerminal(); + + // A loop whose session is already running when its row is clicked. + try fixture.setLive(&.{ "loop-fresh", "loop-live" }); + try clickSidebarLoopRow(app, fixture.project, "loop-live"); + try fixture.waitFor(LiveTerminalFixture.shows, "loop-live"); + for (0..5) |_| fixture.tick(); + try std.testing.expectEqualStrings("Loop opened", app.status()); + try fixture.expectSelectedPane("loop-live"); + try fixture.expectTerminalsInWorkspace(1); + const loop_started_in = try fixture.attachDirectory("loop-live"); + defer std.testing.allocator.free(loop_started_in); + try expectSameDirectory(fixture.worktree, loop_started_in); + + // Native New Tab: a plain shell with no session behind it, started in the loop's worktree. + const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 0); + try nativeClickCenter(app, new_tab); + try expectNoTerminalFailure(app); + try std.testing.expectEqual(@as(usize, 2), workspace.tabCount()); + try std.testing.expectEqual(workspace.tabCount(), ReopenProbe.uia_tabs); + const shell = try std.testing.allocator.dupe(u8, workspace.layout.tabs.items[1].panes.items[0].id); + defer std.testing.allocator.free(shell); + const shell_started_in = try fixture.attachDirectory(shell); + defer std.testing.allocator.free(shell_started_in); + try expectSameDirectory(fixture.worktree, shell_started_in); + try fixture.expectTerminalsInWorkspace(1); + + // Native Split Right of that shell: a second visible terminal beside it. + const split_right = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 1); + try nativeClickCenter(app, split_right); + try expectNoTerminalFailure(app); + try std.testing.expectEqual(@as(usize, 2), (workspace.layout.selectedConst() orelse return error.TestExpectedTab).panes.items.len); + try fixture.expectTerminalsInWorkspace(2); +} + fn emptyStateButton(app: *App, button: c.HWND) TerminalChild { var rect: c.RECT = undefined; _ = c.GetWindowRect(button, &rect); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 931be157..262b574d 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -606,6 +606,24 @@ pub const Workspace = struct { return self.cwd; } + /// The zmx program for a child started outside the shell's working directory. The + /// installed shell names zmx bare (`zmx.exe`) and finds it beside itself through that + /// directory, `...\GraphCode\current\bin`, which PATH need not name. A child resolves a + /// relative program against its own working directory, then PATH, so an attach started + /// in the loop's directory would not find it. Resolved once against the shell's + /// directory, as every other zmx child (started there) already resolves it; a name not + /// found there is left to PATH. + fn zmxExecutable(self: *Workspace) []const u8 { + if (self.zmx_path.len == 0 or std.fs.path.isAbsolute(self.zmx_path)) return self.zmx_path; + const candidate = std.fs.path.join(self.allocator, &.{ if (self.cwd.len == 0) "." else self.cwd, self.zmx_path }) catch + return self.zmx_path; + defer self.allocator.free(candidate); + const resolved = std.fs.cwd().realpathAlloc(self.allocator, candidate) catch return self.zmx_path; + self.allocator.free(self.zmx_path); + self.zmx_path = resolved; + return self.zmx_path; + } + fn layoutPathForProject(self: *Workspace, project: []const u8) ![]u8 { const configured = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_WORKSPACE_LAYOUT") catch try self.allocator.dupe(u8, "graphcode-workspace.json"); @@ -1941,7 +1959,7 @@ pub const Workspace = struct { attach_len = 4; } else { attach_len = (try attachArguments( - self.zmx_path, + self.zmxExecutable(), session, size, &attach_args, @@ -4304,6 +4322,36 @@ test "an explicit open rebinds a stale loop pane and survives a passive recreate try std.testing.expect(workspace.launch_waits[0].reports_timeout); } +test "a bare zmx name resolves against the shell's directory, not the attaching child's" { + var workspace = try minimalWorkspaceForOptionsTest(std.testing.allocator); + defer workspace.layout.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.makePath("bin"); + try tmp.dir.writeFile(.{ .sub_path = "bin\\zmx.exe", .data = "" }); + const bin = try tmp.dir.realpathAlloc(std.testing.allocator, "bin"); + defer std.testing.allocator.free(bin); + const expected = try std.fs.path.join(std.testing.allocator, &.{ bin, "zmx.exe" }); + defer std.testing.allocator.free(expected); + workspace.cwd = bin; + + // As installed: zmx beside the shell, found through its working directory. + workspace.zmx_path = try std.testing.allocator.dupe(u8, "zmx.exe"); + try std.testing.expectEqualStrings(expected, workspace.zmxExecutable()); + try std.testing.expectEqualStrings(expected, workspace.zmx_path); + std.testing.allocator.free(workspace.zmx_path); + + // A name the shell's directory does not hold is left to PATH. + workspace.zmx_path = try std.testing.allocator.dupe(u8, "zmx-elsewhere.exe"); + try std.testing.expectEqualStrings("zmx-elsewhere.exe", workspace.zmxExecutable()); + std.testing.allocator.free(workspace.zmx_path); + + // GRAPHCODE_ZMX's absolute path is used as given. + workspace.zmx_path = try std.testing.allocator.dupe(u8, "C:\\provider\\zmx.exe"); + try std.testing.expectEqualStrings("C:\\provider\\zmx.exe", workspace.zmxExecutable()); + std.testing.allocator.free(workspace.zmx_path); +} + test "a loop pane is detached only when the layout owns it and no slot shows or awaits it" { 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 303b9a87..abc0477b 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -181,7 +181,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; the workspace UIA tree now exposes a destination-specific toolbar, loop bar, tab controls, and Show in Graph action. The `windows-shell` CI job builds the real Swift daemon, Zig shell, and pinned zmx/Winghostty providers and runs `Tools\windows\uia-live-gate.ps1` against the live workspace: the gate independently asserts the toolbar identity child, Show in Graph child, all three split controls, and at least one tab child are present under a real, non-gated `Workspace.init()` (commit `a31813b`, run https://github.com/scgopi/GraphCode/actions/runs/35415967793, passing). Beta10 Dev Box qualification (D7) then found that opening an attended (Turn-based or main) loop showed a bare `cmd` shell: the pane's `zmx attach` created the session before the daemon launched the agent, so the first instruction was never sent. The shell now asks the daemon to start the loop (`resumeSession`) and attaches only once its session is running, and restores persisted loop panes only while their session is live. That fix has unit evidence and a local dev-layout walkthrough with a stub agent, not a Dev Box requalification with a real backend, so the row is `Partial` | Partial | | Folder toolbar identity | Project name and local/remote identity | Workspace chrome now paints an explicit Workspace title, project name, and Local folder/Remote repository identity over the native header, with a matching stable UIA toolbar child. The live gate's `workspace-toolbar-*` assertion (named `"UIA project"`) now runs against the real shell build and passes on the `windows-shell` CI job (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | | Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | The native 46px workspace band shows loop-type stripe, title, state, current activity, backend, elapsed label from `createdAt`, metric-history pass count, token usage when reported, Stop for unresolved loops, and Show in graph. Focused hit-testing/UIA unit coverage plus the live `windows-shell` CI run (workspace toolbar/loop-bar UIA assertions passing at run https://github.com/scgopi/GraphCode/actions/runs/35415967793) validated presence end to end. The beta10 Dev Box qualification (100% DPI) then observed the metadata text drawn under Stop/Show in graph at a 960px window with the sidebar rail and loop detail panel visible. Painting, mouse hit-testing, and UIA bounds now share `LoopBarLayout.zig`, which clamps every text run to the space left of the leftmost button, ellipsizes squeezed runs, and drops runs narrower than 24px; at 960px with rail and panel that drops the metadata line, where macOS truncates instead. Only focused `LoopBarLayout.zig` unit tests cover this; no live observation of the corrected layout has been made | Partial | -| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab. The live gate's `workspace-tab-*` assertion now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793). The beta17 Dev Box qualification then found a native **New Tab** click drew two tabs while UIA still exposed one `workspace-tab-*` element, and that the new shell started in the install `bin` directory instead of the loop's folder. Native New Tab, split, next/previous tab, and tab select/close clicks now republish UIA, and new shells start in the loop's worktree, else its project folder (home for a global loop; the shell's own directory for a remote project or a missing folder), as on macOS. A native-linked App test with real winghostty surfaces drives the clicks through `WM_LBUTTONDOWN/UP` and asserts the published tab count and each new shell's starting directory; no Dev Box rerun has confirmed either, so the row is `Partial` | Partial | +| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip paints agent/shell/split labels, live state indicators, Ctrl+1-style shortcut hints, and per-tab close affordances. Close routing removes only the selected tab topology and refuses the final tab. The live gate's `workspace-tab-*` assertion now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793). The beta17 Dev Box qualification then found a native **New Tab** click drew two tabs while UIA still exposed one `workspace-tab-*` element, and that the new shell started in the install `bin` directory instead of the loop's folder. Native New Tab, split, next/previous tab, and tab select/close clicks now republish UIA, and new shells start in the loop's worktree, else its project folder (home for a global loop; the shell's own directory for a remote project or a missing folder), as on macOS. A native-linked App test with real winghostty surfaces drives the clicks through `WM_LBUTTONDOWN/UP` and asserts the published tab count and each new shell's starting directory; no Dev Box rerun has confirmed either, so the row is `Partial`. The beta18 Dev Box qualification then found every sidebar loop open, New Tab, and Split Right failing (`Unable to open selected loop`, `Unable to create tab`, no terminal): the installed shell names zmx bare and found it only through its working directory (the install `bin`), so starting the attach in the loop's folder sent Windows looking there and on a PATH that did not name the install. zmx now resolves against the shell's own directory before any child starts elsewhere, and the native-linked live App tests now use that bare-name installed layout instead of an absolute stand-in path; no Dev Box rerun has confirmed it | Partial | | Split controls | Visible Split Right, Split Down, New Tab buttons | The terminal tab bar renders distinct New Tab, Split Right, and Split Down controls with shared geometry helpers used by painting and hit testing, plus UIA children and focused gap-boundary regression coverage. The live gate's split-control assertion (`workspace-(new-tab\|split-right\|split-down)-*`, all three present) now runs against the real shell build and passes on `windows-shell` (run https://github.com/scgopi/GraphCode/actions/runs/35415967793) | Validated | | Pane headers | agent/shell identity, backend/shell detail, focused state | Product-owned pane headers distinguish agent and shell panes, label the zmx session detail, add truthful backend: agent/backend: shell detail, and retain the explicit focused-pane accent. Focused rendering unit coverage plus the live `windows-shell` CI run (real workspace/terminal panes, run https://github.com/scgopi/GraphCode/actions/runs/35415967793) provide the side-by-side live evidence that was previously blocked | Validated | | Mounted background tabs | Switching preserves live terminal surfaces | Workspace implementation tests cover topology. The live UIA gate creates a second tab and attempts a switch/identity round trip, but its former `^workspace-tab-` selectors also matched `workspace-tab-close-*` controls (whose name is `Close tab`), so an indexed invocation could close rather than select a tab. All seven actual-tab predicates now require numeric tab IDs; an AST-extracted source regression checks interleaved, close-first, reversed, no-close, close-only, empty, and split-tab child lists for exact selection and order. This source contract does not demonstrate backend continuity or a new live UIA round trip with the corrected gate | Partial |