Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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" });
Expand All @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
50 changes: 49 additions & 1 deletion graphcode-windows/src/TerminalSurface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion investigation/ui-parity-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading