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
34 changes: 23 additions & 11 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 } } },
});
}

Expand Down
111 changes: 93 additions & 18 deletions graphcode-windows/src/TerminalSurface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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).?;
Expand Down Expand Up @@ -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)).?;
Expand All @@ -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{
Expand Down
Loading