diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index b46670fd..2df190b5 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -8628,9 +8628,19 @@ pub const App = struct { self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab-close", close_key, "Close tab", 4, .{ .physical = close_bounds }, false, workspace.canCloseTab()) catch return; } } - for ([_][]const u8{ "New Tab", "Split Right", "Split Down" }, 0..) |label, control_index| { + for (0..3) |control_index| { const control_bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, control_index); - const kind = if (control_index == 0) "workspace-new-tab" else if (control_index == 1) "workspace-split-right" else "workspace-split-down"; + const action = TerminalSurface.chromeActionForIndex(control_index) orelse unreachable; + const kind: []const u8 = switch (action) { + .split_right => "workspace-split-right", + .split_down => "workspace-split-down", + .new_tab => "workspace-new-tab", + }; + const label: []const u8 = switch (action) { + .split_right => "Split Right", + .split_down => "Split Down", + .new_tab => "New Tab", + }; self.appendAccessibilityElement(&elements, &owned_identities, kind, "control", label, 4, .{ .physical = control_bounds }, false, true) catch return; } } @@ -15623,14 +15633,14 @@ const LiveTerminalFixture = struct { fn newTab(self: *LiveTerminalFixture) !void { const workspace = &self.workspace; - const bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 0); + const bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 2); try nativeClickCenter(&self.app, bounds); try expectNoTerminalFailure(&self.app); } fn splitRight(self: *LiveTerminalFixture) !void { const workspace = &self.workspace; - const bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 1); + const bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 0); try nativeClickCenter(&self.app, bounds); try expectNoTerminalFailure(&self.app); } @@ -17065,7 +17075,7 @@ test "workspace surface: native New Tab publishes every drawn tab to UIA and sta try fixture.waitFor(LiveTerminalFixture.shows, loop); try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); - const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 0); + const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 2); try nativeClickCenter(app, new_tab); try std.testing.expectEqual(@as(usize, 2), workspace.tabCount()); try std.testing.expectEqual(workspace.tabCount(), ReopenProbe.uia_tabs); @@ -19361,7 +19371,7 @@ test "workspace surface: sidebar loop opens, New Tab, and Split Right attach vis 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); + const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 2); try nativeClickCenter(app, new_tab); try expectNoTerminalFailure(app); try std.testing.expectEqual(@as(usize, 2), workspace.tabCount()); @@ -19374,10 +19384,12 @@ test "workspace surface: sidebar loop opens, New Tab, and Split Right attach vis 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); + const split_right = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 0); 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); + const selected = workspace.layout.selectedConst() orelse return error.TestExpectedTab; + try std.testing.expect(selected.split_direction == .horizontal); + try std.testing.expectEqual(@as(usize, 2), selected.panes.items.len); try fixture.expectTerminalsInWorkspace(2); } @@ -21441,9 +21453,9 @@ test "DPI UIA terminal tab close and controls retain physical geometry" { .{ .identity = "workspace-tab:0", .name = "Agent", .bounds = .{ .{ 228, 84, 462, 106 }, .{ 342, 124, 748, 146 }, .{ 456, 164, 1034, 186 } } }, .{ .identity = "workspace-tab:1", .name = "Shell", .bounds = .{ .{ 466, 84, 700, 106 }, .{ 754, 124, 1160, 146 }, .{ 1042, 164, 1620, 186 } } }, .{ .identity = "workspace-tab-close:1", .bounds = .{ .{ 676, 84, 700, 106 }, .{ 1124, 124, 1160, 146 }, .{ 1572, 164, 1620, 186 } } }, - .{ .identity = "workspace-new-tab:control", .bounds = .{ .{ 708, 83, 776, 107 }, .{ 1172, 123, 1240, 147 }, .{ 1636, 163, 1704, 187 } } }, - .{ .identity = "workspace-split-right:control", .bounds = .{ .{ 780, 83, 848, 107 }, .{ 1244, 123, 1312, 147 }, .{ 1708, 163, 1776, 187 } } }, - .{ .identity = "workspace-split-down:control", .bounds = .{ .{ 852, 83, 920, 107 }, .{ 1316, 123, 1384, 147 }, .{ 1780, 163, 1848, 187 } } }, + .{ .identity = "workspace-split-right:control", .bounds = .{ .{ 708, 83, 776, 107 }, .{ 1172, 123, 1240, 147 }, .{ 1636, 163, 1704, 187 } } }, + .{ .identity = "workspace-split-down:control", .bounds = .{ .{ 780, 83, 848, 107 }, .{ 1244, 123, 1312, 147 }, .{ 1708, 163, 1776, 187 } } }, + .{ .identity = "workspace-new-tab:control", .bounds = .{ .{ 852, 83, 920, 107 }, .{ 1316, 123, 1384, 147 }, .{ 1780, 163, 1848, 187 } } }, }); } diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 0ba90b2e..ec803f43 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -201,17 +201,21 @@ pub fn loopBarActionAt(left: i32, top: i32, right: i32, x: i32, y: i32, resolved return LoopBarLayout.compute(left, top, right, resolved, panel_toggle).actionAt(x, y); } +pub fn chromeActionForIndex(index: usize) ?ChromeAction { + return switch (index) { + 0 => .split_right, + 1 => .split_down, + 2 => .new_tab, + else => null, + }; +} + fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, x: i32, y: i32) ?ChromeAction { if (x < origin_x or x >= origin_x + width) return null; for (0..3) |index| { const bounds = chromeControlBounds(origin_x, origin_y, width, index); if (x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom) { - return switch (index) { - 0 => .new_tab, - 1 => .split_right, - 2 => .split_down, - else => null, - }; + return chromeActionForIndex(index); } } return null; @@ -569,6 +573,7 @@ pub const Workspace = struct { layout_height: i32 = 250, collapsed: bool = false, agent_tab_title: []u8 = &.{}, + agent_pane_detail: []u8 = &.{}, agent_tab_state: enum { quiet, running, needs_you, asks } = .quiet, chrome_hover_tab: ?usize = null, agent_restart_suspended: bool = false, @@ -681,6 +686,7 @@ pub const Workspace = struct { if (self.loop_id.len != 0) self.allocator.free(self.loop_id); if (self.legacy_directory.len != 0) self.allocator.free(self.legacy_directory); if (self.agent_tab_title.len != 0) self.allocator.free(self.agent_tab_title); + if (self.agent_pane_detail.len != 0) self.allocator.free(self.agent_pane_detail); } pub fn setKeyCallback( @@ -778,6 +784,8 @@ pub const Workspace = struct { self.loop_id = &.{}; if (self.agent_tab_title.len != 0) self.allocator.free(self.agent_tab_title); self.agent_tab_title = &.{}; + if (self.agent_pane_detail.len != 0) self.allocator.free(self.agent_pane_detail); + self.agent_pane_detail = &.{}; self.agent_tab_state = .quiet; self.agent_restart_suspended = false; self.chrome_hover_tab = null; @@ -2054,11 +2062,23 @@ pub const Workspace = struct { pub fn setTabPresentation(self: *Workspace, loop_type: []const u8, backend: []const u8, state: []const u8) !void { const title = agentTabTitle(loop_type, backend); - if (!std.mem.eql(u8, title, self.agent_tab_title)) { - const owned = try self.allocator.dupe(u8, title); + const owned_title = if (!std.mem.eql(u8, title, self.agent_tab_title)) + try self.allocator.dupe(u8, title) + else + null; + errdefer if (owned_title) |owned| self.allocator.free(owned); + const detail = try std.ascii.allocLowerString(self.allocator, backendDisplayName(backend)); + errdefer self.allocator.free(detail); + if (owned_title) |owned| { if (self.agent_tab_title.len != 0) self.allocator.free(self.agent_tab_title); self.agent_tab_title = owned; } + if (std.mem.eql(u8, detail, self.agent_pane_detail)) { + self.allocator.free(detail); + } else { + if (self.agent_pane_detail.len != 0) self.allocator.free(self.agent_pane_detail); + self.agent_pane_detail = detail; + } self.agent_tab_state = if (std.mem.eql(u8, state, "awaitingInput")) .asks else if (std.mem.eql(u8, state, "running")) @@ -2223,6 +2243,7 @@ pub const Workspace = struct { .bottom = bounds.y, }, .title = if (pane.launches_agent) (if (self.boundQuickChatID() != null) "chat" else "agent") else "shell", + .detail = if (pane.launches_agent) self.agent_pane_detail else "", .focused = position == tab.focused_pane, }; } @@ -7359,11 +7380,10 @@ fn tabChromeStyle(selected: bool) TabChromeStyle { const ChromeGlyph = enum { new_tab, split_right, split_down }; fn chromeGlyph(index: usize) ChromeGlyph { - return switch (index) { - 0 => .new_tab, - 1 => .split_right, - 2 => .split_down, - else => unreachable, + return switch (chromeActionForIndex(index) orelse unreachable) { + .new_tab => .new_tab, + .split_right => .split_right, + .split_down => .split_down, }; } @@ -7400,7 +7420,12 @@ fn paintChromeGlyph(hdc: c.HDC, bounds: c.RECT, glyph: ChromeGlyph) void { } } -const PaneHeaderChrome = struct { bounds: c.RECT, title: []const u8, focused: bool }; +const PaneHeaderChrome = struct { + bounds: c.RECT, + title: []const u8, + detail: []const u8 = "", + focused: bool, +}; const pane_focus_hint = "Ctrl+Shift+] to focus"; fn paintPaneHeader(hdc: c.HDC, header: PaneHeaderChrome) void { @@ -7422,6 +7447,14 @@ fn paintPaneHeader(hdc: c.HDC, header: PaneHeaderChrome) void { .right = @min(bounds.left + 122, bounds.right - 8), .bottom = bounds.bottom, }, 9.5, .card_mono, blendChromeColor(0x00FFFFFF, background, 35)); + if (header.detail.len != 0 and bounds.right - bounds.left >= 180) { + drawChromeText(hdc, header.detail, .{ + .left = @max(bounds.left + 128, bounds.right - 128), + .top = bounds.top, + .right = bounds.right - 8, + .bottom = bounds.bottom, + }, 9.5, .card_mono, blendChromeColor(0x00FFFFFF, background, 35)); + } } else if (bounds.right - bounds.left >= 240) { drawChromeText(hdc, pane_focus_hint, .{ .left = bounds.right - 144, @@ -7517,6 +7550,10 @@ fn tabLabel(tab: WorkspaceLayout.Tab) []const u8 { fn agentTabTitle(loop_type: []const u8, backend: []const u8) []const u8 { if (std.mem.eql(u8, loop_type, "goalBased")) return "Goal"; if (std.mem.eql(u8, loop_type, "timeBased")) return "Loop"; + return backendDisplayName(backend); +} + +fn backendDisplayName(backend: []const u8) []const u8 { if (std.mem.eql(u8, backend, "claudeCode")) return "Claude Code"; if (std.mem.eql(u8, backend, "copilotCLI")) return "Copilot CLI"; if (std.mem.eql(u8, backend, "codex")) return "Codex"; @@ -7811,9 +7848,9 @@ test "bounded input write rejects an invalid attach without blocking" { } test "workspace chrome actions occupy distinct visible buttons" { - try std.testing.expectEqual(ChromeAction.new_tab, chromeActionForBounds(220, 34, 800, 804, 44).?); - try std.testing.expectEqual(ChromeAction.split_right, chromeActionForBounds(220, 34, 800, 876, 44).?); - try std.testing.expectEqual(ChromeAction.split_down, chromeActionForBounds(220, 34, 800, 948, 44).?); + try std.testing.expectEqual(ChromeAction.split_right, chromeActionForBounds(220, 34, 800, 804, 44).?); + try std.testing.expectEqual(ChromeAction.split_down, chromeActionForBounds(220, 34, 800, 876, 44).?); + try std.testing.expectEqual(ChromeAction.new_tab, chromeActionForBounds(220, 34, 800, 948, 44).?); try std.testing.expect(chromeActionForBounds(220, 34, 800, 868, 44) == null); try std.testing.expectEqual(@as(?ChromeAction, null), chromeActionForBounds(220, 34, 800, 700, 44)); } @@ -7980,6 +8017,7 @@ test "lane 9 tab names use primary pane type and actual backend without guessing }; defer workspace.layout.deinit(); defer if (workspace.agent_tab_title.len != 0) workspace.allocator.free(workspace.agent_tab_title); + defer if (workspace.agent_pane_detail.len != 0) workspace.allocator.free(workspace.agent_pane_detail); try workspace.layout.addTab("agent", true); try workspace.layout.splitFocused(.horizontal, "split-shell"); try workspace.layout.addTab("plain-shell", false); @@ -8025,6 +8063,7 @@ test "lane 9 tab content shortcut close and overflow geometry agree at narrow an }; defer workspace.layout.deinit(); defer if (workspace.agent_tab_title.len != 0) allocator.free(workspace.agent_tab_title); + defer if (workspace.agent_pane_detail.len != 0) allocator.free(workspace.agent_pane_detail); try workspace.layout.addTab("agent", true); try workspace.setTabPresentation("sketch", "copilotCLI", "awaitingInput"); const single_close = workspace.tabCloseBounds(0).?; @@ -8094,7 +8133,7 @@ test "lane 9 tab content shortcut close and overflow geometry agree at narrow an } test "workspace chrome split glyphs retain their existing action columns" { - for ([_]ChromeGlyph{ .new_tab, .split_right, .split_down }, 0..) |glyph, index| { + for ([_]ChromeGlyph{ .split_right, .split_down, .new_tab }, 0..) |glyph, index| { const bounds = chromeControlBounds(220, 80, 800, index); try std.testing.expectEqual(glyph, chromeGlyph(index)); const action = chromeActionForBounds(220, 80, 800, @divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2)).?; @@ -8103,6 +8142,42 @@ test "workspace chrome split glyphs retain their existing action columns" { try std.testing.expect(chromeActionForBounds(220, 80, 100, 400, 90) == null); } +test "issue 772 workspace chrome matches mac control order and focused backend identity" { + const allocator = std.testing.allocator; + var workspace = Workspace{ + .parent = null, + .allocator = allocator, + .zmx_path = &.{}, + .cwd = &.{}, + .input_queue = .{ .allocator = allocator }, + .layout = try WorkspaceLayout.Layout.init(allocator, "issue-772"), + .layout_path = &.{}, + .project_key = &.{}, + .layout_origin_x = 220, + .layout_origin_y = 80, + .layout_width = 800, + .layout_height = 400, + }; + defer workspace.layout.deinit(); + defer if (workspace.agent_tab_title.len != 0) allocator.free(workspace.agent_tab_title); + defer if (workspace.agent_pane_detail.len != 0) allocator.free(workspace.agent_pane_detail); + try workspace.layout.addTab("agent", true); + try workspace.layout.splitFocused(.horizontal, "shell"); + try workspace.setTabPresentation("sketch", "copilotCLI", "idle"); + workspace.layout.selected().?.focused_pane = 0; + + for ([_]ChromeAction{ .split_right, .split_down, .new_tab }, 0..) |expected, index| { + const bounds = chromeControlBounds(220, 80, 800, index); + try std.testing.expectEqual( + expected, + chromeActionForBounds(220, 80, 800, @divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2)).?, + ); + } + const agent = workspace.paneHeaderChrome(0).?; + try std.testing.expect(agent.focused); + try std.testing.expectEqualStrings("copilot cli", agent.detail); +} + test "workspace chrome pane headers follow selected layout and focus instead of surface slots" { const allocator = std.testing.allocator; var workspace = Workspace{