From a84285307504a9b683d466e258148c924a4712d8 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 09:48:36 -0700 Subject: [PATCH 1/2] Add Windows prompt template workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 34 +++ graphcode-windows/src/App.zig | 84 ++++++- graphcode-windows/src/DaemonClient.zig | 8 + graphcode-windows/src/GraphContextMenu.zig | 11 + graphcode-windows/src/GraphModel.zig | 28 +++ graphcode-windows/src/NativeForms.zig | 56 ++++- graphcode-windows/src/TemplateLibrary.zig | 265 +++++++++++++++++++++ graphcode-windows/src/Wire.zig | 10 + investigation/ui-parity-matrix.md | 2 +- 9 files changed, 487 insertions(+), 11 deletions(-) create mode 100644 graphcode-windows/src/TemplateLibrary.zig diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 6f8fa3d0..6d63b205 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -594,6 +594,7 @@ $oldShowUpdate = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_SHOW_UPDAT $oldIngressError = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_INGRESS_ERROR") $oldDaemonCommandLog = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_DAEMON_COMMAND_LOG") $oldShellExecuteLog = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_SHELL_EXECUTE_LOG") +$oldLocalAppData = [Environment]::GetEnvironmentVariable("LOCALAPPDATA") $process = $null $settingsProcess = $null $status = $null @@ -615,6 +616,7 @@ $settingsPath = $null $settingsErrorPath = $null $daemonCommandLogPath = $null $shellExecuteLogPath = $null +$templateDirectory = $null try { if ($Zmx) { $env:GRAPHCODE_ZMX = $Zmx } $env:GRAPHCODE_GATE_CWD = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path @@ -631,6 +633,14 @@ try { Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue $env:GRAPHCODE_UIA_DAEMON_COMMAND_LOG = $daemonCommandLogPath $env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG = $shellExecuteLogPath + $templateDirectory = Join-Path ([IO.Path]::GetTempPath()) "graphcode-uia-templates-$PID" + $env:LOCALAPPDATA = $templateDirectory + $savedTemplates = Join-Path $templateDirectory "GraphCode\templates" + New-Item -ItemType Directory -Path $savedTemplates -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $savedTemplates "uia-release-review.md"), + "---`nid: 11111111-1111-4111-8111-111111111111`nname: UIA release review`nshape: turn`n---`nReview the release diff.`n" + ) $settingsDirectory = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode-uia-product-settings-$PID" $settingsPath = Join-Path $settingsDirectory "settings.json" $settingsErrorPath = Join-Path $settingsDirectory "stderr.log" @@ -889,6 +899,24 @@ try { Require (Ensure-ShellForeground $shellWindow "project-row New Loop") ` "GraphCode shell did not reacquire foreground before invoking project-row New Loop" $projectNewLoop.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $templatePickerCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Choose a saved template" + )) + ) + $templatePicker = Wait-ForDesktopElement ` + -desktop $desktop ` + -condition $templatePickerCondition ` + -label "project-row New Loop template picker" ` + -diagnosticWindow $shellWindow ` + -RecoverForeground + Require ($null -ne $templatePicker) "project-row New Loop did not expose the saved template picker" + Require ([GraphCodeUiaGateState]::PostKeyboard( + [IntPtr]$templatePicker.Current.NativeWindowHandle, 0x0D + )) "template picker rejected keyboard application" $sidebarNodeForm = $null $sidebarNodeFormCondition = New-Object System.Windows.Automation.AndCondition( (New-Object System.Windows.Automation.PropertyCondition( @@ -2432,6 +2460,9 @@ try { if ($shellExecuteLogPath) { Remove-Item -LiteralPath $shellExecuteLogPath -Force -ErrorAction SilentlyContinue } + if ($templateDirectory) { + Remove-Item -LiteralPath $templateDirectory -Recurse -Force -ErrorAction SilentlyContinue + } if ($null -eq $oldZmx) { Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue } else { $env:GRAPHCODE_ZMX = $oldZmx } if ($null -eq $oldCwd) { Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue } @@ -2470,4 +2501,7 @@ try { if ($null -eq $oldShellExecuteLog) { Remove-Item Env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG -ErrorAction SilentlyContinue } else { $env:GRAPHCODE_UIA_SHELL_EXECUTE_LOG = $oldShellExecuteLog } + if ($null -eq $oldLocalAppData) { + Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue + } else { $env:LOCALAPPDATA = $oldLocalAppData } } diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 25039716..ea595f70 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -8,6 +8,7 @@ const CanvasLayoutStore = @import("CanvasLayoutStore.zig"); const GraphContextMenu = @import("GraphContextMenu.zig"); const Forms = @import("Forms.zig"); const NativeForms = @import("NativeForms.zig"); +const TemplateLibrary = @import("TemplateLibrary.zig"); const JumpPalette = @import("JumpPalette.zig"); const NativeDialogs = @import("WindowsNativeDialogs.zig"); const Sidebar = @import("Sidebar.zig"); @@ -1135,7 +1136,7 @@ pub const App = struct { const path = self.allocator.dupe(u8, current_path) catch return; defer self.allocator.free(path); const settings = self.product_settings orelse return; - var draft = NativeForms.node(self.window.hwnd, self.allocator, .{ + var initial = Forms.NodeDraft{ .title = "", .backend = settings.default_backend, .model_tier = settings.default_model, @@ -1143,7 +1144,39 @@ pub const App = struct { .copilot_permissions = settings.copilot_permissions, .briefing_enabled = settings.briefing, .activity_enabled = settings.activity, - }) catch { + }; + var templates = TemplateLibrary.load(self.allocator, path) catch { + self.setStatus("Unable to load saved templates"); + return; + }; + defer templates.deinit(); + if (templates.templates.items.len != 0) { + var labels = std.array_list.Managed([]const u8).init(self.allocator); + defer { + for (labels.items) |label| self.allocator.free(label); + labels.deinit(); + } + for (templates.templates.items) |template| { + const label = std.fmt.allocPrint(self.allocator, "{s} — {s}", .{ template.name, template.body }) catch { + self.setStatus("Unable to prepare saved template list"); + return; + }; + labels.append(label) catch { + self.allocator.free(label); + self.setStatus("Unable to prepare saved template list"); + return; + }; + } + const selected = NativeForms.templatePicker(self.window.hwnd, self.allocator, labels.items) catch { + self.setStatus("Unable to open saved template picker"); + return; + }; + if (selected) |index| TemplateLibrary.apply(&initial, templates.templates.items[index], self.allocator) catch { + self.setStatus("Unable to apply selected template"); + return; + }; + } + var draft = NativeForms.node(self.window.hwnd, self.allocator, initial) catch { self.setStatus("Unable to open node form"); return; } orelse return; @@ -1913,6 +1946,7 @@ pub const App = struct { .composite = composite, .can_arm = std.mem.eql(u8, graph.nodes.items[index].pilot_state, "piloted"), .unwired = unwired, + .follows_template = graph.nodes.items[index].follows_template, } }, x, y, @@ -2035,6 +2069,11 @@ pub const App = struct { }, .wire_node => self.beginWireSelectedNode(index), .mark_entry => self.markSelectedNodeAsEntry(index), + .detach_template => { + self.client.sendDetachTemplate(graph.project.path, graph.nodes.items[index].id); + self.setStatus("Detached loop from its template"); + }, + .save_node_template => self.saveSelectedNodeAsTemplate(index), else => {}, } }, @@ -2073,6 +2112,47 @@ pub const App = struct { _ = c.InvalidateRect(self.window.hwnd, null, 0); } + fn saveSelectedNodeAsTemplate(self: *App, index: usize) void { + const graph = self.model.graph orelse return; + if (index >= graph.nodes.items.len) return; + var result = NativeDialogs.textWithDescription( + self.window.hwnd, self.allocator, "Save as Template", + "Name this reusable prompt template. It is stored in your per-user GraphCode library.", + &.{"Template name"}, &.{graph.nodes.items[index].title}, + ) catch { + self.setStatus("Unable to open template save form"); + return; + } orelse return; + defer result.deinit(self.allocator); + const node = graph.nodes.items[index]; + const draft = Forms.NodeDraft{ + .title = self.allocator.dupe(u8, node.title) catch return, + .loop_type = self.allocator.dupe(u8, node.loop_type) catch return, + .first_instruction = self.allocator.dupe(u8, if (node.trigger_prompt.len != 0) node.trigger_prompt else node.check_description) catch return, + .goal_summary = self.allocator.dupe(u8, node.goal_summary) catch return, + .trigger_prompt = self.allocator.dupe(u8, node.trigger_prompt) catch return, + .claude_permissions = "", + .copilot_permissions = "", + }; + defer { + self.allocator.free(draft.title); + self.allocator.free(draft.loop_type); + self.allocator.free(draft.first_instruction); + self.allocator.free(draft.goal_summary); + self.allocator.free(draft.trigger_prompt); + } + var template = TemplateLibrary.fromDraft(self.allocator, result.values[0], draft) catch { + self.setStatus("A template needs a name and prompt"); + return; + }; + defer template.deinit(self.allocator); + TemplateLibrary.save(self.allocator, template) catch { + self.setStatus("Unable to save template"); + return; + }; + self.setStatus("Saved reusable template"); + } + fn showCompositeGroup(self: *App, node: GraphModel.Node) void { const node_id = self.allocator.dupe(u8, node.id) catch return; defer self.allocator.free(node_id); diff --git a/graphcode-windows/src/DaemonClient.zig b/graphcode-windows/src/DaemonClient.zig index 8a4e564c..f33395f1 100644 --- a/graphcode-windows/src/DaemonClient.zig +++ b/graphcode-windows/src/DaemonClient.zig @@ -393,6 +393,14 @@ pub const DaemonClient = struct { self.sendCommand(command); } + pub fn sendDetachTemplate(self: *DaemonClient, project_path: []const u8, node_id: []const u8) void { + const command = Wire.commandGraphDetachTemplate(self.allocator, project_path, node_id) catch { + self.publishState(self.connectionState(), "detach template command encoding failed"); + return; + }; + self.sendCommand(command); + } + pub fn sendSidebarRootOrder( self: *DaemonClient, project_path: []const u8, diff --git a/graphcode-windows/src/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig index 33031985..785037d5 100644 --- a/graphcode-windows/src/GraphContextMenu.zig +++ b/graphcode-windows/src/GraphContextMenu.zig @@ -7,6 +7,7 @@ pub const NodeTarget = struct { composite: bool = false, can_arm: bool = false, unwired: bool = false, + follows_template: bool = false, }; pub const EdgeTarget = struct { @@ -44,6 +45,8 @@ pub const Action = enum { open_composite, pilot_composite, arm_composite, + save_node_template, + detach_template, wire_node, mark_entry, edit_edge, @@ -92,6 +95,8 @@ const ids = struct { const open_composite = 5113; const pilot_composite = 5107; const arm_composite = 5108; + const save_node_template = 5114; + const detach_template = 5115; const wire_node = 5109; const mark_entry = 5112; const edit_edge = 5110; @@ -177,6 +182,8 @@ pub fn show( separator(menu); } append(menu, ids.edit_node, "Edit Details...\tCtrl+E"); + append(menu, ids.save_node_template, "Save as Template..."); + if (node.follows_template) append(menu, ids.detach_template, "Detach from Template"); append(menu, ids.rename_node, "Rename...\tF2"); append(menu, ids.stop_node, "Stop\tCtrl+S"); append(menu, ids.delete_node, "Delete Loop...\tDelete"); @@ -222,6 +229,8 @@ fn actionForCommand(command: c_int) Action { ids.open_composite => .open_composite, ids.pilot_composite => .pilot_composite, ids.arm_composite => .arm_composite, + ids.save_node_template => .save_node_template, + ids.detach_template => .detach_template, ids.wire_node => .wire_node, ids.mark_entry => .mark_entry, ids.edit_edge => .edit_edge, @@ -285,6 +294,8 @@ test "context actions remain stable when graph IDs are reordered" { try std.testing.expectEqual(Action.arm_composite, actionForCommand(ids.arm_composite)); try std.testing.expectEqual(Action.wire_node, actionForCommand(ids.wire_node)); try std.testing.expectEqual(Action.mark_entry, actionForCommand(ids.mark_entry)); + try std.testing.expectEqual(Action.save_node_template, actionForCommand(ids.save_node_template)); + try std.testing.expectEqual(Action.detach_template, actionForCommand(ids.detach_template)); } test "destructive context actions cannot bypass a cancelled confirmation" { diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 1f6cb592..3b2299ab 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -28,6 +28,7 @@ pub const Node = struct { worktree_path: []u8 = @constCast(""), worktree_branch: []u8 = &.{}, subgraph_json: []u8 = &.{}, + follows_template: bool = false, }; pub const ActivityEvent = struct { @@ -1043,6 +1044,7 @@ fn cloneNode(allocator: std.mem.Allocator, node: Node) !Node { .worktree_path = try allocator.dupe(u8, node.worktree_path), .worktree_branch = try allocator.dupe(u8, node.worktree_branch), .subgraph_json = try allocator.dupe(u8, node.subgraph_json), + .follows_template = node.follows_template, }; } @@ -1111,9 +1113,19 @@ fn decodeNodes( .worktree_path = try duplicateWorktreePath(allocator, scalar_object), .worktree_branch = try duplicateWorktreeBranch(allocator, scalar_object), .subgraph_json = try duplicateJsonObjectOrEmpty(allocator, object, "subGraph"), + .follows_template = hasNonNullJsonField(scalar_object, "templateFollow"), }); cursor = end + 1; } + +} + +fn hasNonNullJsonField(object: []const u8, key: []const u8) bool { + const marker = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":", .{key}) catch return false; + defer std.heap.page_allocator.free(marker); + const start = std.mem.indexOf(u8, object, marker) orelse return false; + const value = std.mem.trimLeft(u8, object[start + marker.len ..], " \t\r\n"); + return !std.mem.startsWith(u8, value, "null"); } fn decodeEdges( @@ -1482,6 +1494,22 @@ test "stub graph snapshot decodes two actionable nodes" { try std.testing.expectEqualStrings("idle", graph.nodes.items[1].presence); } +test "template-following nodes retain their detach eligibility" { + const allocator = std.testing.allocator; + var nodes = std.array_list.Managed(Node).init(allocator); + defer { + for (nodes.items) |node| freeNode(allocator, node); + nodes.deinit(); + } + try decodeNodes( + allocator, + \\[{"id":"11111111-1111-4111-8111-111111111111","title":"Template loop","loopType":"turnBased","state":{"idle":{}},"pausesBeforeWritesOnly":false,"pilotState":"notPiloted","templateFollow":{"id":"22222222-2222-4222-8222-222222222222"}}] + , + &nodes, + ); + try std.testing.expect(nodes.items[0].follows_template); +} + test "reordered graph fixture preserves nonsequential edge IDs" { const allocator = std.testing.allocator; const frame = try std.fs.cwd().readFileAlloc( diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 37fa2ce4..4ae49ddd 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -35,12 +35,13 @@ const DialogState = struct { tile_field_index: ?usize = null, tile_buttons: [max_tiles]c.HWND = .{null} ** max_tiles, tile_count: usize = 0, + template_options: []const []const u8 = &.{}, }; const max_tiles = 8; const tile_base_id = 9600; -const Kind = enum { node, edge, update, settings, jump, worktree_policy, worktree_sweep }; +const Kind = enum { node, edge, update, settings, jump, template_picker, worktree_policy, worktree_sweep }; const InputKind = enum { edit, readonly, combo, checkbox, tiles }; const ChoiceGroup = enum { none, loop_type, backend, model_tier, metric_direction, optional_metric_direction, edge_kind, edge_condition, transform }; const Choice = struct { label: []const u8, value: []const u8, description: []const u8 = "", accent: u32 = Tokens.canvas_selection }; @@ -175,6 +176,7 @@ pub fn node( freeValues(state); allocator.destroy(state); } + state.values[0] = try allocator.dupe(u8, initial.title); state.values[1] = try allocator.dupe(u8, initial.loop_type); state.values[2] = try allocator.dupe(u8, initial.check_description); @@ -200,6 +202,28 @@ pub fn node( return try buildNodeDraft(allocator, &state.values, initial); } +/// A native, keyboard-searchable list of saved templates. The editable combo +/// provides standard type-ahead search, Up/Down selection, Enter acceptance, +/// and UI Automation ComboBox semantics without introducing a custom canvas. +pub fn templatePicker( + parent: c.HWND, + allocator: std.mem.Allocator, + options: []const []const u8, +) !?usize { + if (options.len == 0) return null; + const state = try allocator.create(DialogState); + state.* = .{ .allocator = allocator, .kind = .template_picker, .parent = parent, .template_options = options }; + defer { + freeValues(state); + allocator.destroy(state); + } + state.values[0] = try allocator.dupe(u8, "0"); + if (!(try show(state, "Choose a saved template", &.{}))) return null; + const selected = std.fmt.parseUnsigned(usize, state.values[0], 10) catch return error.InvalidTemplateSelection; + if (selected >= options.len) return error.InvalidTemplateSelection; + return selected; +} + fn buildNodeDraft( allocator: std.mem.Allocator, values: []const []u8, @@ -754,7 +778,7 @@ fn configureFields(state: *DialogState) void { .edge => 10, .update => 9, .settings => 2, - .jump => 1, + .jump, .template_picker => 1, .worktree_policy => 0, .worktree_sweep => state.field_count, }; @@ -787,6 +811,9 @@ fn configureFields(state: *DialogState) void { state.input_kinds[8] = .combo; state.choice_groups[8] = .model_tier; }, + .template_picker => { + state.input_kinds[0] = .combo; + }, else => {}, } updateConditionalVisibility(state); @@ -848,6 +875,7 @@ fn fieldLabel(kind: Kind, index: usize) []const u8 { .update => update_labels[index], .settings => settings_labels[index], .jump => "Loop title or ID", + .template_picker => "Saved templates — type to search, then use Up/Down and Enter", .worktree_policy, .worktree_sweep => "", }; } @@ -1137,7 +1165,7 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { c.WS_EX_CLIENTEDGE, std.unicode.utf8ToUtf16LeStringLiteral("COMBOBOX").ptr, null, - style | @as(c.DWORD, @intCast(c.CBS_DROPDOWNLIST)) | @as(c.DWORD, @intCast(c.WS_VSCROLL)), + style | @as(c.DWORD, @intCast(if (state.kind == .template_picker) c.CBS_DROPDOWN else c.CBS_DROPDOWNLIST)) | @as(c.DWORD, @intCast(c.WS_VSCROLL)), 18, 0, 530, @@ -1184,6 +1212,14 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { switch (state.input_kinds[index]) { .tiles => {}, .combo => { + if (state.kind == .template_picker) { + for (state.template_options) |option| { + const wide = utf8ToWideZ(state.allocator, option) catch continue; + defer state.allocator.free(wide); + _ = c.SendMessageW(input, c.CB_ADDSTRING, 0, @intCast(@intFromPtr(wide.ptr))); + } + _ = c.SendMessageW(input, c.CB_SETCURSEL, 0, 0); + } else if (isEndpointCombo(state, index)) { for (state.edge_endpoints) |endpoint| { const label = std.fmt.allocPrint(state.allocator, "{s} — {s}", .{ endpoint.title, endpoint.id }) catch continue; @@ -1439,11 +1475,15 @@ fn readValue(state: *DialogState, index: usize) void { const selected = c.SendMessageW(state.edits[index], c.CB_GETCURSEL, 0, 0); if (selected < 0) return; const selected_index: usize = @intCast(selected); - const next = if (isEndpointCombo(state, index) and selected_index < state.edge_endpoints.len) - state.edge_endpoints[selected_index].id - else - choiceValue(state.choice_groups[index], selected_index, state.values[index]); - const value = state.allocator.dupe(u8, next) catch return; + const value: []u8 = if (state.kind == .template_picker) + std.fmt.allocPrint(state.allocator, "{d}", .{selected_index}) catch return + else blk: { + const next = if (isEndpointCombo(state, index) and selected_index < state.edge_endpoints.len) + state.edge_endpoints[selected_index].id + else + choiceValue(state.choice_groups[index], selected_index, state.values[index]); + break :blk state.allocator.dupe(u8, next) catch return; + }; state.allocator.free(state.values[index]); state.values[index] = value; }, diff --git a/graphcode-windows/src/TemplateLibrary.zig b/graphcode-windows/src/TemplateLibrary.zig new file mode 100644 index 00000000..222316fb --- /dev/null +++ b/graphcode-windows/src/TemplateLibrary.zig @@ -0,0 +1,265 @@ +const std = @import("std"); +const Forms = @import("Forms.zig"); + +/// Windows reads the same human-editable markdown files as GraphcodeKit: +/// project templates take precedence over the per-user library. +pub const Template = struct { + id: []u8, + name: []u8, + body: []u8, + shape: []u8, + + pub fn deinit(self: *Template, allocator: std.mem.Allocator) void { + allocator.free(self.id); + allocator.free(self.name); + allocator.free(self.body); + allocator.free(self.shape); + } +}; + +pub const Library = struct { + templates: std.array_list.Managed(Template), + + pub fn init(allocator: std.mem.Allocator) Library { + return .{ .templates = std.array_list.Managed(Template).init(allocator) }; + } + + pub fn deinit(self: *Library) void { + for (self.templates.items) |*template| template.deinit(self.templates.allocator); + self.templates.deinit(); + } +}; + +pub fn load(allocator: std.mem.Allocator, project_path: []const u8) !Library { + var library = Library.init(allocator); + errdefer library.deinit(); + const project = try projectDirectory(allocator, project_path); + defer allocator.free(project); + try loadDirectory(&library, project); + const home = try homeDirectory(allocator); + defer allocator.free(home); + try loadDirectory(&library, home); + return library; +} + +pub fn apply(draft: *Forms.NodeDraft, template: Template, allocator: std.mem.Allocator) !void { + const shape = normalizeShape(template.shape); + try replace(allocator, &draft.loop_type, shape); + try replace(allocator, &draft.title, template.name); + if (std.mem.eql(u8, shape, "goalBased")) { + try replace(allocator, &draft.goal_summary, template.body); + } else if (std.mem.eql(u8, shape, "timeBased")) { + try replace(allocator, &draft.trigger_prompt, template.body); + } else { + try replace(allocator, &draft.first_instruction, template.body); + } +} + +pub fn fromDraft(allocator: std.mem.Allocator, name: []const u8, draft: Forms.NodeDraft) !Template { + const body = switch (draft.loop_type[0]) { + 'g' => draft.goal_summary, + 't' => if (std.mem.eql(u8, draft.loop_type, "timeBased")) draft.trigger_prompt else draft.first_instruction, + else => draft.first_instruction, + }; + if (std.mem.trim(u8, name, " \t\r\n").len == 0 or std.mem.trim(u8, body, " \t\r\n").len == 0) + return error.InvalidTemplate; + return .{ + .id = try randomId(allocator), + .name = try allocator.dupe(u8, name), + .body = try allocator.dupe(u8, body), + .shape = try allocator.dupe(u8, shapeWord(draft.loop_type)), + }; +} + +pub fn save(allocator: std.mem.Allocator, template: Template) !void { + const directory = try homeDirectory(allocator); + defer allocator.free(directory); + try std.fs.cwd().makePath(directory); + const file_name = try fileName(allocator, template.name); + defer allocator.free(file_name); + const path = try std.fs.path.join(allocator, &.{ directory, file_name }); + defer allocator.free(path); + const contents = try std.fmt.allocPrint(allocator, + "---\nid: {s}\nname: {s}\nshape: {s}\n---\n{s}\n", + .{ template.id, template.name, template.shape, template.body }); + defer allocator.free(contents); + try std.fs.cwd().writeFile(.{ .sub_path = path, .data = contents }); +} + +pub fn filterMatches(template: Template, query: []const u8) bool { + return query.len == 0 or containsIgnoreCase(template.name, query) or containsIgnoreCase(template.body, query); +} + +fn loadDirectory(library: *Library, directory: []const u8) !void { + var dir = std.fs.cwd().openDir(directory, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound, error.NotDir, error.AccessDenied => return, + else => return err, + }; + defer dir.close(); + var iterator = dir.iterate(); + while (try iterator.next()) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".md")) continue; + const text = try dir.readFileAlloc(library.templates.allocator, entry.name, 64 * 1024); + defer library.templates.allocator.free(text); + const template = parse(library.templates.allocator, text) catch continue; + if (containsID(library.templates.items, template.id)) { + var duplicate = template; + duplicate.deinit(library.templates.allocator); + continue; + } + try library.templates.append(template); + } +} + +fn parse(allocator: std.mem.Allocator, text: []const u8) !Template { + if (!std.mem.startsWith(u8, text, "---\n")) return error.InvalidTemplate; + const end = std.mem.indexOfPos(u8, text, 4, "---\n") orelse return error.InvalidTemplate; + const header = text[4..end]; + const body = std.mem.trim(u8, text[end + 4 ..], " \t\r\n"); + const id = headerValue(header, "id") orelse return error.InvalidTemplate; + const name = headerValue(header, "name") orelse return error.InvalidTemplate; + const shape = headerValue(header, "shape") orelse "turn"; + if (body.len == 0 or !validShape(shape)) return error.InvalidTemplate; + return .{ + .id = try allocator.dupe(u8, id), + .name = try allocator.dupe(u8, name), + .body = try allocator.dupe(u8, body), + .shape = try allocator.dupe(u8, shape), + }; +} + +fn headerValue(header: []const u8, key: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, header, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, key) or line.len <= key.len or line[key.len] != ':') continue; + return std.mem.trim(u8, line[key.len + 1 ..], " \t\r\n"); + } + return null; +} + +fn homeDirectory(allocator: std.mem.Allocator) ![]u8 { + const base = std.process.getEnvVarOwned(allocator, "LOCALAPPDATA") catch + return std.process.getEnvVarOwned(allocator, "APPDATA"); + defer allocator.free(base); + return std.fs.path.join(allocator, &.{ base, "GraphCode", "templates" }); +} + +fn projectDirectory(allocator: std.mem.Allocator, project_path: []const u8) ![]u8 { + return std.fs.path.join(allocator, &.{ project_path, ".graphcode", "templates" }); +} + +fn containsID(templates: []const Template, id: []const u8) bool { + for (templates) |template| if (std.mem.eql(u8, template.id, id)) return true; + return false; +} + +fn normalizeShape(shape: []const u8) []const u8 { + if (std.mem.eql(u8, shape, "goal") or std.mem.eql(u8, shape, "goalBased")) return "goalBased"; + if (std.mem.eql(u8, shape, "timed") or std.mem.eql(u8, shape, "timeBased")) return "timeBased"; + return "turnBased"; +} + +fn shapeWord(loop_type: []const u8) []const u8 { + if (std.mem.eql(u8, loop_type, "goalBased")) return "goal"; + if (std.mem.eql(u8, loop_type, "timeBased")) return "timed"; + return "turn"; +} + +fn validShape(shape: []const u8) bool { + return std.mem.eql(u8, shape, "turn") or std.mem.eql(u8, shape, "turnBased") or + std.mem.eql(u8, shape, "goal") or std.mem.eql(u8, shape, "goalBased") or + std.mem.eql(u8, shape, "timed") or std.mem.eql(u8, shape, "timeBased"); +} + +fn replace(allocator: std.mem.Allocator, target: *[]const u8, value: []const u8) !void { + const copy = try allocator.dupe(u8, value); + target.* = copy; +} + +fn randomId(allocator: std.mem.Allocator) ![]u8 { + var bytes: [16]u8 = undefined; + std.crypto.random.bytes(&bytes); + return std.fmt.allocPrint(allocator, "{x:0>2}{x:0>2}{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}", .{ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], + }); +} + +fn fileName(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + var result = std.array_list.Managed(u8).init(allocator); + defer result.deinit(); + for (name) |byte| { + if (std.ascii.isAlphanumeric(byte)) try result.append(std.ascii.toLower(byte)) + else if (byte == ' ' or byte == '-') try result.append('-'); + } + while (result.items.len != 0 and result.items[result.items.len - 1] == '-') _ = result.pop(); + if (result.items.len == 0) try result.appendSlice("template"); + try result.appendSlice(".md"); + return result.toOwnedSlice(); +} + +fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { + if (needle.len > haystack.len) return false; + for (0..haystack.len - needle.len + 1) |offset| { + if (std.ascii.eqlIgnoreCase(haystack[offset .. offset + needle.len], needle)) return true; + } + return false; +} + +test "template application deterministically maps prompt and shape to a draft" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + var draft = Forms.NodeDraft{ + .title = try allocator.dupe(u8, ""), + .loop_type = try allocator.dupe(u8, "turnBased"), + .first_instruction = try allocator.dupe(u8, "old"), + .goal_summary = try allocator.dupe(u8, ""), + .trigger_prompt = try allocator.dupe(u8, ""), + }; + const template = Template{ + .id = try allocator.dupe(u8, "template-id"), + .name = try allocator.dupe(u8, "Ship checklist"), + .body = try allocator.dupe(u8, "Verify the release."), + .shape = try allocator.dupe(u8, "goal"), + }; + try apply(&draft, template, allocator); + try std.testing.expectEqualStrings("goalBased", draft.loop_type); + try std.testing.expectEqualStrings("Ship checklist", draft.title); + try std.testing.expectEqualStrings("Verify the release.", draft.goal_summary); +} + +test "template search matches names and prompt text without case sensitivity" { + const template = Template{ + .id = @constCast("id"), + .name = @constCast("Release review"), + .body = @constCast("Inspect the diff"), + .shape = @constCast("turn"), + }; + try std.testing.expect(filterMatches(template, "REVIEW")); + try std.testing.expect(filterMatches(template, "diff")); + try std.testing.expect(!filterMatches(template, "deploy")); +} + +test "timed templates populate the scheduled prompt" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + var draft = Forms.NodeDraft{ + .title = try allocator.dupe(u8, ""), + .loop_type = try allocator.dupe(u8, "turnBased"), + .first_instruction = try allocator.dupe(u8, "old"), + .goal_summary = try allocator.dupe(u8, ""), + .trigger_prompt = try allocator.dupe(u8, ""), + }; + const template = Template{ + .id = try allocator.dupe(u8, "template-id"), + .name = try allocator.dupe(u8, "Poll"), + .body = try allocator.dupe(u8, "Check the queue."), + .shape = try allocator.dupe(u8, "timed"), + }; + try apply(&draft, template, allocator); + try std.testing.expectEqualStrings("timeBased", draft.loop_type); + try std.testing.expectEqualStrings("Check the queue.", draft.trigger_prompt); + try std.testing.expectEqualStrings("old", draft.first_instruction); +} diff --git a/graphcode-windows/src/Wire.zig b/graphcode-windows/src/Wire.zig index 686255ac..96ce2b48 100644 --- a/graphcode-windows/src/Wire.zig +++ b/graphcode-windows/src/Wire.zig @@ -593,6 +593,10 @@ pub fn commandGraphArmComposite(allocator: std.mem.Allocator, project_path: []co return graphUnaryUUID(allocator, project_path, "armComposite", node_id); } +pub fn commandGraphDetachTemplate(allocator: std.mem.Allocator, project_path: []const u8, node_id: []const u8) ![]u8 { + return graphUnaryUUID(allocator, project_path, "detachTemplate", node_id); +} + pub fn commandGraphRefreshUsage(allocator: std.mem.Allocator, project_path: []const u8) ![]u8 { const path = try quoteJson(allocator, project_path); defer allocator.free(path); return std.fmt.allocPrint(allocator, @@ -1083,6 +1087,12 @@ test "graph commands match Swift Codable associated-value shapes" { "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"stopNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\"}}}}", stop, ); + const detach_template = try commandGraphDetachTemplate(allocator, project, node); + defer allocator.free(detach_template); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"detachTemplate\":{\"_0\":\"11111111-1111-4111-8111-111111111111\"}}}}", + detach_template, + ); try std.testing.expectError( error.UnsupportedGraphAction, commandGraphNodeAction(allocator, project, node, "deleteNode", null), diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 96ffcd60..55830cec 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -81,7 +81,7 @@ Statuses: | Composite card actions | Open Group, Pilot Once, Arm Schedule | Canvas and sidebar composite menus expose all three actions. Open Group is live-validated; nested creates, edits, deletes, edge changes, pilot, and arm commands use the daemon's authoritative `subGraphCommand` envelope; and Arm Schedule is disabled unless the decoded pilot state is exactly `piloted` | Validated | | Edge presentation | Kind style, fired state, cycle label | Project edges retain kind-specific styling, condition/fired labels, and selected emphasis; dense labels now shift vertically to avoid overlap with earlier labels. Full cycle-guard wording is still limited by the current edge model, and live evidence remains blocked | Partial | | Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | A guided native form provides endpoint selectors, kind/condition/transform controls, conditional fields, cycle guards, inline validation, keyboard traversal, and scrolling. The shared `NativeForms.zig` window now paints the dark panel/text theme (`Tokens.dialog_panel`/`dialog_body_text`/`dialog_error_text`/`dialog_field_background`) via `WM_ERASEBKGND`/`WM_CTLCOLORSTATIC`/`WM_CTLCOLOREDIT`, matching the rest of the app instead of default Win32 gray. Focused form coverage remains green; recap and live recapture remain incomplete, and automated/UIA screenshot evidence of the new theme is still pending | Partial | -| Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. The loop-type field is now rendered as four owner-drawn "teaching tiles" (`NativeForms.zig`: `createTileButtons`/`drawTile`/`WM_DRAWITEM`) — rounded 9px cards with an accent color chip, bold title, and one-line description, mirroring `graphcode/Sources/Features/Project/LoopTypeChooser.swift`'s grid; tile accents are the exact macOS RGB values from `LoopTypeAppearance.swift` (turnBased #D55181, timeBased #C98500, goalBased #199E70, composite #9085E9) packed as correct COLORREFs via a new `tileColor` helper, with selection shown as an accent-tinted fill/border via `blendColor` and idle tiles a faint dark card, all on the shared dark panel background. Focused tests (including new tile-accent and layout-math tests) remain green; branch picker, recap, and live recapture remain incomplete, and live/UIA screenshot evidence of the new tiles is still pending | Partial | +| Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. The loop-type field is now rendered as four owner-drawn "teaching tiles" (`NativeForms.zig`: `createTileButtons`/`drawTile`/`WM_DRAWITEM`) — rounded 9px cards with an accent color chip, bold title, and one-line description, mirroring `graphcode/Sources/Features/Project/LoopTypeChooser.swift`'s grid; tile accents are the exact macOS RGB values from `LoopTypeAppearance.swift` (turnBased #D55181, timeBased #C98500, goalBased #199E70, composite #9085E9) packed as correct COLORREFs via a new `tileColor` helper, with selection shown as an accent-tinted fill/border via `blendColor` and idle tiles a faint dark card, all on the shared dark panel background. The form now also exposes a native saved-template picker that filters through keyboard-standard ComboBox behavior and deterministically pre-fills the typed draft; loop context menus can save reusable templates and detach followed templates. Focused Zig and WindowsShell coverage pass, and the live gate reached and accepted the template picker before an unrelated workspace-toolbar fixture assertion; branch picker, recap, and complete live recapture remain incomplete | Partial | | Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. The canvas context menu now also exposes Edit Details..., backed by the typed `NativeForms.update` editor and authoritative `sendUpdateNodeForm` path for goal, predicate, polling/stall, metric, trigger/check, and model fields. Focused form/wire coverage passes; live editor evidence remains blocked | Partial | | Delete confirmations | Named object, consequences, safe default | Loop deletion names the loop and explains graph-connection removal. Edge deletion now names both endpoint loops and the connection kind, explains that the loops remain, re-resolves the stable edge after confirmation, and defaults to cancellation | Validated | | Canvas context menu | Folder actions on background; complete node/edge actions | Background retains Create Edge; node menus expose composite Open Group/Pilot/Arm actions plus Edit Details and no longer show the non-macOS Message/Memo actions. Focused menu coverage remains the available evidence; live context-menu/UIA evidence is still pending because the current live gate does not synthesize a right-click menu walkthrough | Partial | From 7bfab824e8df2b31c5733de83a881071b55580e4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 10:23:05 -0700 Subject: [PATCH 2/2] Restore default Windows New Loop flow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/uia-live-gate.ps1 | 37 ++++++--- graphcode-windows/src/App.zig | 94 +++++++++++++++-------- graphcode-windows/src/NativeForms.zig | 59 +++++++++++++- graphcode-windows/src/TemplateLibrary.zig | 22 ++++++ 4 files changed, 167 insertions(+), 45 deletions(-) diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 6d63b205..610701a0 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -899,6 +899,34 @@ try { Require (Ensure-ShellForeground $shellWindow "project-row New Loop") ` "GraphCode shell did not reacquire foreground before invoking project-row New Loop" $projectNewLoop.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $sidebarNodeForm = $null + $sidebarNodeFormCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Create or edit node" + )) + ) + $sidebarNodeForm = Wait-ForDesktopElement ` + -desktop $desktop ` + -condition $sidebarNodeFormCondition ` + -label "project-row New Loop node form" ` + -diagnosticWindow $shellWindow ` + -RecoverForeground + Require ($null -ne $sidebarNodeForm) "project-row New Loop did not open the node form" + $templatesButton = $sidebarNodeForm.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Templates" + )) + ) + Require ($null -ne $templatesButton) "node form omitted the explicit Templates action" + Require ($templatesButton.Current.AutomationId -eq "4") ` + "Templates action did not expose its stable native command identity" + Require ([GraphCodeUiaGateState]::PostCommand( + [IntPtr]$sidebarNodeForm.Current.NativeWindowHandle, 4 + )) "Templates action rejected invocation" $templatePickerCondition = New-Object System.Windows.Automation.AndCondition( (New-Object System.Windows.Automation.PropertyCondition( [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id @@ -917,15 +945,6 @@ try { Require ([GraphCodeUiaGateState]::PostKeyboard( [IntPtr]$templatePicker.Current.NativeWindowHandle, 0x0D )) "template picker rejected keyboard application" - $sidebarNodeForm = $null - $sidebarNodeFormCondition = New-Object System.Windows.Automation.AndCondition( - (New-Object System.Windows.Automation.PropertyCondition( - [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id - )), - (New-Object System.Windows.Automation.PropertyCondition( - [System.Windows.Automation.AutomationElement]::NameProperty, "Create or edit node" - )) - ) $sidebarNodeForm = Wait-ForDesktopElement ` -desktop $desktop ` -condition $sidebarNodeFormCondition ` diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index ea595f70..348d77da 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1136,7 +1136,7 @@ pub const App = struct { const path = self.allocator.dupe(u8, current_path) catch return; defer self.allocator.free(path); const settings = self.product_settings orelse return; - var initial = Forms.NodeDraft{ + const initial = Forms.NodeDraft{ .title = "", .backend = settings.default_backend, .model_tier = settings.default_model, @@ -1150,42 +1150,72 @@ pub const App = struct { return; }; defer templates.deinit(); - if (templates.templates.items.len != 0) { - var labels = std.array_list.Managed([]const u8).init(self.allocator); - defer { - for (labels.items) |label| self.allocator.free(label); - labels.deinit(); - } - for (templates.templates.items) |template| { - const label = std.fmt.allocPrint(self.allocator, "{s} — {s}", .{ template.name, template.body }) catch { - self.setStatus("Unable to prepare saved template list"); - return; - }; - labels.append(label) catch { - self.allocator.free(label); - self.setStatus("Unable to prepare saved template list"); - return; - }; - } - const selected = NativeForms.templatePicker(self.window.hwnd, self.allocator, labels.items) catch { - self.setStatus("Unable to open saved template picker"); + if (templates.templates.items.len == 0) { + var draft = NativeForms.node(self.window.hwnd, self.allocator, initial) catch { + self.setStatus("Unable to open node form"); + return; + } orelse return; + defer draft.deinit(self.allocator); + Forms.validateNode(draft) catch { + self.setStatus("Invalid node form"); return; }; - if (selected) |index| TemplateLibrary.apply(&initial, templates.templates.items[index], self.allocator) catch { - self.setStatus("Unable to apply selected template"); + self.client.sendCreateNodeDraft(path, draft); + return; + } + + var labels = std.array_list.Managed([]const u8).init(self.allocator); + defer { + for (labels.items) |label| self.allocator.free(label); + labels.deinit(); + } + for (templates.templates.items) |template| { + const label = std.fmt.allocPrint(self.allocator, "{s} — {s}", .{ template.name, template.body }) catch { + self.setStatus("Unable to prepare saved template list"); + return; + }; + labels.append(label) catch { + self.allocator.free(label); + self.setStatus("Unable to prepare saved template list"); return; }; } - var draft = NativeForms.node(self.window.hwnd, self.allocator, initial) catch { - self.setStatus("Unable to open node form"); - return; - } orelse return; - defer draft.deinit(self.allocator); - Forms.validateNode(draft) catch { - self.setStatus("Invalid node form"); - return; - }; - self.client.sendCreateNodeDraft(path, draft); + + var current = initial; + var owns_current = false; + defer if (owns_current) current.deinit(self.allocator); + while (true) { + const result = NativeForms.nodeWithTemplates(self.window.hwnd, self.allocator, current, true) catch { + self.setStatus("Unable to open node form"); + return; + }; + switch (result) { + .cancelled => return, + .draft => |draft| { + var submitted = draft; + defer submitted.deinit(self.allocator); + Forms.validateNode(submitted) catch { + self.setStatus("Invalid node form"); + return; + }; + self.client.sendCreateNodeDraft(path, submitted); + return; + }, + .templates => |draft| { + if (owns_current) current.deinit(self.allocator); + current = draft; + owns_current = true; + const selected = NativeForms.templatePicker(self.window.hwnd, self.allocator, labels.items) catch { + self.setStatus("Unable to open saved template picker"); + return; + }; + if (selected) |index| TemplateLibrary.applyOwned(¤t, templates.templates.items[index], self.allocator) catch { + self.setStatus("Unable to apply selected template"); + return; + }; + }, + } + } } fn editSelectedNode(self: *App) void { diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 4ae49ddd..a7a3bbf8 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -36,6 +36,8 @@ const DialogState = struct { tile_buttons: [max_tiles]c.HWND = .{null} ** max_tiles, tile_count: usize = 0, template_options: []const []const u8 = &.{}, + templates_available: bool = false, + template_requested: bool = false, }; const max_tiles = 8; @@ -55,6 +57,7 @@ const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeNativeForm") const ok_id = 1; const cancel_id = 2; const reveal_id = 3; +const templates_id = 4; var active_state: bool = false; var active_state_storage: DialogState = undefined; @@ -170,8 +173,33 @@ pub fn node( allocator: std.mem.Allocator, initial: Forms.NodeDraft, ) !?Forms.NodeDraft { + return switch (try nodeWithTemplates(parent, allocator, initial, false)) { + .draft => |draft| draft, + .cancelled, .templates => null, + }; +} + +pub const NodeResult = union(enum) { + cancelled, + draft: Forms.NodeDraft, + templates: Forms.NodeDraft, +}; + +/// Opens the normal node form. Saved templates are an explicit secondary action, +/// mirroring macOS's Templates control rather than intercepting New Loop. +pub fn nodeWithTemplates( + parent: c.HWND, + allocator: std.mem.Allocator, + initial: Forms.NodeDraft, + templates_available: bool, +) !NodeResult { const state = try allocator.create(DialogState); - state.* = .{ .allocator = allocator, .kind = .node, .parent = parent }; + state.* = .{ + .allocator = allocator, + .kind = .node, + .parent = parent, + .templates_available = templates_available, + }; defer { freeValues(state); allocator.destroy(state); @@ -198,8 +226,11 @@ pub fn node( state.values[18] = try allocator.dupe(u8, initial.subgraph_json); state.values[19] = try allocator.dupe(u8, initial.created_by); for (0..20) |index| state.initial_values[index] = try allocator.dupe(u8, state.values[index]); - if (!(try show(state, "Create or edit node", &.{}))) return null; - return try buildNodeDraft(allocator, &state.values, initial); + if (!(try show(state, "Create or edit node", &.{}))) { + if (!state.template_requested) return .cancelled; + return .{ .templates = try buildNodeDraftUnchecked(allocator, &state.values, initial) }; + } + return .{ .draft = try buildNodeDraft(allocator, &state.values, initial) }; } /// A native, keyboard-searchable list of saved templates. The editable combo @@ -228,6 +259,17 @@ fn buildNodeDraft( allocator: std.mem.Allocator, values: []const []u8, initial: Forms.NodeDraft, +) !Forms.NodeDraft { + var result = try buildNodeDraftUnchecked(allocator, values, initial); + errdefer result.deinit(allocator); + try Forms.validateNode(result); + return result; +} + +fn buildNodeDraftUnchecked( + allocator: std.mem.Allocator, + values: []const []u8, + initial: Forms.NodeDraft, ) !Forms.NodeDraft { const goal_based = std.mem.eql(u8, values[1], "goalBased"); const poll_interval = if (goal_based) @@ -264,7 +306,6 @@ fn buildNodeDraft( result.copilot_permissions = initial.copilot_permissions; result.briefing_enabled = initial.briefing_enabled; result.activity_enabled = initial.activity_enabled; - try Forms.validateNode(result); return result; } @@ -949,6 +990,8 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) var client: c.RECT = undefined; _ = c.GetClientRect(safe_hwnd, &client); createButton(safe_hwnd, if (value.kind == .node) "Create" else if (value.kind == .worktree_policy) "Done" else if (value.kind == .worktree_sweep) "Remove Selected" else "OK", ok_id, 478, client.bottom - 38); + if (value.kind == .node and value.templates_available) + createButton(safe_hwnd, "Templates", templates_id, 300, client.bottom - 38); if (value.kind == .worktree_sweep) createButton(safe_hwnd, "Show in Explorer", reveal_id, 300, client.bottom - 38); createButton(safe_hwnd, "Cancel", cancel_id, 393, client.bottom - 38); return 0; @@ -976,6 +1019,8 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) _ = c.GetClientRect(safe_hwnd, &client); _ = c.MoveWindow(c.GetDlgItem(safe_hwnd, @intCast(ok_id)), 478, client.bottom - 38, 70, 26, 1); _ = c.MoveWindow(c.GetDlgItem(safe_hwnd, @intCast(cancel_id)), 393, client.bottom - 38, 70, 26, 1); + if (value.kind == .node and value.templates_available) + _ = c.MoveWindow(c.GetDlgItem(safe_hwnd, @intCast(templates_id)), 300, client.bottom - 38, 82, 26, 1); if (value.validation != null) _ = c.MoveWindow(value.validation, 18, client.bottom - 42, 360, 34, 1); if (value.kind != .worktree_policy) layoutForm(safe_hwnd, value); updateScrollBar(safe_hwnd, value); @@ -1064,6 +1109,12 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) } return 0; } + if (command == templates_id and value.kind == .node and value.templates_available) { + readValues(value); + value.template_requested = true; + applyModalCommand(value, .cancel); + return 0; + } if (command == cancel_id) { applyModalCommand(value, .cancel); return 0; diff --git a/graphcode-windows/src/TemplateLibrary.zig b/graphcode-windows/src/TemplateLibrary.zig index 222316fb..088001a3 100644 --- a/graphcode-windows/src/TemplateLibrary.zig +++ b/graphcode-windows/src/TemplateLibrary.zig @@ -55,6 +55,22 @@ pub fn apply(draft: *Forms.NodeDraft, template: Template, allocator: std.mem.All } } +/// Applies to a draft returned by NativeForms, whose editable values are all +/// allocator-owned. This preserves other in-progress form edits without leaking +/// the values that the selected template replaces. +pub fn applyOwned(draft: *Forms.NodeDraft, template: Template, allocator: std.mem.Allocator) !void { + const shape = normalizeShape(template.shape); + try replaceOwned(allocator, &draft.loop_type, shape); + try replaceOwned(allocator, &draft.title, template.name); + if (std.mem.eql(u8, shape, "goalBased")) { + try replaceOwned(allocator, &draft.goal_summary, template.body); + } else if (std.mem.eql(u8, shape, "timeBased")) { + try replaceOwned(allocator, &draft.trigger_prompt, template.body); + } else { + try replaceOwned(allocator, &draft.first_instruction, template.body); + } +} + pub fn fromDraft(allocator: std.mem.Allocator, name: []const u8, draft: Forms.NodeDraft) !Template { const body = switch (draft.loop_type[0]) { 'g' => draft.goal_summary, @@ -176,6 +192,12 @@ fn replace(allocator: std.mem.Allocator, target: *[]const u8, value: []const u8) target.* = copy; } +fn replaceOwned(allocator: std.mem.Allocator, target: *[]const u8, value: []const u8) !void { + const copy = try allocator.dupe(u8, value); + allocator.free(target.*); + target.* = copy; +} + fn randomId(allocator: std.mem.Allocator) ![]u8 { var bytes: [16]u8 = undefined; std.crypto.random.bytes(&bytes);