From 4c04cc96dd2384966436190f0d23c9bd4cd47f77 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 08:04:03 -0700 Subject: [PATCH 1/5] Prompt templates: reusable briefs for every loop type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PROMPT_TEMPLATES.md (New Designs v4): a Templates button and ⌘T picker in the New loop dialog, an applied state that never gains a shape silently, markdown template files in ~/.graphcode/templates (read also from /.graphcode/templates), Save as template, and follow-vs-snapshot — timed and composite loops re-read their template on the next run; main, goal and turn loops snapshot at creation. Includes the fixes from the design review of the first draft: Data loss and correctness - `TemplateStorage.move` wrote the copy and then deleted "the original" at the same path when a project relocation fell back to home, destroying the only copy. The move that isn't a move now stops before writing. - Saving no longer overwrites a different template that slugged onto the same filename; the second takes the next free name. Same guard on `move`. - A picker row click applied whatever the keyboard was on rather than the row clicked. - "Save as Template…" from a card's context menu set state that nothing presented — the only sheet host lived inside the New loop dialog. Both the canvas and the overview now host it, with a quiet notice bar for the path. - A following composite re-identified its whole sub-graph on every pilot, so the previous pass's child sessions were orphaned (node ids are zmx session names) and their memory stranded. It now compares what a human authored and only replaces when the template actually changed, tearing down the outgoing children when it does. Fidelity to the spec - Asking whether a project can take a template no longer creates `.graphcode/templates` in the checkout; nothing lands there by default. - The use count counts uses, not saves. - A resolve that flips a follow to `missing` now broadcasts, so the warning reaches the card; an unchanged resolve does not, so the timer-driven sweeps don't persist the graph every tick. - Templates carry an agent only when they name one, so "absent means the app's own default stands" is reachable and a shared template doesn't pin an agent a teammate may not have. - The relocation notice reports where the file actually landed. - A sheet rename reaches the filename (`PromptTemplate.renamed(to:)`). - The directory watch re-arms, so a `git pull` that creates `.graphcode/templates` is picked up rather than never seen. - An unwritable project is dropped from the save sheet rather than ineffectually `.disabled` on a Picker tag. - Tokens left in a done check, metric or branch block Start too; the save notice can be dismissed; ✕ restores the pre-template draft after two applies. Also: the repo-wide `cyclomatic_complexity` relaxation is dropped — the reducer is split into picker/apply/save switches instead, which is what the complexity was actually reporting. 17 new tests (1345 total, up from 1328), covering every fix above. make check: swiftlint 0 errors, swift-format clean on all touched files. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJR8Wuwc4qnaSBUMcYf7Af --- GraphcodeKit/Sources/Domain/LoopNode.swift | 44 ++ GraphcodeKit/Sources/Domain/NodeDraft.swift | 21 +- GraphcodeKit/Sources/GraphStore.swift | 208 +++++++- GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 7 + GraphcodeKit/Sources/ProjectRegistry.swift | 6 + .../Sources/Templates/PromptTemplate.swift | 473 ++++++++++++++++++ .../Sources/Templates/TemplateStorage.swift | 323 ++++++++++++ .../Clients/TemplateLibraryClient.swift | 125 +++++ .../Features/Canvas/LoopCardView.swift | 32 ++ .../Overview/GraphOverviewCards.swift | 6 + .../Features/Overview/GraphOverviewView.swift | 3 + .../Features/Project/NodeDraftFields.swift | 31 ++ .../Features/Project/NodeDraftForm.swift | 258 +++++++++- .../Project/NodeDraftTypeFields.swift | 71 ++- .../Features/Project/ProjectCanvasCards.swift | 9 + .../Features/Project/ProjectCanvasView.swift | 4 + .../ProjectFeature+TemplateSaving.swift | 269 ++++++++++ .../Project/ProjectFeature+Templates.swift | 407 +++++++++++++++ .../Features/Project/ProjectFeature.swift | 211 ++++++-- .../Project/ProjectFeatureState.swift | 123 +++++ .../Features/Project/TemplatePickerView.swift | 296 +++++++++++ .../Features/Project/TemplateSaveSheet.swift | 200 ++++++++ .../Features/Settings/SettingsView.swift | 116 +++++ graphcode/Tests/TemplateApplyTests.swift | 311 ++++++++++++ graphcode/Tests/TemplateFollowTests.swift | 368 ++++++++++++++ graphcode/Tests/TemplatePickerTests.swift | 207 ++++++++ graphcode/Tests/TemplateSaveTests.swift | 299 +++++++++++ graphcode/Tests/TemplateStorageTests.swift | 358 +++++++++++++ 28 files changed, 4692 insertions(+), 94 deletions(-) create mode 100644 GraphcodeKit/Sources/Templates/PromptTemplate.swift create mode 100644 GraphcodeKit/Sources/Templates/TemplateStorage.swift create mode 100644 graphcode/Sources/Clients/TemplateLibraryClient.swift create mode 100644 graphcode/Sources/Features/Project/ProjectFeature+TemplateSaving.swift create mode 100644 graphcode/Sources/Features/Project/ProjectFeature+Templates.swift create mode 100644 graphcode/Sources/Features/Project/TemplatePickerView.swift create mode 100644 graphcode/Sources/Features/Project/TemplateSaveSheet.swift create mode 100644 graphcode/Tests/TemplateApplyTests.swift create mode 100644 graphcode/Tests/TemplateFollowTests.swift create mode 100644 graphcode/Tests/TemplatePickerTests.swift create mode 100644 graphcode/Tests/TemplateSaveTests.swift create mode 100644 graphcode/Tests/TemplateStorageTests.swift diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index bc45717e..262bd2a8 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -7,6 +7,32 @@ import Foundation /// the full taxonomy describes — plain fields for now, one per loop type actually /// wired up (turn-based, time-based), rather than a whole payload-type hierarchy for /// types (`.goalBased`, `.composite`) nothing constructs yet. +/// The template a loop still reads its brief from — see PROMPT_TEMPLATES.md +/// (New Designs v4) § Follow vs snapshot. +/// +/// Only a **timed or composite** loop carries one: those re-read the template and +/// pick up its edits on their next run, so a fixed nightly brief doesn't need the +/// loop recreated. A Main, Goal or Turn loop snapshots its brief at creation and +/// never carries this — a running session cannot have its text swapped underneath +/// it. The node's own fields *are* the snapshot: if the template's file later +/// disappears, the loop keeps running on what it already had and says so. +public struct TemplateFollow: Codable, Equatable, Sendable { + /// The template's id, not its filename — how a follow survives a rename or a + /// move between home and a project. + public var id: UUID + public var name: String + /// Set when a resolve could not find the file. The loop keeps its snapshot and + /// the card warns rather than failing — the one difference between "the template + /// changed" and "the template is gone". + public var missing: Bool + + public init(id: UUID, name: String, missing: Bool = false) { + self.id = id + self.name = name + self.missing = missing + } +} + public struct LoopNode: Identifiable, Codable, Equatable, Sendable { public let id: UUID public var title: String @@ -134,6 +160,13 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// handoff, and custody has to be: stopping or deleting a parent takes its spawned /// descendants with it, while a drawn edge to a peer must never be caught in that. public let createdBy: UUID? + /// Which template this loop's brief came from, for every type — pure attribution + /// the card can show, never a live link. A snapshot loop keeps this and nothing + /// more; a following one also carries `templateFollow`. + public var createdFromTemplateID: UUID? + /// The template a **timed or composite** loop still follows — see `TemplateFollow` + /// for why only those two types do. `nil` for every snapshot loop. + public var templateFollow: TemplateFollow? public var state: LoopState public var createdAt: Date @@ -159,6 +192,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { presence: PresenceReading? = nil, metricHistory: [MetricSample] = [], createdBy: UUID? = nil, + createdFromTemplateID: UUID? = nil, + templateFollow: TemplateFollow? = nil, state: LoopState = .idle, createdAt: Date = Date() ) { @@ -183,6 +218,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.presence = presence self.metricHistory = metricHistory self.createdBy = createdBy + self.createdFromTemplateID = createdFromTemplateID + self.templateFollow = templateFollow self.state = state self.createdAt = createdAt } @@ -418,6 +455,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds + case createdFromTemplateID, templateFollow } /// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph` @@ -457,6 +495,12 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { metricHistory = try container.decodeIfPresent([MetricSample].self, forKey: .metricHistory) ?? [] createdBy = try container.decodeIfPresent(UUID.self, forKey: .createdBy) + createdFromTemplateID = + try container.decodeIfPresent(UUID.self, forKey: .createdFromTemplateID) + // Absent on every graph saved before templates existed — those loops were all + // snapshots, which is what nil says. + templateFollow = try container.decodeIfPresent( + TemplateFollow.self, forKey: .templateFollow) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } diff --git a/GraphcodeKit/Sources/Domain/NodeDraft.swift b/GraphcodeKit/Sources/Domain/NodeDraft.swift index 4e2fc641..c825ccf8 100644 --- a/GraphcodeKit/Sources/Domain/NodeDraft.swift +++ b/GraphcodeKit/Sources/Domain/NodeDraft.swift @@ -68,6 +68,12 @@ public struct NodeDraft: Codable, Equatable, Sendable { /// /// `nil` for anything a human created, which is the truth: the form is not a loop. public var createdBy: UUID? + /// Which template the brief came from — attribution, carried to the node. + public var createdFromTemplateID: UUID? + /// The template a **timed or composite** draft follows — see + /// `TemplateFollow`. The text itself travels in the type's own field as + /// the creation-time snapshot; this only says what to re-read next run. + public var templateFollow: TemplateFollow? public init( id: UUID = UUID(), @@ -83,7 +89,9 @@ public struct NodeDraft: Codable, Equatable, Sendable { modelTier: ModelTier? = nil, worktree: WorktreeRef? = nil, subGraph: LoopGraph? = nil, - createdBy: UUID? = nil + createdBy: UUID? = nil, + createdFromTemplateID: UUID? = nil, + templateFollow: TemplateFollow? = nil ) { self.id = id self.title = title @@ -99,6 +107,8 @@ public struct NodeDraft: Codable, Equatable, Sendable { self.worktree = worktree self.subGraph = subGraph self.createdBy = createdBy + self.createdFromTemplateID = createdFromTemplateID + self.templateFollow = templateFollow } /// docs/08-quality-and-token-budgets.md wants the cheap-to-ignore version of each @@ -183,7 +193,7 @@ public struct NodeDraft: Codable, Equatable, Sendable { public func makeNode() -> LoopNode { LoopNode( // The draft's own id, not a fresh one — the client that built the draft may - // already be holding this id to address a follow-up at (see `id`). + // already be holding this id to address a follow-up at (see `NodeDraft.id`). id: id, title: resolvedTitle, loopType: loopType, @@ -205,6 +215,8 @@ public struct NodeDraft: Codable, Equatable, Sendable { project: ProjectRef(path: "\(resolvedTitle)-subgraph", name: resolvedTitle))) : nil, createdBy: createdBy, + createdFromTemplateID: createdFromTemplateID, + templateFollow: loopType == .timeBased || loopType == .composite ? templateFollow : nil, state: loopType == .goalBased ? .running : .idle) } } @@ -214,6 +226,7 @@ extension NodeDraft { case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier case worktree, subGraph, createdBy, firstInstruction, pausesBeforeWritesOnly case heartbeatIntervalSeconds + case createdFromTemplateID, templateFollow } /// `id` is `decodeIfPresent` because drafts also arrive over the wire from a CLI that @@ -240,5 +253,9 @@ extension NodeDraft { worktree = try container.decodeIfPresent(WorktreeRef.self, forKey: .worktree) subGraph = try container.decodeIfPresent(LoopGraph.self, forKey: .subGraph) createdBy = try container.decodeIfPresent(UUID.self, forKey: .createdBy) + createdFromTemplateID = + try container.decodeIfPresent(UUID.self, forKey: .createdFromTemplateID) + templateFollow = + try container.decodeIfPresent(TemplateFollow.self, forKey: .templateFollow) } } diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index d05a669a..357a9fba 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -197,6 +197,7 @@ public actor GraphStore { @Sendable (LoopNode, LoopSummary, String?, String?) async -> SummaryBoard? )? = nil, onBoardsEnabled: (@Sendable () -> Bool)? = nil, + onResolveTemplate: (@Sendable (UUID, String?) -> PromptTemplate?)? = nil, subGraphDepth: Int = 0 ) { self.graph = graph @@ -220,6 +221,7 @@ public actor GraphStore { self.onHeartbeatEnabled = onHeartbeatEnabled self.onComposeBoard = onComposeBoard self.onBoardsEnabled = onBoardsEnabled + self.onResolveTemplate = onResolveTemplate } private func recordMemory(_ nodeID: UUID, _ entry: String) { @@ -248,8 +250,204 @@ public actor GraphStore { /// The path is where the session opens when the node has no worktree of its own. Without /// it a daemon-launched loop inherits `graphcoded`'s own directory, which under launchd /// is `/`, so the loop ran nowhere near the project it was created in. + /// + /// This is also where a **following loop picks up its template's edits** — every start + /// is a "next run", whatever caused it. The resolve runs before the launch, so the + /// session opens on the current brief and the node's stored snapshot is refreshed with + /// it; see `resolvedForLaunch`. private func ensureSession(_ node: LoopNode) { - onEnsureSession?(node, graph.project.path) + onEnsureSession?(resolvedForLaunch(node), graph.project.path) + } + + // MARK: - Template follows + + /// Asks the storage layer for the template a loop follows, when it can. Injected + /// like every other side effect so tests can stand in a scratch directory; the + /// production wiring reads home + the project's own `.graphcode/templates`, + /// project winning on a filename collision. + private var onResolveTemplate: (@Sendable (UUID, String?) -> PromptTemplate?)? + + /// Re-reads a following loop's template at a run boundary and returns the node to + /// launch with — the design's "they re-read it and pick up edits on the next run", + /// with the node's own fields as the fallback snapshot. + /// + /// Three refusals keep a resolve from mangling a loop: + /// - The template's file is gone → the node keeps its snapshot and `missing` flips + /// on (once — the card warns, nothing fails). + /// - The body still carries `{tokens}` nobody filled → the snapshot stands; a brief + /// with a hole in it is not a brief. + /// - The template has since committed to a different shape → the snapshot stands; + /// a loop cannot change what it is underneath a running session. + /// + /// The refreshed node is written back to wherever it lives (top level or a + /// composite's sub-graph) so the change survives a restart. Commands broadcast + /// through `handle`; the two session sweeps are not commands and have to say so + /// themselves — see `broadcastIfTemplatesRefreshed`. + func resolvedForLaunch(_ node: LoopNode) -> LoopNode { + guard let follow = node.templateFollow, let resolve = onResolveTemplate else { return node } + guard let template = resolve(follow.id, graph.project.path) else { + if !follow.missing, var stored = stored(node.id) { + stored.templateFollow?.missing = true + store(stored) + templatesRefreshed = true + } + return node + } + guard var refreshed = refreshedCopy(of: node, from: template) else { return node } + refreshed.templateFollow?.missing = false + // Only a resolve that actually changed something is a write. The sweeps run on a + // timer, so storing an identical node would persist the graph every tick for + // bytes nobody's edited. + if refreshed != node { + store(refreshed) + templatesRefreshed = true + } + return refreshed + } + + /// Set by a resolve that changed a node, drained by the session sweeps. Without it + /// a `missing` template — the one thing the design puts on the card — would sit in + /// the daemon's memory and never reach a client, because nothing else in those + /// sweeps broadcasts. + private var templatesRefreshed = false + + private func broadcastIfTemplatesRefreshed() { + guard templatesRefreshed else { return } + templatesRefreshed = false + broadcast() + } + + /// The node a template's current contents would launch — or the unchanged node + /// when the resolve declines (refusals above). The recomposition preserves what + /// the old prompt already knew: the cadence, unless the template now carries one + /// of its own, and any trailing "Stop after …" the old brief promised. + private func refreshedCopy(of node: LoopNode, from template: PromptTemplate) -> LoopNode? { + let body = template.body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !body.isEmpty, PromptTemplate.tokens(in: body).isEmpty else { return nil } + if let shape = template.shape, shape != node.loopType { return nil } + var refreshed = node + if node.heartbeatIntervalSeconds != nil { + // The daemon holds the timer, so the prompt is the bare task — recomposing a + // /loop here would double-drive the loop. + refreshed.triggerPrompt = body + return refreshed + } + guard let old = node.triggerPrompt else { + refreshed.triggerPrompt = body + return refreshed + } + guard let recurrence = SessionPrompt.recurrence(of: old) else { + refreshed.triggerPrompt = body + return refreshed + } + let cadence = + template.settings?.cadence.map { $0.trimmingCharacters(in: .whitespaces) } + .flatMap { $0.isEmpty ? nil : $0 } ?? recurrence.interval + var prompt = "/loop \(cadence) \(body)" + if let stop = Self.stopAfterClause(of: old) { prompt += " Stop after \(stop)." } + refreshed.triggerPrompt = prompt + return refreshed + } + + /// The "Stop after …" tail of a composed prompt, without its punctuation — so a + /// refresh can carry the same promise forward rather than silently dropping it. + /// Searched backwards: the clause the form appends is the last one, and a brief is + /// perfectly entitled to use the words "stop after" in its own sentence. + static func stopAfterClause(of prompt: String) -> String? { + guard let range = prompt.range(of: "Stop after ", options: .backwards) else { return nil } + let tail = + prompt[range.upperBound...] + .trimmingCharacters(in: CharacterSet(charactersIn: ". \n")) + return tail.isEmpty ? nil : tail + } + + /// A composite that follows its template re-reads the **graph** the template + /// carries before a pilot — the pilot is the composite's next run. The template is + /// the source of truth a following composite has chosen, and `Detach` is how a + /// local re-arrangement opts out. + /// + /// Replacing the sub-graph is destructive in a way the rest of a follow is not: + /// node ids are `zmx` session names, so re-identified children mean the previous + /// pass's sessions are still running with nothing in the graph pointing at them, + /// and their memory logs are stranded under ids no card can reach. So two rules: + /// **nothing happens unless the template's graph actually differs from what is + /// here** (compared on what a human authored, not on ids or run state), and when it + /// does differ the outgoing children are torn down the way `removeSingleNode` tears + /// down a deleted composite's workers. + private func resolveCompositeFollow(_ nodeID: UUID) { + guard let node = graph.nodes[id: nodeID], node.loopType == .composite, + let follow = node.templateFollow, let resolve = onResolveTemplate, + let template = resolve(follow.id, graph.project.path) + else { return } + // The template was found, so the follow is intact whatever it carries. A + // composite template with no children is a template someone hasn't finished, not + // a missing file — `missing` means the file is gone, and saying it here would put + // the wrong warning on the card. + graph.nodes[id: nodeID]?.templateFollow?.missing = false + guard let carried = template.settings?.carriedGraph, !carried.nodes.isEmpty else { return } + let current = node.subGraph + guard Self.authoredShape(of: carried) != current.map(Self.authoredShape(of:)) else { return } + for worker in current?.nodesAtAnyDepth ?? [] { + terminateSession(worker) + onRemoveMemory?(worker.id) + } + graph.nodes[id: nodeID]?.subGraph = carried.reIdentified() + } + + /// A sub-graph reduced to what a person wrote — titles, types, briefs, agents and + /// the edges between them, positionally. Ids, run state, usage and presence are all + /// left out, because two copies of the same template's graph differ in every one of + /// them and are still the same orchestration. + static func authoredShape(of graph: LoopGraph) -> String { + var position: [UUID: Int] = [:] + for (index, node) in graph.nodes.enumerated() { position[node.id] = index } + let nodes = graph.nodes.map { node in + [ + node.title, String(describing: node.loopType), node.triggerPrompt ?? "", + node.firstInstruction ?? "", node.goal?.summary ?? "", node.goal?.predicate ?? "", + node.goal?.metricCommand ?? "", String(describing: node.backend), + String(node.pausesBeforeWritesOnly), + ].joined(separator: "\u{1}") + } + let edges = + graph.edges + .map { edge in + "\(position[edge.from].map(String.init) ?? "?")>" + + "\(position[edge.to].map(String.init) ?? "?"):\(String(describing: edge.kind))" + } + .sorted() + return (nodes + ["--"] + edges).joined(separator: "\u{2}") + } + + /// `GraphCommand.detachTemplate`: the follow is dropped and the node's own brief + /// — which is already exactly what it has been running — becomes the whole truth. + private func detachTemplate(_ nodeID: UUID) { + guard var node = graph.nodes[id: nodeID], node.templateFollow != nil else { return } + node.templateFollow = nil + graph.nodes[id: nodeID] = node + recordMemory(nodeID, "detached from its template — the current brief is now its own") + } + + /// Where a node with this id actually lives — top level, or inside a composite's + /// sub-graph. `nil` when it has been deleted under the resolve. + private func stored(_ nodeID: UUID) -> LoopNode? { + if graph.nodes[id: nodeID] != nil { return graph.nodes[id: nodeID] } + for composite in graph.nodes { + if let child = composite.subGraph?.nodes[id: nodeID] { return child } + } + return nil + } + + /// The write-back half of `stored(_:)` — same search, assignment instead. + private func store(_ node: LoopNode) { + if graph.nodes[id: node.id] != nil { + graph.nodes[id: node.id] = node + } else { + for composite in graph.nodes + where composite.subGraph?.nodes[id: node.id] != nil { + graph.nodes[id: composite.id]?.subGraph?.nodes[id: node.id] = node + } + } } // MARK: - Connections @@ -373,6 +571,9 @@ public actor GraphStore { case .promoteNode(let nodeID, let promotion, let promotedBy): promoteNode(nodeID, promotion: promotion, promotedBy: promotedBy) + case .detachTemplate(let nodeID): + detachTemplate(nodeID) + case .memoNode(let nodeID, let text, let from): memoNode(nodeID, text: text, from: from) @@ -515,6 +716,9 @@ public actor GraphStore { guard let node = graph.nodes[id: nodeID], node.loopType == .composite, node.subGraph != nil else { return } + // The template's edits land here, at the run boundary a following composite + // has — see `resolveCompositeFollow`. + resolveCompositeFollow(nodeID) graph.nodes[id: nodeID]?.pilotState = .piloting graph.nodes[id: nodeID]?.state = .running @@ -2254,6 +2458,7 @@ public actor GraphStore { if !node.isResolved { armHeartbeat(for: node) } ensureSession(node) } + broadcastIfTemplatesRefreshed() } /// The session half of `ensureUnattendedSessions`, for the repeating remote liveness @@ -2275,6 +2480,7 @@ public actor GraphStore { for node in graph.nodes where node.runsUnattended && !node.isResolved { ensureSession(node) } + broadcastIfTemplatesRefreshed() } // MARK: - Broadcast diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 13278954..17b0b717 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -95,6 +95,13 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// stop condition. Optional so frames from clients that predate the field decode as /// an unattributed promotion rather than failing. case promoteNode(UUID, promotion: SketchPromotion, promotedBy: UUID?) + /// Stop a following loop from reading its template — see + /// `LoopNode.templateFollow`. Detaching converts it to a snapshot *in place*: the + /// brief the node already carries keeps running exactly as it is, and the next + /// edit to the template's file no longer reaches it. One local tweak should never + /// force a fork of the shared file, which is what editing a followed template + /// otherwise asks for. + case detachTemplate(UUID) /// Append a learned note to a node's memory log (`NodeMemory`) — what `graphcode /// node memo` rides on. `from` is attributed the same way `messageNode`'s is. case memoNode(UUID, text: String, from: UUID?) diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index 4b6f0f85..a4e3ba08 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -475,6 +475,12 @@ public actor ProjectRegistry { // meaningless without the reading that feeds it. Switching the rail off takes the // boards with it rather than leaving pictures of a run nothing is narrating. return settings.summarisesLoops && settings.visualisesSummaries + }, + // What a following loop re-reads at its next run: home + this project's + // `.graphcode/templates`, project winning on a filename collision. See + // `TemplateStorage`. + onResolveTemplate: { templateID, projectPath in + TemplateStorage.shared.template(withID: templateID, projectPath: projectPath) }) stores[path] = newStore // Only on first load of this project — a time-based node's session outlives the app diff --git a/GraphcodeKit/Sources/Templates/PromptTemplate.swift b/GraphcodeKit/Sources/Templates/PromptTemplate.swift new file mode 100644 index 00000000..aee88250 --- /dev/null +++ b/GraphcodeKit/Sources/Templates/PromptTemplate.swift @@ -0,0 +1,473 @@ +import Foundation + +/// A reusable brief for a loop — one markdown file, human-readable and diffable. +/// See PROMPT_TEMPLATES.md (New Designs v4). +/// +/// Four parts, only the first required: +/// - **body** — the prompt, with `{token}` placeholders the person using it fills in. +/// - **shape** — the loop type the text assumes; `nil` and the loop stays Main. +/// - **settings** — done check, cadence, agent, branch, each landing in its real field. +/// - **origin** — home or a project folder; project ones sort above home ones. +/// +/// A composite template additionally carries its sub-graph, which is how an +/// orchestration gets shared; it serialises inside the front matter as one JSON line. +public struct PromptTemplate: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var body: String + /// The loop type this brief assumes. Written in front matter with the human-facing + /// word (`goal`, `timed`), read back through either that word or `LoopType`'s raw + /// value — both have shipped, and a hand-edited file should never refuse to load. + public var shape: LoopType? + public var settings: TemplateSettings? + public var origin: TemplateOrigin + /// The name of the file this template was read from — the key the loader dedupes + /// on, so a project's copy of `review-diff.md` wins over home's regardless of what + /// either names itself inside. Fresh templates (not yet written) derive it from + /// their name. + public var fileName: String + /// How many times this template has been applied, as this app has counted it. + /// **Never written back into the file** — applying a template must not dirty a + /// repository's working tree, and a project folder may not even be writable. It is + /// app-local state the loader overlays, keyed on the filename. + public var useCount: Int + + public init( + id: UUID = UUID(), + name: String, + body: String, + shape: LoopType? = nil, + settings: TemplateSettings? = nil, + origin: TemplateOrigin = .home, + useCount: Int = 0 + ) { + self.id = id + self.name = name + self.body = body + self.shape = shape + self.settings = settings + self.origin = origin + self.fileName = Self.fileName(for: name) + self.useCount = useCount + } + + /// The tokens the body still asks to be filled, in order of first appearance — + /// the order the picker's chips show and the order tabbing walks. + public var tokens: [String] { + Self.tokens(in: body) + } + + public static func tokens(in text: String) -> [String] { + var seen = Set() + var ordered: [String] = [] + for range in text.ranges(of: Self.tokenPattern) { + let token = String(text[range]).dropFirst().dropLast() + if seen.insert(String(token)).inserted { ordered.append(String(token)) } + } + return ordered + } + + /// What was typed over each `{token}`, recovered by matching the filled text + /// against the template's own brief. The literals around the tokens have to + /// match exactly — someone who rewrote more than the tokens gets `nil` and + /// their text is saved as they wrote it, never guessed at. + public static func tokenValues(of filled: String, against brief: String) -> [String: String]? { + let tokenRanges = Array(brief.ranges(of: Self.tokenPattern)) + guard !tokenRanges.isEmpty else { return [:] } + var pattern = "^" + var tokensFound: [String] = [] + var cursor = brief.startIndex + for range in tokenRanges { + let literal = String(brief[cursor.. PromptTemplate { + var renamed = self + renamed.name = newName + renamed.fileName = Self.fileName(for: newName) + return renamed + } + + /// The filename a new template is stored under — the slug of its name, so a + /// committed `review-diff.md` reads in a diff the way the template reads in the app. + public static func fileName(for name: String) -> String { + let slug = + name + .lowercased() + .replacingOccurrences(of: " ", with: "-") + .filter { $0.isLetter || $0.isNumber || $0 == "-" } + let trimmed = slug.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return (trimmed.isEmpty ? "template" : String(trimmed.prefix(64))) + ".md" + } + + /// The body's first sentence — the picker's second line, so a list of briefs can + /// be read without opening any of them. A sentence rather than a line, because a + /// brief written as one paragraph would otherwise show its whole first paragraph + /// and a brief written as bullets would show only its heading. + public var summaryLine: String { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return name } + let firstBreak = trimmed.firstIndex(where: { $0.isNewline }) ?? trimmed.endIndex + let firstLine = String(trimmed[.. 96 else { return sentence } + return String(sentence.prefix(93)) + "…" + } + + /// Up to and including the first sentence terminator that actually ends a + /// sentence. A terminator followed by anything other than a space ends nothing — + /// which is what keeps `./scripts/score.sh` and `v1.2` in one piece. + static func firstSentence(of line: String) -> String { + var index = line.startIndex + while let stop = line[index...].firstIndex(where: { $0 == "." || $0 == "?" || $0 == "!" }) { + let after = line.index(after: stop) + if after == line.endIndex || line[after].isWhitespace { + return String(line[.. String { + switch loopType { + case .sketch: return "main" + case .goalBased: return "goal" + case .timeBased: return "timed" + case .turnBased: return "turn" + case .composite: return "composite" + case nil: return "main" + } + } + + public static func parse(_ raw: String) -> LoopType? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if let exact = TemplateShapeWord(rawValue: trimmed)?.loopType { return exact } + // Case-insensitive fallback: `GoalBased`, `goalbased`, `TIMEBASED` all mean + // what their lower-case spelling means — a hand-edited file should load. + let lowered = trimmed.lowercased() + return TemplateShapeWord.allCases + .first(where: { $0.rawValue.lowercased() == lowered })? + .loopType + } +} + +/// Everything but the prompt a template can set. Each field lands in its *real* +/// dialog field when applied — never a preview — so the names mirror the form's own. +public struct TemplateSettings: Codable, Equatable, Sendable { + /// The agent the prompt assumes; absent means the app's own default stands. + public var backend: CLISessionBackendKind? + /// A goal loop's done check (`GoalSpec.predicate`). + public var doneCheck: String? + /// A timed loop's cadence, as the `/loop` directive spells it — "1h", "daily", + /// whatever `IntervalChoice.directiveValue` produces. The node's own session owns + /// the timer; this only writes the directive. + public var cadence: String? + /// A turn loop's pause shape. + public var pausesBeforeWritesOnly: Bool? + /// The branch to cut a worktree for. An existing branch binds as `.existing`; + /// this template only ever asks for a *new* one, because a name that exists on + /// every machine a template reaches is a name the loader cannot promise. + public var branch: String? + /// A goal loop's progress metric. + public var metric: String? + /// A composite's carried sub-graph — the children and edges an orchestration + /// shares. One JSON line, `LoopGraph`'s own encoding, re-identified on apply. + public var graphJSON: String? + + public init( + backend: CLISessionBackendKind? = nil, + doneCheck: String? = nil, + cadence: String? = nil, + pausesBeforeWritesOnly: Bool? = nil, + branch: String? = nil, + metric: String? = nil, + graphJSON: String? = nil + ) { + self.backend = backend + self.doneCheck = doneCheck + self.cadence = cadence + self.pausesBeforeWritesOnly = pausesBeforeWritesOnly + self.branch = branch + self.metric = metric + self.graphJSON = graphJSON + } + + public var isEmpty: Bool { + self == TemplateSettings() + } +} + +/// Where a template was read from — which is also where a copy of it lives. +/// `project` carries the folder's path, not the templates directory's, because the +/// destination for a later "put it in the project" is derived from it. +public enum TemplateOrigin: Codable, Equatable, Hashable, Sendable { + /// `~/.graphcode/templates` — yours, offered in every project, the default + /// write target. + case home + /// `/.graphcode/templates` — read if present, sorted first. + case project(String) + + public var isProject: Bool { + if case .project = self { return true } + return false + } +} + +// MARK: - File format + +/// One template is one markdown file: a `---`-delimited front matter block for +/// shape, settings and identity, then the body. Deliberately plain — a template +/// should be editable in an editor, readable in a diff, and loadable by a script, +/// which is why this is a tiny bespoke parser rather than a YAML dependency: the +/// schema is flat key-value lines and nothing more. +public enum TemplateFileCodec { + private static let frontMatterDelimiter = "---" + + /// Reads a template out of a file's text. `origin` is supplied by the caller — + /// the file cannot know where it lives. + public static func decode(_ text: String, origin: TemplateOrigin) -> PromptTemplate? { + var lines = Substring(text) + guard lines.hasPrefix(frontMatterDelimiter + "\n") || lines == frontMatterDelimiter + else { + // No front matter: the whole file is the body. A template that is only a + // prompt is a complete template — name from the first line, Main shape. + let body = String(lines).trimmingCharacters(in: .whitespacesAndNewlines) + guard !body.isEmpty else { return nil } + let firstLine = body.split(separator: "\n", maxSplits: 1)[0] + return PromptTemplate( + name: String(firstLine.prefix(64)).trimmingCharacters(in: .whitespaces), + body: body, origin: origin) + } + lines.removeFirst(frontMatterDelimiter.count + 1) + guard let end = lines.range(of: "\n" + frontMatterDelimiter) + else { return nil } + let header = String(lines[.. String { + var lines: [String] = [frontMatterDelimiter] + lines.append("id: \(template.id.uuidString)") + lines.append("name: \(quoteIfNeeded(template.name))") + lines.append("shape: \(TemplateShapeWord.word(for: template.shape))") + if let settings = template.settings, !settings.isEmpty { + if let backend = settings.backend { + lines.append("backend: \(backend.rawValue)") + } + if let check = settings.doneCheck { lines.append("done-check: \(quoteIfNeeded(check))") } + if let cadence = settings.cadence { lines.append("cadence: \(cadence)") } + if settings.pausesBeforeWritesOnly == true { + lines.append("pauses-before-writes-only: true") + } + if let branch = settings.branch { lines.append("branch: \(quoteIfNeeded(branch))") } + if let metric = settings.metric { lines.append("metric: \(quoteIfNeeded(metric))") } + if let graph = settings.graphJSON { lines.append("graph: \(quoteIfNeeded(graph))") } + } + lines.append(frontMatterDelimiter) + lines.append("") + lines.append(template.body) + return lines.joined(separator: "\n") + "\n" + } + + private static func unwrapQuoted(_ value: String) -> String { + guard value.count >= 2 else { return value } + let first = value.first! + let last = value.last! + if (first == "\"" && last == "\"") || (first == "'" && last == "'") { + // `encode` escapes the quotes it adds around values that contain them; the + // read side undoes exactly that, so a carried graph's JSON survives the trip. + return String(value.dropFirst().dropLast()) + .replacingOccurrences(of: "\\\"", with: "\"") + } + return value + } + + private static func quoteIfNeeded(_ value: String) -> String { + // The last clause is the round trip talking: a value that already begins and ends + // with a quote would come back through `unwrapQuoted` with those quotes eaten, so + // it has to be quoted on the way out even when nothing else would require it. + let looksQuoted = + value.count >= 2 + && ((value.hasPrefix("\"") && value.hasSuffix("\"")) + || (value.hasPrefix("'") && value.hasSuffix("'"))) + let needsQuoting = + value.contains(":") || value.hasPrefix("{") || value.hasPrefix("[") + || value.hasPrefix("#") || looksQuoted + return needsQuoting + ? "\"\(value.replacingOccurrences(of: "\"", with: "\\\""))\"" : value + } +} + +extension TemplateSettings { + /// The carried sub-graph, decoded — `nil` when the JSON doesn't decode, which is + /// a template that simply doesn't carry one rather than a template that fails. + public var carriedGraph: LoopGraph? { + graphJSON.flatMap { json in + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(LoopGraph.self, from: data) + } + } + + /// The carried sub-graph, encoded — the one JSON line `graph:` stores. + public static func graphJSON(for graph: LoopGraph) -> String? { + guard let data = try? JSONEncoder().encode(graph) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/GraphcodeKit/Sources/Templates/TemplateStorage.swift b/GraphcodeKit/Sources/Templates/TemplateStorage.swift new file mode 100644 index 00000000..b30e3be5 --- /dev/null +++ b/GraphcodeKit/Sources/Templates/TemplateStorage.swift @@ -0,0 +1,323 @@ +import Foundation + +/// Where prompt templates live and how they are read, written and watched. +/// See PROMPT_TEMPLATES.md (New Designs v4) § Storage. +/// +/// **Home is where the app writes; the project is also read.** +/// +/// | Location | Role | +/// | --- | --- | +/// | `~/.graphcode/templates/*.md` | Yours, offered in every project. Save writes here by default. | +/// | `/.graphcode/templates/*.md` | Read if present, sorted first. Saving here is an explicit choice. | +/// +/// Nothing may land in a checkout by default — many repos will not accept a new +/// dotfolder — and the app must be fully usable with only the home location. A team +/// that *can* commit templates gets project ones for free. Sharing is a git action, +/// not an app feature: GraphCode never pushes, pulls, or touches `.gitignore`. +/// +/// Pure Foundation so both the app and the daemon can read the same files; the +/// daemon resolves a following loop's brief here at its next run. +public struct TemplateStorage: Sendable { + /// Injected so tests can point every path at a scratch directory — the real + /// entry points below default to the true locations. + public var homeDirectory: URL + public var projectDirectory: @Sendable (String) -> URL + + public init( + homeDirectory: URL? = nil, + projectDirectory: (@Sendable (String) -> URL)? = nil + ) { + self.homeDirectory = + homeDirectory ?? SupportDirectory.url.appendingPathComponent("templates", isDirectory: true) + self.projectDirectory = + projectDirectory ?? { path in + URL(fileURLWithPath: path, isDirectory: true) + .appendingPathComponent(".graphcode", isDirectory: true) + .appendingPathComponent("templates", isDirectory: true) + } + } + + public static let shared = TemplateStorage() + + // MARK: - Reading + + /// Every template this project is offered: the project's own first, then home. + /// Same filename in both means the project's copy wins — a team's committed + /// version outranks a personal one with the same name, which is what "sorted + /// first and deduped with project winning" means when both exist. + public func load(projectPath: String?) -> [PromptTemplate] { + let home = read(directory: homeDirectory, origin: .home) + guard let projectPath, !projectPath.isEmpty else { return home } + let project = read( + directory: projectDirectory(projectPath), origin: .project(projectPath)) + guard !project.isEmpty else { return home } + let projectNames = Set(project.map(\.fileName)) + return project + home.filter { !projectNames.contains($0.fileName) } + } + + /// The one template a following loop looks for, by id — how a rename or a move + /// between home and a project keeps the loop attached. Searched project first + /// for the same reason `load` sorts that way. + public func template(withID id: UUID, projectPath: String?) -> PromptTemplate? { + load(projectPath: projectPath).first(where: { $0.id == id }) + } + + /// Reads one directory. Unreadable files are skipped, not fatal: a half-written + /// file from an editor, or one an older build wrote differently, must not take + /// the whole library down. + private func read(directory: URL, origin: TemplateOrigin) -> [PromptTemplate] { + let fileManager = FileManager.default + guard + let entries = try? fileManager.contentsOfDirectory( + at: directory, includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + let files = entries.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == false + && $0.pathExtension == "md" + } + return + files + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .compactMap { url in + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil } + var template = TemplateFileCodec.decode(text, origin: origin) + template?.fileName = url.lastPathComponent + return template + } + } + + // MARK: - Writing + + /// Saves a template. Returns where it actually landed: **home unless the caller + /// explicitly asked for the project, and the project only when that folder is + /// actually writable** — a read-only checkout or a repo that would reject the + /// dotfolder falls back to home rather than losing the save. + @discardableResult + public func save( + _ template: PromptTemplate, to requested: TemplateOrigin, projectPath: String? + ) throws -> (template: PromptTemplate, origin: TemplateOrigin) { + var directory = homeDirectory + var origin = TemplateOrigin.home + if requested.isProject, let projectPath, !projectPath.isEmpty, + canWrite(to: projectDirectory(projectPath)) + { + directory = projectDirectory(projectPath) + origin = .project(projectPath) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var saved = template + saved.origin = origin + // A name that slugs onto a file somebody else's template already owns gets the + // next free suffix rather than the other one's contents. Saving is not editing: + // nothing in the app asks to overwrite a template, so a collision here is always + // two different briefs that happen to be called the same thing. + saved.fileName = availableFileName(saved.fileName, in: directory, keeping: saved.id) + let text = TemplateFileCodec.encode(saved) + try text.write( + to: directory.appendingPathComponent(saved.fileName), atomically: true, encoding: .utf8) + return (saved, origin) + } + + /// `name.md`, or `name-2.md`, `name-3.md` … — the first spelling no *other* + /// template holds. A file already carrying this template's own id is its own + /// earlier version and is written over. + private func availableFileName( + _ preferred: String, in directory: URL, keeping id: UUID + ) -> String { + let base = preferred.hasSuffix(".md") ? String(preferred.dropLast(3)) : preferred + var candidate = preferred + var suffix = 1 + while let existing = try? String( + contentsOf: directory.appendingPathComponent(candidate), encoding: .utf8), + TemplateFileCodec.decode(existing, origin: .home)?.id != id + { + suffix += 1 + candidate = "\(base)-\(suffix).md" + } + return candidate + } + + /// Removes a template file. Used by Settings' manage list; deleting a project + /// template deletes the file in the checkout — an explicit act on an explicit + /// location, same as saving there. + public func delete(_ template: PromptTemplate) throws { + let directory: URL + switch template.origin { + case .home: directory = homeDirectory + case .project(let path): directory = projectDirectory(path) + } + try FileManager.default.removeItem(at: directory.appendingPathComponent(template.fileName)) + } + + /// "Put it in the project instead" / "Keep it at home instead" — the quiet line + /// after a save offers the other location, and this is the move it performs. + /// Returns the template as it now is — the origin it actually landed on, and the + /// filename it took there, which is not always the one it arrived with. + @discardableResult + public func move( + _ template: PromptTemplate, to destination: TemplateOrigin, projectPath: String? + ) throws -> PromptTemplate { + guard destination != template.origin else { return template } + let directory: URL + let origin: TemplateOrigin + if destination.isProject, let projectPath, !projectPath.isEmpty, + canWrite(to: projectDirectory(projectPath)) + { + directory = projectDirectory(projectPath) + origin = .project(projectPath) + } else { + directory = homeDirectory + origin = .home + } + let source = directoryURL(for: template.origin).appendingPathComponent(template.fileName) + // The other location may already hold a different template of the same name — a + // teammate's committed `review-diff.md` is exactly the case the design expects. + // Moving must not write over it. + var moved = template + moved.fileName = availableFileName(template.fileName, in: directory, keeping: template.id) + let target = directory.appendingPathComponent(moved.fileName) + // The destination can be the source: asking to move a home template into a + // project that won't take one falls back to home, which is where it already is. + // Writing and then deleting "the original" would delete the file itself, so the + // move that isn't a move stops here. + guard target != source else { return template } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + moved.origin = origin + try TemplateFileCodec.encode(moved).write(to: target, atomically: true, encoding: .utf8) + // Only remove the original once the copy landed; a failed write must leave + // the template exactly where it was. + try? FileManager.default.removeItem(at: source) + return moved + } + + private func directoryURL(for origin: TemplateOrigin) -> URL { + switch origin { + case .home: return homeDirectory + case .project(let path): return projectDirectory(path) + } + } + + /// Whether a template could be written to this folder — **without creating it**. + /// The design's rule is that nothing lands in a checkout by default, so the + /// question "should the save fall back to home" must not itself put a + /// `.graphcode/templates` in somebody's repository. An absent folder is answered + /// by the nearest ancestor that does exist: if that is writable, the folder can be + /// created when a save actually asks for one. + public func canWrite(to url: URL) -> Bool { + let fileManager = FileManager.default + var candidate = url.standardizedFileURL + while !fileManager.fileExists(atPath: candidate.path) { + let parent = candidate.deletingLastPathComponent().standardizedFileURL + guard parent != candidate else { return false } + candidate = parent + } + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { return false } + return fileManager.isWritableFile(atPath: candidate.path) + } + + // MARK: - Watching + + /// Fires once whenever either location's contents change — an external edit, a + /// `git pull` bringing a teammate's template in, a save from this or another + /// window — so the library is current without a relaunch. + /// + /// A directory that does not exist yet cannot be watched, and that is the common + /// case rather than the corner one: most projects have no `.graphcode/templates` + /// until the day a teammate commits one. So the set of watched directories is + /// re-armed on a slow poll — appearing, disappearing and being replaced wholesale + /// (which is what `git checkout` and an atomic editor save both look like) all put + /// the watch back on the right descriptor and report the change. + public func watch(projectPath: String?) -> AsyncStream { + let directories = + [homeDirectory] + + (projectPath.map { [projectDirectory($0)] } ?? []) + return AsyncStream { continuation in + let arming = DirectoryWatch(directories: directories) { continuation.yield() } + continuation.onTermination = { _ in arming.stop() } + } + } +} + +/// Keeps one `DispatchSource` per directory that currently exists, and re-arms as +/// directories come and go. Separate from `TemplateStorage` because it owns mutable +/// state with a lifetime — the storage type itself is a value anyone may copy. +private final class DirectoryWatch: @unchecked Sendable { + /// Slow on purpose: this only has to notice a directory *appearing*, which the + /// dispatch sources cannot do for themselves. Everything that happens inside an + /// already-watched directory is reported immediately by its source. + private static let rearmInterval: DispatchTimeInterval = .seconds(2) + + private let queue = DispatchQueue(label: "graphcode.template-watch") + private let directories: [URL] + private let onChange: () -> Void + private var sources: [URL: DispatchSourceFileSystemObject] = [:] + private var timer: DispatchSourceTimer? + private var stopped = false + + init(directories: [URL], onChange: @escaping () -> Void) { + self.directories = directories + self.onChange = onChange + queue.async { [weak self] in self?.arm(reporting: false) } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + Self.rearmInterval, repeating: Self.rearmInterval) + timer.setEventHandler { [weak self] in self?.arm(reporting: true) } + timer.resume() + self.timer = timer + } + + func stop() { + queue.async { [self] in + stopped = true + timer?.cancel() + timer = nil + for source in sources.values { source.cancel() } + sources.removeAll() + } + } + + /// Brings `sources` back in line with which directories exist. `reporting` is false + /// only for the very first pass, where the caller has just read the library itself + /// and a yield would be a redundant re-read. + private func arm(reporting: Bool) { + guard !stopped else { return } + var changed = false + for url in directories { + let exists = FileManager.default.fileExists(atPath: url.path) + let watched = sources[url] != nil + if exists, !watched, let source = makeSource(for: url) { + sources[url] = source + changed = true + } else if !exists, watched { + sources[url]?.cancel() + sources[url] = nil + changed = true + } + } + if changed, reporting { onChange() } + } + + private func makeSource(for url: URL) -> DispatchSourceFileSystemObject? { + let descriptor = open(url.path, O_EVTONLY) + guard descriptor >= 0 else { return nil } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: [.write, .rename, .delete], queue: queue) + source.setEventHandler { [weak self] in + guard let self else { return } + // A directory that was renamed or deleted out from under the descriptor keeps + // reporting nothing useful; dropping it here lets the next re-arm re-open the + // path, which is how a `git checkout` that swaps the folder is picked up. + if source.data.contains(.delete) || source.data.contains(.rename) { + source.cancel() + sources[url] = nil + } + onChange() + } + source.setCancelHandler { close(descriptor) } + source.resume() + return source + } +} diff --git a/graphcode/Sources/Clients/TemplateLibraryClient.swift b/graphcode/Sources/Clients/TemplateLibraryClient.swift new file mode 100644 index 00000000..a29b2a8a --- /dev/null +++ b/graphcode/Sources/Clients/TemplateLibraryClient.swift @@ -0,0 +1,125 @@ +import Dependencies +import Foundation +import GraphcodeKit + +/// The template library, as the app sees it — the bridge between +/// `TemplateStorage` (pure Foundation, shared with the daemon) and the form. +/// +/// The watch stream is what makes the library live: an external edit or a `git +/// pull` shows up without a relaunch, which is the design's whole argument for +/// reading templates from files rather than holding them in the app. +struct TemplateLibraryClient: Sendable { + var load: @Sendable (_ projectPath: String?) async -> [PromptTemplate] + /// Saves and answers what actually landed — a read-only checkout falls back to + /// home, and the caller says so rather than pretending the save went where it + /// was asked. + var save: + @Sendable (_ template: PromptTemplate, _ origin: TemplateOrigin, _ projectPath: String?) + async throws -> (template: PromptTemplate, origin: TemplateOrigin) + /// Answers with the template as it landed — origin *and* filename, because a + /// destination that already holds a template of that name gives it another. + var move: + @Sendable (_ template: PromptTemplate, _ destination: TemplateOrigin, _ projectPath: String?) + async throws -> PromptTemplate + var delete: @Sendable (_ template: PromptTemplate) async throws -> Void + /// Fires once per change to either location while the stream lives. + var watch: @Sendable (_ projectPath: String?) -> AsyncStream + /// Resolves a template by id — the same read the daemon performs when a + /// following loop next runs. + var template: @Sendable (_ id: UUID, _ projectPath: String?) async -> PromptTemplate? + /// Whether a project folder can take a `.graphcode/templates` — the save sheet + /// greys the project option out rather than offering a save that falls back + /// silently. Answering must not *create* the folder: nothing lands in a checkout + /// until a save asks for it. + var projectIsWritable: @Sendable (_ projectPath: String) -> Bool + /// One more use of this template, as the picker counts them. App-local: applying a + /// template must never write to the file, which may live in a repository. + var recordUse: @Sendable (_ template: PromptTemplate) -> Void +} + +extension TemplateLibraryClient: DependencyKey { + static let liveValue = TemplateLibraryClient( + load: { projectPath in + let storage = TemplateStorage.shared + let templates = await Task.detached(priority: .userInitiated) { + storage.load(projectPath: projectPath) + }.value + return overlayUseCounts(templates) + }, + save: { template, origin, projectPath in + try await Task.detached(priority: .userInitiated) { + try TemplateStorage.shared.save(template, to: origin, projectPath: projectPath) + }.value + }, + move: { template, destination, projectPath in + try await Task.detached(priority: .userInitiated) { + try TemplateStorage.shared.move(template, to: destination, projectPath: projectPath) + }.value + }, + delete: { template in + try await Task.detached(priority: .userInitiated) { + try TemplateStorage.shared.delete(template) + }.value + }, + watch: { projectPath in + TemplateStorage.shared.watch(projectPath: projectPath) + }, + template: { id, projectPath in + await Task.detached(priority: .userInitiated) { + TemplateStorage.shared.template(withID: id, projectPath: projectPath) + }.value + }, + projectIsWritable: { projectPath in + let storage = TemplateStorage.shared + return storage.canWrite(to: storage.projectDirectory(projectPath)) + }, + recordUse: { template in + bumpUseCount(for: template) + } + ) + + static let testValue = TemplateLibraryClient( + load: { _ in [] }, + save: { _, _, _ in (PromptTemplate(name: "", body: ""), .home) }, + move: { template, _, _ in template }, + delete: { _ in }, + watch: { _ in AsyncStream { $0.finish() } }, + template: { _, _ in nil }, + projectIsWritable: { _ in false }, + recordUse: { _ in } + ) + + /// The use count is app-local (UserDefaults, keyed on filename + origin) and is + /// never written back into a file — applying a template must not dirty a + /// repository's working tree. See `PromptTemplate.useCount`. + static func overlayUseCounts(_ templates: [PromptTemplate]) -> [PromptTemplate] { + let defaults = UserDefaults.standard + return templates.map { template in + var overlaid = template + overlaid.useCount = defaults.integer(forKey: Self.useCountKey(template)) + return overlaid + } + } + + private static func bumpUseCount(for template: PromptTemplate) { + let defaults = UserDefaults.standard + let key = Self.useCountKey(template) + defaults.set(defaults.integer(forKey: key) + 1, forKey: key) + } + + private static func useCountKey(_ template: PromptTemplate) -> String { + let origin: String + switch template.origin { + case .home: origin = "home" + case .project(let path): origin = path + } + return "templateUseCount.\(origin).\(template.fileName)" + } +} + +extension DependencyValues { + var templateLibrary: TemplateLibraryClient { + get { self[TemplateLibraryClient.self] } + set { self[TemplateLibraryClient.self] = newValue } + } +} diff --git a/graphcode/Sources/Features/Canvas/LoopCardView.swift b/graphcode/Sources/Features/Canvas/LoopCardView.swift index b187fbe5..5d3ba296 100644 --- a/graphcode/Sources/Features/Canvas/LoopCardView.swift +++ b/graphcode/Sources/Features/Canvas/LoopCardView.swift @@ -230,6 +230,9 @@ struct LoopCardView: View { .foregroundStyle(.white.opacity(0.52)) .lineLimit(1) .truncationMode(.middle) + if let follow = node.templateFollow { + FollowsChip(follow: follow) + } if isRemote { Image(systemName: "network").font(.system(size: 9)).foregroundStyle(.white.opacity(0.4)) } @@ -242,6 +245,35 @@ struct LoopCardView: View { } } +/// A following loop's mark: a 5pt blue dot and the name of what it follows, with +/// "· missing" when the file could not be found at the last resolve. The blue is +/// the action blue the Templates button uses — the follow is chrome on the loop, +/// not a sixth kind of it. +struct FollowsChip: View { + let follow: TemplateFollow + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(missing ? Color(red: 1.0, green: 0.624, blue: 0.039) : Theme.paneFocusTint) + .frame(width: 5, height: 5) + Text(follow.missing ? "Follows \(follow.name) · missing" : "Follows \(follow.name)") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(missing ? Color(red: 1.0, green: 0.804, blue: 0.478) : .white.opacity(0.6)) + .lineLimit(1) + } + .padding(.vertical, 1) + .padding(.horizontal, 6) + .background( + missing + ? Color(red: 1.0, green: 0.624, blue: 0.039).opacity(0.12) + : Theme.paneFocusTint.opacity(0.12), + in: RoundedRectangle(cornerRadius: 4)) + } + + private var missing: Bool { follow.missing } +} + /// The state, in a word. The pill is the fix for the overloaded dot: colour, shape, and /// text all say the same thing, so losing any one of them still leaves two. struct LoopStatePill: View { diff --git a/graphcode/Sources/Features/Overview/GraphOverviewCards.swift b/graphcode/Sources/Features/Overview/GraphOverviewCards.swift index 9f4b557a..eddb20d5 100644 --- a/graphcode/Sources/Features/Overview/GraphOverviewCards.swift +++ b/graphcode/Sources/Features/Overview/GraphOverviewCards.swift @@ -218,6 +218,12 @@ extension GraphOverviewView { .disabled(!node.pilotState.canArm) } Button("Rename…") { send(.renameNodeRequested(node.id), to: loop.projectPath) } + Button("Save as Template…") { send(.saveLoopTemplateTapped(node.id), to: loop.projectPath) } + if node.templateFollow != nil { + Button("Detach from Template") { + send(.detachTemplateTapped(node.id), to: loop.projectPath) + } + } if !node.isResolved { Button("Stop Loop") { store.send(.stopNodeTapped(projectPath: loop.projectPath, nodeID: node.id)) diff --git a/graphcode/Sources/Features/Overview/GraphOverviewView.swift b/graphcode/Sources/Features/Overview/GraphOverviewView.swift index 18f697c5..27f63236 100644 --- a/graphcode/Sources/Features/Overview/GraphOverviewView.swift +++ b/graphcode/Sources/Features/Overview/GraphOverviewView.swift @@ -134,6 +134,9 @@ struct GraphOverviewView: View { .sheet(isPresented: $store.showingNewNodeForm) { NodeDraftForm(store: store) } + // Same host as the canvas has: the overview's card menus offer Save as + // Template too, and a sheet needs somewhere mounted to present from. + .modifier(TemplateSaveSheetHost(store: store)) } } diff --git a/graphcode/Sources/Features/Project/NodeDraftFields.swift b/graphcode/Sources/Features/Project/NodeDraftFields.swift index 7e07534e..384f8ad1 100644 --- a/graphcode/Sources/Features/Project/NodeDraftFields.swift +++ b/graphcode/Sources/Features/Project/NodeDraftFields.swift @@ -12,11 +12,20 @@ struct DraftField: View { /// The "optional" / "recommended" note beside the label. var qualifier: String? var help: String? + /// The template set this field — a 5pt blue dot beside the label, never a lock: + /// everything a template lands stays editable. See PROMPT_TEMPLATES.md § Applied + /// state. + var fromTemplate = false @ViewBuilder let content: () -> Content var body: some View { VStack(alignment: .leading, spacing: 5) { HStack(alignment: .firstTextBaseline, spacing: 6) { + if fromTemplate { + Circle() + .fill(Theme.paneFocusTint) + .frame(width: 5, height: 5) + } Text(label) .font(.system(size: 11.5, weight: .semibold)) .foregroundStyle(.white.opacity(0.85)) @@ -25,6 +34,11 @@ struct DraftField: View { .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.56)) } + if fromTemplate { + Text("from template") + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.75)) + } } content() if let help { @@ -76,6 +90,9 @@ struct DraftTextField: View { let placeholder: String @Binding var text: String var isMono = false + /// The picker's ⏎ asks the brief's field to take focus; the field consumes the + /// request by clearing it, so exactly one field answers. + var takesFocusRequest: Binding? = nil @FocusState private var isFocused: Bool @@ -85,6 +102,12 @@ struct DraftTextField: View { .font(.system(size: isMono ? 12 : 13, design: isMono ? .monospaced : .default)) .focused($isFocused) .draftFieldBox(isFocused: isFocused) + .onAppear { + if let takesFocusRequest, takesFocusRequest.wrappedValue { + isFocused = true + takesFocusRequest.wrappedValue = false + } + } } } @@ -93,6 +116,8 @@ struct DraftTextField: View { struct DraftProseField: View { let placeholder: String @Binding var text: String + /// See `DraftTextField.takesFocusRequest` — the brief is where ⏎ lands the human. + var takesFocusRequest: Binding? = nil @FocusState private var isFocused: Bool @@ -103,6 +128,12 @@ struct DraftProseField: View { .font(.system(size: 13)) .focused($isFocused) .draftFieldBox(isFocused: isFocused, minHeight: 54) + .onAppear { + if let takesFocusRequest, takesFocusRequest.wrappedValue { + isFocused = true + takesFocusRequest.wrappedValue = false + } + } } } diff --git a/graphcode/Sources/Features/Project/NodeDraftForm.swift b/graphcode/Sources/Features/Project/NodeDraftForm.swift index ed2ec2c4..b13e9ec6 100644 --- a/graphcode/Sources/Features/Project/NodeDraftForm.swift +++ b/graphcode/Sources/Features/Project/NodeDraftForm.swift @@ -35,19 +35,26 @@ struct NodeDraftForm: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - header - LoopTypeChooser(selection: $store.draftLoopType) - ScrollView { - VStack(alignment: .leading, spacing: 16) { - typeFields - Divider().overlay(Color.white.opacity(0.08)) - runsAs - NodeDraftRecap(draft: store.draft, worktree: store.draftWorktree) + if store.templates.isPickerOpen { + // The picker replaces the body while it is open — the same sheet, not a + // second window (PROMPT_TEMPLATES.md § Picker). + TemplatePickerView(store: store) + } else { + header + appliedState + LoopTypeChooser(selection: $store.draftLoopType) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + typeFields + Divider().overlay(Color.white.opacity(0.08)) + runsAs + NodeDraftRecap(draft: store.draft, worktree: store.draftWorktree) + } + .padding(.bottom, 4) } - .padding(.bottom, 4) + .scrollIndicators(.automatic) + footer } - .scrollIndicators(.automatic) - footer } .padding(.horizontal, 22) .padding(.top, 20) @@ -58,17 +65,158 @@ struct NodeDraftForm: View { .frame(minWidth: 520, idealWidth: 520, maxWidth: .infinity) .frame(minHeight: 560, idealHeight: 760, maxHeight: .infinity) .background(Theme.sheet) + // Save-as-template's sheet: the dialog is already a sheet, and one prompt plus a + // name and a destination is all the ceremony a save needs. + .sheet(item: $store.templates.pendingSave) { _ in + TemplateSaveSheet(store: store) + } } private var header: some View { - VStack(alignment: .leading, spacing: 2) { - Text("New loop").font(.system(size: 16, weight: .semibold)) - Text("in \(store.graph.project.name)") - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.45)) + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("New loop").font(.system(size: 16, weight: .semibold)) + Text("in \(store.graph.project.name)") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.45)) + } + Spacer(minLength: 8) + templatesButton + } + } + + /// Action blue, never a loop-type hue — templates are chrome, not taxonomy, and + /// there is no sixth colour in this feature (PROMPT_TEMPLATES.md § What changes + /// in the New loop dialog). + private var templatesButton: some View { + Button { + store.send(.templatesButtonTapped) + } label: { + HStack(spacing: 6) { + Text("Templates") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.706, green: 0.843, blue: 1.0).opacity(0.95)) + Text("⌘T") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.white.opacity(0.6)) + } + .padding(.horizontal, 10) + .frame(height: 26) + .background( + Theme.paneFocusTint.opacity(0.14), in: RoundedRectangle(cornerRadius: 6) + ) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Theme.paneFocusTint.opacity(0.4), lineWidth: 1) + } + } + .buttonStyle(.plain) + .keyboardShortcut("t", modifiers: .command) + } + + /// The applied template, stated — the chip, the plain-words shape sentence, the + /// tokens still to fill. The dialog must never gain a shape silently. + @ViewBuilder + private var appliedState: some View { + if let applied = store.templates.applied { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + HStack(spacing: 6) { + Text(applied.name) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.706, green: 0.843, blue: 1.0).opacity(0.95)) + .lineLimit(1) + Button { + store.send(.templateChipRemoved) + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + } + .buttonStyle(.plain) + .help("Remove the template — everything it contributed goes") + } + .padding(.horizontal, 9) + .frame(height: 24) + .background( + Theme.paneFocusTint.opacity(0.16), in: RoundedRectangle(cornerRadius: 6) + ) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Theme.paneFocusTint.opacity(0.45), lineWidth: 1) + } + Spacer(minLength: 8) + } + if applied.carriesShape { + HStack(alignment: .top, spacing: 4) { + Text(shapeSentence(applied)) + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.75)) + .fixedSize(horizontal: false, vertical: true) + Button("Undo the shape") { store.send(.templateShapeUndone) } + .buttonStyle(.plain) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9)) + } + } + if !store.unfilledTokens.isEmpty { + HStack(spacing: 5) { + Text("Fill in:") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.55)) + ForEach(store.unfilledTokens, id: \.self) { token in + Text("{\(token)}") + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(Color(red: 0.812, green: 0.902, blue: 1.0)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.18), + in: RoundedRectangle(cornerRadius: 5) + ) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.4), lineWidth: 1) + } + } + } + } + } } } + /// The shape in words, exactly the strip's promise: what type it made the loop + /// and what it set. "Undo the shape to keep just the prompt" rides on it. + private func shapeSentence(_ applied: ProjectFeature.AppliedTemplate) -> AttributedString { + var text = AttributedString("This template") + if applied.setFields.contains(.shape) || applied.shape != nil { + text += AttributedString(" makes it a \((applied.shape ?? .sketch).displayName) loop") + } + let settings: [String] = [ + applied.setFields.contains(.doneCheck) ? "sets a done check" : nil, + applied.setFields.contains(.cadence) ? "sets a cadence" : nil, + applied.setFields.contains(.pausesBeforeWritesOnly) ? "pauses only before writes" : nil, + applied.setFields.contains(.branch) ? "sets a worktree" : nil, + applied.setFields.contains(.metric) ? "tracks a metric" : nil, + applied.setFields.contains(.backend) ? "names the agent" : nil, + applied.setFields.contains(.subGraph) ? "carries its loops" : nil, + ].compactMap { $0 } + if !settings.isEmpty { + if text.characters.count > "This template".count { text += AttributedString(" and") } + text += AttributedString(" " + Self.list(settings)) + } + text += AttributedString(".") + return text + } + + /// "a", "a and b", "a, b and c" — the spec's sentence reads as a sentence, and + /// three settings joined with two "and"s does not. + static func list(_ parts: [String]) -> String { + guard let last = parts.last else { return "" } + guard parts.count > 1 else { return last } + return parts.dropLast().joined(separator: ", ") + " and " + last + } + @ViewBuilder private var typeFields: some View { switch store.draftLoopType { @@ -88,7 +236,9 @@ struct NodeDraftForm: View { VStack(alignment: .leading, spacing: 10) { DraftSectionCaption(text: "RUNS AS") HStack(alignment: .top, spacing: 10) { - DraftField(label: "Agent") { + DraftField( + label: "Agent", fromTemplate: store.templateSetFields.contains(.backend) + ) { Picker("", selection: $store.draftBackend) { ForEach(CLISessionBackendKind.allCases, id: \.self) { backend in Text(backend.displayName).tag(backend) @@ -97,7 +247,9 @@ struct NodeDraftForm: View { .labelsHidden() } if !isRemoteProject && !store.graph.isGlobal { - DraftField(label: "Branch") { + DraftField( + label: "Branch", fromTemplate: store.templateSetFields.contains(.branch) + ) { Picker("", selection: $store.draftWorktree) { Text("This folder").tag(ProjectFeature.WorktreeSelection.none) ForEach(store.availableWorktrees) { worktree in @@ -144,10 +296,40 @@ struct NodeDraftForm: View { .buttonStyle(.plain) .font(.system(size: 12.5)) .foregroundStyle(.white.opacity(0.7)) + if hasBriefToSave { + Button("Save as template…") { store.send(.saveTemplateTapped) } + .buttonStyle(.plain) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9)) + .help("Save this brief as a template you can start from next time") + } Spacer(minLength: 8) // `draft.isValid` already knows why the button is off. Saying it beats a disabled // control with no explanation, which is the version people file bugs about. - if let reason = disabledReason { + if let notice = store.templates.saveNotice { + HStack(spacing: 4) { + Text(noticePath(notice)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + .truncationMode(.middle) + Button(notice.otherOffer) { store.send(.templateRelocationTapped) } + .buttonStyle(.plain) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9)) + // The line is quiet, not permanent: it shares this slot with the reason + // Create is disabled, and staying forever would mean the dialog never + // explains itself again. + Button { + store.send(.templateSaveNoticeDismissed) + } label: { + Image(systemName: "xmark") + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(.white.opacity(0.45)) + } + .buttonStyle(.plain) + } + } else if let reason = disabledReason { Text(reason) .font(.system(size: 11.5)) .foregroundStyle(.white.opacity(0.62)) @@ -157,6 +339,19 @@ struct NodeDraftForm: View { } } + /// A prompt written is a template waiting to happen — the design's second save + /// entry point (the first being a loop's context menu, the third a composite's). + private var hasBriefToSave: Bool { + !store.currentBriefText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || store.draftLoopType == .composite + } + + /// "~/.graphcode/templates/review-diff.md" or the project's own — the quiet + /// line states the path so the human knows where the file went. + private func noticePath(_ notice: ProjectFeature.TemplateSaveNotice) -> String { + "Saved to " + TemplateSavePath.display(of: notice.template) + } + private var createButton: some View { Button { store.send(.createNodeConfirmed) @@ -166,33 +361,44 @@ struct NodeDraftForm: View { .font(.system(size: 13, weight: .semibold)) Text("⏎").font(.system(size: 11, design: .monospaced)).opacity(0.7) } - .foregroundStyle(store.draft.isValid ? .white : .white.opacity(0.42)) + .foregroundStyle(isCreateEnabled ? .white : .white.opacity(0.42)) .padding(.horizontal, 14) .frame(height: 32) .background( - Theme.paneFocusTint.opacity(store.draft.isValid ? 1 : 0.28), + Theme.paneFocusTint.opacity(isCreateEnabled ? 1 : 0.28), in: RoundedRectangle(cornerRadius: 7)) } .buttonStyle(.plain) .keyboardShortcut(.defaultAction) // docs/08 wants an under-specified node to be structurally awkward, not just // discouraged — so the button is off until the draft actually means something. - .disabled(!store.draft.isValid) + // A template's unfilled `{token}` is the same kind of hole: Start stays off + // until the brief is whole (PROMPT_TEMPLATES.md § What a template carries). + .disabled(!isCreateEnabled) } - /// A sketch *starts* rather than being created — asking for nothing and opening a - /// session is the whole type, and "Create loop" would overstate the ceremony. + private var isCreateEnabled: Bool { + store.draft.isValid && !store.draftBlocksOnTokens + } + + /// The primary button says what Create will actually do — and when a template + /// shaped the draft, it says the shape the draft took on. private var createLabel: String { switch store.draftLoopType { case .sketch: "Start" case .composite: "Create & open" - case .goalBased, .timeBased, .turnBased: "Create loop" + case .goalBased: store.templates.applied?.shape != nil ? "Create goal loop" : "Create loop" + case .timeBased: store.templates.applied?.shape != nil ? "Create timed loop" : "Create loop" + case .turnBased: store.templates.applied?.shape != nil ? "Create turn loop" : "Create loop" } } /// Which field is missing, in the words of the thing that is missing. private var disabledReason: String? { - guard !store.draft.isValid else { return nil } + guard !store.draft.isValid || store.draftBlocksOnTokens else { return nil } + if let first = store.unfilledTokens.first { + return "Fill in {\(first)} to continue" + } guard store.draftBackend.canHost(store.draftLoopType) else { return "This agent can't host this kind of loop" } diff --git a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift index 0dbe27d0..adad2af6 100644 --- a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift +++ b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift @@ -12,11 +12,12 @@ struct SketchDraftFields: View { var body: some View { DraftField( label: "Starting note", qualifier: "optional — leave it blank and it opens quiet", - help: "No done check, no cadence — it works with you until you promote it or close it." + help: "No done check, no cadence — it works with you until you promote it or close it.", + fromTemplate: store.templateSetFields.contains(.brief) ) { DraftProseField( placeholder: "e.g. where does the usage cap get read from?", - text: $store.draftSketchNote) + text: $store.draftSketchNote, takesFocusRequest: $store.templates.requestsPrimaryFocus) } } } @@ -34,15 +35,18 @@ struct GoalDraftFields: View { VStack(alignment: .leading, spacing: 14) { DraftField( label: "What does done look like?", - help: "In your own words. The loop is told this, and works toward it." + help: "In your own words. The loop is told this, and works toward it.", + fromTemplate: store.templateSetFields.contains(.brief) ) { DraftProseField( - placeholder: "the crash rate is back under 1%", text: $store.draftGoal) + placeholder: "the crash rate is back under 1%", text: $store.draftGoal, + takesFocusRequest: $store.templates.requestsPrimaryFocus) } DraftField( label: "Done check", qualifier: "optional", - help: "Runs periodically while the loop works. Exit 0 means done." + help: "Runs periodically while the loop works. Exit 0 means done.", + fromTemplate: store.templateSetFields.contains(.doneCheck) ) { HStack(spacing: 8) { DraftTextField( @@ -96,7 +100,8 @@ struct GoalDraftFields: View { VStack(alignment: .leading, spacing: 10) { DraftField( label: "Measured by", - help: "A command printing one number. Sampled once per pass, not per poll." + help: "A command printing one number. Sampled once per pass, not per poll.", + fromTemplate: store.templateSetFields.contains(.metric) ) { DraftTextField( placeholder: "./scripts/score.sh", text: $store.draftMetric, isMono: true) @@ -172,7 +177,8 @@ struct TimedDraftFields: View { help: store.draftRequiresDaemonHeartbeat ? "GraphCode's daemon holds the timer for this backend." : "GraphCode writes the /loop directive for you — you don't have to know " - + "the syntax." + + "the syntax.", + fromTemplate: store.templateSetFields.contains(.cadence) ) { VStack(alignment: .leading, spacing: 8) { Picker("", selection: $store.draftInterval) { @@ -189,10 +195,13 @@ struct TimedDraftFields: View { } } - DraftField(label: "What to do each time") { + DraftField( + label: "What to do each time", + fromTemplate: store.templateSetFields.contains(.brief) + ) { DraftProseField( placeholder: "Check for new crash reports and triage anything new", - text: $store.draftTimedTask) + text: $store.draftTimedTask, takesFocusRequest: $store.templates.requestsPrimaryFocus) } if store.draftRequiresDaemonHeartbeat { @@ -261,13 +270,19 @@ struct TurnDraftFields: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - DraftField(label: "First instruction") { + DraftField( + label: "First instruction", + fromTemplate: store.templateSetFields.contains(.brief) + ) { DraftProseField( placeholder: "Port the settings screen to the new design system", - text: $store.draftFirstInstruction) + text: $store.draftFirstInstruction, + takesFocusRequest: $store.templates.requestsPrimaryFocus) } - DraftField(label: "Pause") { + DraftField( + label: "Pause", fromTemplate: store.templateSetFields.contains(.pausesBeforeWritesOnly) + ) { VStack(alignment: .leading, spacing: 6) { radio( "After every turn", isOn: !store.draftPausesBeforeWritesOnly, @@ -334,8 +349,36 @@ struct CompositeDraftFields: View { .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.6)) - DraftField(label: "Name") { - DraftTextField(placeholder: "Nightly sweep", text: $store.draftTitle) + DraftField(label: "Name", fromTemplate: store.templateSetFields.contains(.title)) { + DraftTextField( + placeholder: "Nightly sweep", text: $store.draftTitle, + takesFocusRequest: $store.templates.requestsPrimaryFocus) + } + + if let carried = store.draftSubGraph { + // A template brought its children: the fifth block, not a detour into the + // graph editor — the loops land inside on Create (PROMPT_TEMPLATES.md). + DraftField(label: "Carried loops", qualifier: "from the template") { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(carried.nodes.prefix(4).enumerated()), id: \.element.id) { + index, node in + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 1.5) + .fill(node.loopType.accent) + .frame(width: 6, height: 6) + Text(node.title) + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.75)) + .lineLimit(1) + } + } + if carried.nodes.count > 4 { + Text("and \(carried.nodes.count - 4) more") + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.45)) + } + } + } } DraftField( diff --git a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift index e5a1ea5d..6ba28963 100644 --- a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift +++ b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift @@ -108,6 +108,15 @@ extension ProjectCanvasView { // Available on a resolved loop too: a finished loop is still something you read the // graph by, and its name is what you read. Button("Rename…") { store.send(.renameNodeRequested(node.id)) } + // The design's first save entry point: a loop that worked is the most common thing + // to reuse. Saving a shaped loop captures its type and settings alongside the text; + // saving a Main loop captures text only. See PROMPT_TEMPLATES.md § Save as template. + Button("Save as Template…") { store.send(.saveLoopTemplateTapped(node.id)) } + if node.templateFollow != nil { + // Detach converts it to a snapshot in place: the brief it already has keeps + // running, and the next edit to the template's file stops reaching it. + Button("Detach from Template") { store.send(.detachTemplateTapped(node.id)) } + } if !node.isResolved { Button("Stop Loop") { store.send(.stopNodeTapped(node.id)) } } diff --git a/graphcode/Sources/Features/Project/ProjectCanvasView.swift b/graphcode/Sources/Features/Project/ProjectCanvasView.swift index 61f023a0..6949afba 100644 --- a/graphcode/Sources/Features/Project/ProjectCanvasView.swift +++ b/graphcode/Sources/Features/Project/ProjectCanvasView.swift @@ -77,6 +77,7 @@ struct ProjectCanvasView: View { in: store.canvasGraph, declaredEntries: store.declaredEntryIDs)) return VStack(spacing: 0) { + TemplateSaveNoticeBar(store: store) if let connectionError = store.connectionError { Text("Not connected to graphcoded: \(connectionError)") .font(.caption) @@ -122,6 +123,9 @@ struct ProjectCanvasView: View { .sheet(isPresented: $store.showingNewNodeForm) { NodeDraftForm(store: store) } + // A save started from a card's context menu has no dialog to live in; this is + // where it presents (PROMPT_TEMPLATES.md § Save as template). + .modifier(TemplateSaveSheetHost(store: store)) .sheet(item: $store.pendingEdge) { _ in edgeForm } diff --git a/graphcode/Sources/Features/Project/ProjectFeature+TemplateSaving.swift b/graphcode/Sources/Features/Project/ProjectFeature+TemplateSaving.swift new file mode 100644 index 00000000..a3fe4515 --- /dev/null +++ b/graphcode/Sources/Features/Project/ProjectFeature+TemplateSaving.swift @@ -0,0 +1,269 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit + +/// Save-as-template — the sheet's state, what it writes, and where it lands. Split +/// from `ProjectFeature+Templates.swift` because saving and applying are two +/// separate verbs that happen to share a state bag; see PROMPT_TEMPLATES.md +/// § Save as template and § Storage. +extension ProjectFeature { + /// What Save-as-template is about to write, as the sheet's one binding target. + /// The template is fully built by the reducer — the sheet edits only its name + /// and where it lands. + struct TemplateSaveContext: Equatable, Identifiable { + let id: UUID + var name: String + var scope: TemplateOrigin + var template: PromptTemplate + /// Whether this folder can take a `.graphcode/templates` — the sheet greys the + /// project option out rather than offering a save that silently falls home. + var projectCanSave: Bool + + init( + name: String, scope: TemplateOrigin, template: PromptTemplate, projectCanSave: Bool + ) { + self.id = UUID() + self.name = name + self.scope = scope + self.template = template + self.projectCanSave = projectCanSave + } + } + + /// The quiet line after a save — the template as it landed, plus the other + /// location it could be moved to. Never a modal; see PROMPT_TEMPLATES.md § Storage. + struct TemplateSaveNotice: Equatable { + var template: PromptTemplate + var landedInProject: Bool + var otherOffer: String { + landedInProject ? "Keep it at home instead" : "Put it in the project instead" + } + } + + /// Save-as-template: the sheet, where the file lands, and the quiet line after + /// (PROMPT_TEMPLATES.md § Save as template). + func templateSaveReducer( + _ state: inout State, _ action: Action + ) -> Effect { + switch action { + case .saveTemplateTapped: + guard let draft = templateDraftContext(&state) else { return .none } + state.templates.pendingSave = draft + return .none + + case .saveLoopTemplateTapped(let nodeID): + guard let context = templateContext(fromNode: nodeID, in: &state) else { return .none } + state.templates.pendingSave = context + return .none + + case .saveTemplateCancelled: + state.templates.pendingSave = nil + return .none + + case .saveTemplateConfirmed: + return confirmSaveTemplate(&state) + + case .templateSaved(let template): + state.templates.saveNotice = TemplateSaveNotice( + template: template, landedInProject: template.origin.isProject) + return .none + + case .templateSaveNoticeDismissed: + state.templates.saveNotice = nil + return .none + + case .templateRelocationTapped: + return relocateTemplate(&state) + + default: + return .none + } + } + + // MARK: - Saving + + /// The dialog's own save context — built from what the form holds *right now*. + /// Values the human typed into the applied template's `{token}` positions go + /// back as tokens, not baked in: the filled text is matched against the brief + /// the template carried, and only a clean match rewinds to `{token}`. + func templateDraftContext(_ state: inout State) -> TemplateSaveContext? { + let text = state.currentBriefText + guard + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || state.draftLoopType == .composite + else { return nil } + var body = text + if let applied = state.templates.applied, applied.brief != text, + PromptTemplate.tokenValues(of: text, against: applied.brief) != nil + { + // The human typed over the template's tokens and changed nothing else — a clean + // match is the proof of that — so the save offers those values back as tokens + // rather than baking them in. + body = applied.brief + } + let shape = state.draftLoopType == .sketch ? nil : state.draftLoopType + var settings = TemplateSettings() + settings.backend = Self.templateBackend(state.draftBackend) + if shape == .goalBased { + settings.doneCheck = + state.draftPredicate.trimmingCharacters(in: .whitespaces).isEmpty + ? nil : state.draftPredicate + settings.metric = + state.draftMetric.trimmingCharacters(in: .whitespaces).isEmpty + ? nil : state.draftMetric + } + if shape == .timeBased { + settings.cadence = state.draftInterval.directiveValue(custom: state.draftCustomInterval) + } + if shape == .turnBased { + settings.pausesBeforeWritesOnly = state.draftPausesBeforeWritesOnly + } + if shape == .composite, let subGraph = state.draftSubGraph, !subGraph.nodes.isEmpty { + settings.graphJSON = TemplateSettings.graphJSON(for: subGraph) + } + if case .newBranch = state.draftWorktree, + !state.draftBranch.trimmingCharacters(in: .whitespaces).isEmpty + { + settings.branch = state.draftBranch.trimmingCharacters(in: .whitespaces) + } + let hadSettings = shape != nil || !settings.isEmpty + let name = + state.draftTitle.trimmingCharacters(in: .whitespaces).isEmpty + ? (state.templates.applied?.name ?? Self.name(fromBrief: body)) + : state.draftTitle + let template = PromptTemplate( + name: name, + body: body, + shape: shape, + settings: hadSettings ? settings : nil, + origin: .home) + return TemplateSaveContext( + name: name, scope: .home, template: template, + projectCanSave: templateLibrary.projectIsWritable(state.graph.project.path)) + } + + /// A card's save context — a finished loop is the most common thing someone + /// wants to reuse. Saving a shaped loop captures its type and settings + /// alongside the text; saving a Main loop captures text only. + func templateContext(fromNode nodeID: UUID, in state: inout State) -> TemplateSaveContext? { + guard let node = state.graph.nodes[id: nodeID] else { return nil } + var settings = TemplateSettings() + settings.backend = Self.templateBackend(node.backend) + var body = "" + let shape: LoopType? + switch node.loopType { + case .sketch: + shape = nil + body = node.firstInstruction ?? "" + case .goalBased: + shape = .goalBased + body = node.goal?.summary ?? "" + settings.doneCheck = node.goal?.predicate + settings.metric = node.goal?.metricCommand + case .timeBased: + shape = .timeBased + let prompt = node.triggerPrompt ?? "" + let task = SessionPrompt.firstPass(of: prompt) ?? prompt + body = task + settings.cadence = SessionPrompt.recurrence(of: prompt)?.interval + case .turnBased: + shape = .turnBased + body = node.firstInstruction ?? "" + settings.pausesBeforeWritesOnly = node.pausesBeforeWritesOnly + case .composite: + shape = .composite + body = node.title + if let subGraph = node.subGraph, !subGraph.nodes.isEmpty { + settings.graphJSON = TemplateSettings.graphJSON(for: subGraph) + } + } + guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || shape == .composite + else { return nil } + let hadSettings = shape != nil || !settings.isEmpty + let template = PromptTemplate( + name: node.title, + body: body, + shape: shape, + settings: hadSettings ? settings : nil, + origin: .home) + return TemplateSaveContext( + name: node.title, scope: .home, template: template, + projectCanSave: templateLibrary.projectIsWritable(state.graph.project.path)) + } + + /// A template's name when nobody typed one: the brief's own first line — the + /// same line the picker shows, so a file saved this way reads the same in both. + static func name(fromBrief brief: String) -> String { + let first = + brief + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: true) + .first.map(String.init) ?? brief + let trimmed = first.trimmingCharacters(in: .whitespaces) + return trimmed.count > 64 ? String(trimmed.prefix(61)) + "…" : trimmed + } + + func confirmSaveTemplate(_ state: inout State) -> Effect { + guard var context = state.templates.pendingSave else { return .none } + let name = context.name.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return .none } + context.name = name + // `renamed(to:)` rather than assigning `name`: the filename is derived from the + // name and stored, so a sheet rename that only set `name` would write the file + // under the draft's old slug and print a path that doesn't match what was typed. + let template = { + // A fresh id per save: two saves of the same brief are two templates, and the + // id is what a following loop resolves by. + var built = context.template.renamed(to: name) + built.id = UUID() + return built + }() + state.templates.pendingSave = nil + let projectPath = state.graph.project.path + let scope = context.scope + let library = templateLibrary + return .run { send in + guard let (saved, _) = try? await library.save(template, scope, projectPath) + else { return } + await send(.templateLibraryChanged(await library.load(projectPath))) + await send(.templateSaved(saved)) + } + } + + func relocateTemplate(_ state: inout State) -> Effect { + guard let notice = state.templates.saveNotice else { return .none } + state.templates.saveNotice = nil + let projectPath = state.graph.project.path + let destination: TemplateOrigin = notice.landedInProject ? .home : .project(projectPath) + let library = templateLibrary + return .run { send in + // What `move` *returns* is where the file is, which is not always where it was + // asked to go: a project folder that won't take a `.graphcode/templates` sends + // the template home. Reporting the request instead would tell the human their + // template is somewhere it isn't. + guard let moved = try? await library.move(notice.template, destination, projectPath) + else { return } + await send(.templateLibraryChanged(await library.load(projectPath))) + await send(.templateSaved(moved)) + } + } + + /// One more use of this template, recorded app-locally. Never a write to the file: + /// applying a template must not dirty a repository's working tree, and a project + /// folder may not even be writable (PROMPT_TEMPLATES.md § Storage). + func countUse(of template: PromptTemplate, in projectPath: String) -> Effect { + let library = templateLibrary + return .run { send in + library.recordUse(template) + await send(.templateLibraryChanged(await library.load(projectPath))) + } + } + + /// The agent a template should carry: one it *names*, or nothing at all. A save + /// that always wrote the current backend would make `TemplateSettings.backend`'s + /// "absent means the app's own default stands" unreachable, and hand a teammate a + /// template pinned to an agent they may not have installed. + static func templateBackend(_ backend: CLISessionBackendKind) -> CLISessionBackendKind? { + backend == GraphcodeSettingsStore.load().defaultBackend ? nil : backend + } +} diff --git a/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift b/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift new file mode 100644 index 00000000..00c50d15 --- /dev/null +++ b/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift @@ -0,0 +1,407 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit + +/// The template feature's reducer half — everything ⌘T's picker, the applied +/// state and Save-as-template do to the draft. Lives beside `ProjectFeature` the +/// way the form's other helpers do: applying a template is a mutation on the +/// existing `NodeDraft`, never a second draft type (PROMPT_TEMPLATES.md § +/// Implementation notes). +extension ProjectFeature { + /// Which fields the template set — the "from template" dots on the form, and + /// the boundary `Undo the shape` and ✕ revert along. + enum TemplateFieldKey: String, Equatable, CaseIterable, Sendable { + case brief + case shape + case title + case backend + case doneCheck + case cadence + case pausesBeforeWritesOnly + case branch + case metric + case subGraph + } + + /// The template currently shaping the form, with everything un-applying needs: + /// what it set, and the fields as they were before it landed. Held rather than + /// re-derived so the ✕ can restore a draft the human has since edited — the + /// restore answers "what did this template change", not "what does the form + /// hold now". + struct AppliedTemplate: Equatable { + let id: UUID + let name: String + let shape: LoopType? + var setFields: Set + var snapshot: DraftSnapshot + /// The brief as the template carries it, `{token}` holes and all — what Save + /// compares the human's filled text against to offer the values back as + /// tokens (PROMPT_TEMPLATES.md § Save as template). + var brief: String + + /// Whether the template carried a shape or any setting — the applied strip + /// and its "Undo the shape" only exist when something beyond the text landed. + var carriesShape: Bool { + setFields.contains(.shape) + || !setFields.isDisjoint(with: [ + .backend, .doneCheck, .cadence, .pausesBeforeWritesOnly, .branch, .metric, .subGraph, + ]) + } + } + + /// The form's fields as they were before a template landed — the ✕ restore. + /// Deliberately every field a template *could* touch, so one snapshot serves + /// every apply, and nothing about the restore depends on which fields this + /// particular template happened to set. + struct DraftSnapshot: Equatable { + var loopType: LoopType + var title: String + var sketchNote: String + var goal: String + var predicate: String + var metric: String + var metricDirection: MetricDirection + var isMetricExpanded: Bool + var firstInstruction: String + var pausesBeforeWritesOnly: Bool + var timedTask: String + var interval: IntervalChoice + var customInterval: String + var stopAfter: String + var schedule: CompositeSchedule + var scheduleTime: String + var backend: CLISessionBackendKind + var worktree: WorktreeSelection + var branch: String + var subGraph: LoopGraph? + } + + // MARK: - The template switches + + /// The ⌘T picker: opening it, searching it, walking it, closing it. Split from the + /// two switches below so each stays a switch about one thing — the whole family in + /// one function was a single 20-branch reducer that no reader could hold. + /// Non-template actions pass through untouched. + func templatePickerReducer( + _ state: inout State, _ action: Action + ) -> Effect { + switch action { + case .templatesButtonTapped: + state.templates.isPickerOpen = true + state.templates.query = "" + // The row ⏎ would take is the first one, so the keys work before the mouse + // does — and ⌘⏎'s "start now" offer reads from the same selection. + state.templates.selectionIndex = 0 + // The library is re-read on open, not only when the form opened: an edit + // between the two moments, or a watcher that never got to fire, shows up + // the moment ⌘T is pressed. + let projectPath = state.graph.project.path + let library = templateLibrary + return .run { send in + await send(.templateLibraryChanged(await library.load(projectPath))) + } + + case .templatePickerClosed: + state.templates.isPickerOpen = false + return .none + + case .templateQueryChanged(let query): + state.templates.query = query + state.templates.selectionIndex = 0 + return .none + + case .templateSelectionMoved(let offset): + let count = state.templatePickerRows.count + guard count > 0 else { return .none } + let current = state.templates.selectionIndex ?? -1 + state.templates.selectionIndex = min(max(current + offset, 0), count - 1) + return .none + + case .templateLibraryChanged(let templates): + state.templates.library = templates + return .none + + default: + return .none + } + } + + /// What a chosen template does to the draft, and how it is taken back — the + /// applied state (PROMPT_TEMPLATES.md § Applied state). + func templateApplyReducer( + _ state: inout State, _ action: Action + ) -> Effect { + switch action { + case .templateChosen(let id): + guard let template = state.templates.library.first(where: { $0.id == id }) else { + return .none + } + state.templates.isPickerOpen = false + applyTemplate(&state, template) + state.templates.requestsPrimaryFocus = true + return countUse(of: template, in: state.graph.project.path) + + case .templateLaunched(let id): + guard let template = state.templates.library.first(where: { $0.id == id }) else { + return .none + } + state.templates.isPickerOpen = false + applyTemplate(&state, template) + let counted = countUse(of: template, in: state.graph.project.path) + // ⌘⏎ is "start now", and a brief with a hole in it is not one: the same + // unfilled-token gate the Create button obeys. The fill still happened, so the + // human lands on the brief with the holes to fill, exactly as ⏎ leaves them. + guard state.unfilledTokens.isEmpty, state.draft.isValid else { + state.templates.requestsPrimaryFocus = true + return counted + } + return .merge(counted, confirmCreateNode(&state)) + + case .templateChipRemoved: + restoreDraft(&state, from: state.templates.applied?.snapshot) + state.templates.applied = nil + return .none + + case .templateShapeUndone: + return undoTemplateShape(&state) + + case .detachTemplateTapped(let nodeID): + let projectPath = state.graph.project.path + return .run { _ in + try? await orchestratorClient.send( + .graphCommand(projectPath: projectPath, command: .detachTemplate(nodeID))) + } + + default: + return .none + } + } + + // MARK: - Applying + + /// One template, one mutation on the fields the form already edits. Every field + /// the template sets is recorded in `setFields` — that is what draws the + /// "from template" dot and what ✕ and `Undo the shape` revert along. + func applyTemplate(_ state: inout State, _ template: PromptTemplate) { + // Applying a second template keeps the *first* one's snapshot: ✕ answers "put the + // form back the way I found it", and the way the human found it is before any + // template landed, not before the most recent one. + let restorePoint = state.templates.applied?.snapshot ?? snapshot(of: &state) + state.templates.applied = AppliedTemplate( + id: template.id, name: template.name, shape: template.shape, + setFields: [], snapshot: restorePoint, brief: template.body) + var set: Set = [] + set.formUnion(applyBrief(&state, template)) + set.formUnion(applyShape(&state, template)) + set.formUnion(applySettings(&state, template)) + if template.shape == .composite, state.draftTitle.isEmpty { + set.insert(.title) + state.draftTitle = template.name + } + state.templates.applied?.setFields = set + } + + /// The brief lands in the type's own field — Main's starting note, Goal's + /// "what does done look like", Timed's "what to do each time", Turn's first + /// instruction. Composite's brief is its name. + private func applyBrief( + _ state: inout State, _ template: PromptTemplate + ) -> Set { + guard !template.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return [] } + switch template.shape { + case .sketch, nil: state.draftSketchNote = template.body + case .goalBased: state.draftGoal = template.body + case .timeBased: state.draftTimedTask = template.body + case .turnBased: state.draftFirstInstruction = template.body + case .composite: + // A composite's brief is its name (the form has no prose field for it), and the + // name is set from the template separately. Nothing landed here, so nothing is + // marked — a "from template" dot on a field this didn't write would be a lie. + return [] + } + return [.brief] + } + + /// The shape the loop takes on, and the one rule `runsAs`'s onChange applies + /// with it — a type the chosen agent can't host falls back to one it can. + private func applyShape( + _ state: inout State, _ template: PromptTemplate + ) -> Set { + // A shapeless template means Main (PROMPT_TEMPLATES.md § What a template + // carries) — the form follows, and the switch is the shape it set, so Undo + // can put the form back where it was. + let resolvedShape = template.shape ?? (state.draftLoopType == .sketch ? nil : .sketch) + guard let shape = resolvedShape, shape != state.draftLoopType else { return [] } + state.draftLoopType = shape + if !state.draftBackend.canHost(shape) { + state.draftBackend = CLISessionBackendKind.hosting(shape).first ?? .claudeCode + } + return [.shape] + } + + /// Everything but the prompt, each landing in its *real* dialog field. The agent + /// and the branch are honest in any shape — where the loop runs and where it works + /// are not what the loop *is* — so they sit here; everything else is the type's own + /// and lives in `applyShapeSettings`. + private func applySettings( + _ state: inout State, _ template: PromptTemplate + ) -> Set { + guard let settings = template.settings else { return [] } + var set: Set = [] + if let backend = settings.backend { + set.insert(.backend) + state.draftBackend = backend + } + set.formUnion(applyShapeSettings(&state, shape: template.shape, settings: settings)) + set.formUnion(applyBranch(&state, settings: settings)) + return set + } + + /// The settings only one loop type has a field for. + private func applyShapeSettings( + _ state: inout State, shape: LoopType?, settings: TemplateSettings + ) -> Set { + switch shape { + case .goalBased: + var set: Set = [] + if let check = settings.doneCheck { + set.insert(.doneCheck) + state.draftPredicate = check + } + if let metric = settings.metric { + set.insert(.metric) + state.draftMetric = metric + state.isMetricExpanded = true + } + return set + case .timeBased: + return applyCadence(&state, settings.cadence) + case .turnBased: + guard let pauses = settings.pausesBeforeWritesOnly else { return [] } + state.draftPausesBeforeWritesOnly = pauses + return [.pausesBeforeWritesOnly] + case .composite: + guard let graph = settings.carriedGraph, !graph.nodes.isEmpty else { return [] } + state.draftSubGraph = graph.reIdentified() + return [.subGraph] + case .sketch, nil: + return [] + } + } + + /// The five-segment interval control when the template's cadence is one of them, + /// Custom… with the value typed in when it isn't. + private func applyCadence( + _ state: inout State, _ raw: String? + ) -> Set { + guard let cadence = raw?.trimmingCharacters(in: .whitespaces), !cadence.isEmpty else { + return [] + } + if let choice = IntervalChoice.allCases.first(where: { + $0 != .custom && $0.directiveValue(custom: "") == cadence + }) { + state.draftInterval = choice + state.draftCustomInterval = "" + } else { + state.draftInterval = .custom + state.draftCustomInterval = cadence + } + return [.cadence] + } + + /// Hidden for a remote project or the global graph, exactly where the form itself + /// hides the branch picker — a template must not set a field nobody can see. + private func applyBranch( + _ state: inout State, settings: TemplateSettings + ) -> Set { + guard let branch = settings.branch, !branch.isEmpty, !state.graph.isGlobal, + RemoteProjectLocation.parse(projectPath: state.graph.project.path) == nil + else { return [] } + state.draftWorktree = .newBranch + state.draftBranch = branch + return [.branch] + } + + /// "Undo the shape": type and settings revert, the prompt text stays, the loop + /// returns to Main. The brief travels with it — text sitting in a Goal field + /// becomes the Main note, because "keep just the prompt" means the prompt. + func undoTemplateShape(_ state: inout State) -> Effect { + guard let applied = state.templates.applied, applied.carriesShape else { return .none } + let keptText = state.currentBriefText + restoreDraft(&state, from: applied.snapshot) + state.draftLoopType = .sketch + state.draftSketchNote = keptText + state.templates.applied?.setFields = [.brief] + return .none + } + + /// Puts every snapshotable field back. The ✕ path passes the applied template's + /// snapshot; other callers pass nothing and clear the rest of the template + /// state themselves. + func restoreDraft(_ state: inout State, from snapshot: DraftSnapshot?) { + guard let snapshot else { + // No snapshot means there was never a shape to revert — a Main template's + // ✕ clears only its text. + state.draftSketchNote = "" + state.draftGoal = "" + state.draftTimedTask = "" + state.draftFirstInstruction = "" + state.draftPredicate = "" + state.draftMetric = "" + state.isMetricExpanded = false + state.draftInterval = .hourly + state.draftCustomInterval = "" + state.draftPausesBeforeWritesOnly = false + state.draftBackend = GraphcodeSettingsStore.load().defaultBackend + state.draftWorktree = .none + state.draftBranch = "" + state.draftSubGraph = nil + return + } + state.draftLoopType = snapshot.loopType + state.draftTitle = snapshot.title + state.draftSketchNote = snapshot.sketchNote + state.draftGoal = snapshot.goal + state.draftPredicate = snapshot.predicate + state.draftMetric = snapshot.metric + state.draftMetricDirection = snapshot.metricDirection + state.isMetricExpanded = snapshot.isMetricExpanded + state.draftFirstInstruction = snapshot.firstInstruction + state.draftPausesBeforeWritesOnly = snapshot.pausesBeforeWritesOnly + state.draftTimedTask = snapshot.timedTask + state.draftInterval = snapshot.interval + state.draftCustomInterval = snapshot.customInterval + state.draftStopAfter = snapshot.stopAfter + state.draftSchedule = snapshot.schedule + state.draftScheduleTime = snapshot.scheduleTime + state.draftBackend = snapshot.backend + state.draftWorktree = snapshot.worktree + state.draftBranch = snapshot.branch + state.draftSubGraph = snapshot.subGraph + } + + private func snapshot(of state: inout State) -> DraftSnapshot { + DraftSnapshot( + loopType: state.draftLoopType, + title: state.draftTitle, + sketchNote: state.draftSketchNote, + goal: state.draftGoal, + predicate: state.draftPredicate, + metric: state.draftMetric, + metricDirection: state.draftMetricDirection, + isMetricExpanded: state.isMetricExpanded, + firstInstruction: state.draftFirstInstruction, + pausesBeforeWritesOnly: state.draftPausesBeforeWritesOnly, + timedTask: state.draftTimedTask, + interval: state.draftInterval, + customInterval: state.draftCustomInterval, + stopAfter: state.draftStopAfter, + schedule: state.draftSchedule, + scheduleTime: state.draftScheduleTime, + backend: state.draftBackend, + worktree: state.draftWorktree, + branch: state.draftBranch, + subGraph: state.draftSubGraph) + } + +} diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index b7edcfba..700c7683 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -88,6 +88,11 @@ struct ProjectFeature { var draftBackend: CLISessionBackendKind = .claudeCode var draftWorktree: WorktreeSelection = .none var draftBranch = "" + /// A composite draft's carried sub-graph — empty for a hand-made composite, + /// populated when a template brought its children along (see + /// `TemplateSettings.graphJSON`). The same field `.createNode`'s cross-graph + /// spawn path fills. + var draftSubGraph: LoopGraph? /// Set when the form was opened from a node card's + handle: the node the new loop /// hangs off. Creation then also draws a hand-off edge from it — see /// `createNodeConfirmed`. @@ -156,6 +161,11 @@ struct ProjectFeature { /// moment its loop is deleted or answered. var worktreeReclaimOffers: [UUID: WorktreeAssessment] = [:] + /// The template feature's own state — New Designs v4 (PROMPT_TEMPLATES.md): + /// the library, the ⌘T picker, the applied template and the save flow, all on + /// the store the dialog already runs on. See `TemplateFormState`. + var templates = TemplateFormState() + /// This folder's worktree stats, mirrored in by `AppWorktreesReducer` when it loads /// them — so the canvas, which only holds a project-scoped store, can put the count /// on its own `Worktrees…` menu item. @@ -263,6 +273,27 @@ struct ProjectFeature { /// the same reason — the folder was named, not the composite its canvas happens /// to be drilled into. case projectImportRequested + + // Templates — New Designs v4 (PROMPT_TEMPLATES.md). See `TemplateFormState` + // for the state these drive; the verb-by-verb detail lives beside it in + // `ProjectFeature+Templates.swift`. + case templatesButtonTapped + case templatePickerClosed + case templateQueryChanged(String) + case templateSelectionMoved(Int) + case templateChosen(UUID) + case templateLaunched(UUID) + case templateChipRemoved + case templateShapeUndone + case saveTemplateTapped + case saveLoopTemplateTapped(UUID) + case saveTemplateConfirmed + case saveTemplateCancelled + case templateSaved(PromptTemplate) + case templateSaveNoticeDismissed + case templateRelocationTapped + case templateLibraryChanged([PromptTemplate]) + case detachTemplateTapped(UUID) } @Dependency(\.gitClient) var gitClient @@ -273,9 +304,24 @@ struct ProjectFeature { @Dependency(\.loopTitleDirectory) var loopTitleDirectory @Dependency(\.orchestratorClient) var orchestratorClient + @Dependency(\.templateLibrary) var templateLibrary + + /// The one long-lived effect the template feature owns: the directory watch that + /// keeps `templateLibrary` current while the form is open. Cancelled when the + /// form closes. + enum CancelID { + case templateWatch + } var body: some ReducerOf { BindingReducer() + // Templates live in their own switches — the ⌘T picker, the applied state and + // Save-as-template — kept beside their state (`TemplateFormState`) and helpers so + // the main switch below stays about the graph. Three rather than one because each + // is a switch about one thing; see `ProjectFeature+Templates.swift`. + Reduce { state, action in templatePickerReducer(&state, action) } + Reduce { state, action in templateApplyReducer(&state, action) } + Reduce { state, action in templateSaveReducer(&state, action) } Reduce { state, action in switch action { case .binding: @@ -338,11 +384,21 @@ struct ProjectFeature { case .cancelNewNodeForm: state.showingNewNodeForm = false - return .none + return .cancel(id: CancelID.templateWatch) case .createNodeConfirmed: return confirmCreateNode(&state) + // Template actions were handled by the switch above; they land here as + // no-ops so this switch stays exhaustive, and nothing runs twice. + case .templatesButtonTapped, .templatePickerClosed, .templateQueryChanged, + .templateSelectionMoved, .templateChosen, .templateLaunched, .templateChipRemoved, + .templateShapeUndone, .saveTemplateTapped, .saveLoopTemplateTapped, + .saveTemplateConfirmed, .saveTemplateCancelled, .templateSaved, + .templateSaveNoticeDismissed, .templateRelocationTapped, .templateLibraryChanged, + .detachTemplateTapped: + return .none + case .worktreeCreationFailed(let message): state.connectionError = "Couldn't create worktree: \(message)" return .none @@ -548,6 +604,37 @@ struct ProjectFeature { } } +/// The template feature's own state — the library this project is offered, the +/// ⌘T picker, the applied template and the save flow. Nested rather than flat so +/// the dialog's fields stay where they were and everything ⌘T owns reads as one +/// block (`store.templates.…`). +@ObservableState +struct TemplateFormState: Equatable { + /// What this project is offered: the project's own `.graphcode/templates` + /// first, then home. Refreshed on every change to either directory, so an + /// external edit or a `git pull` shows up without a relaunch. + var library: [PromptTemplate] = [] + var isPickerOpen = false + var query = "" + /// The highlighted row, as an index into the *flattened* picker list — one + /// selection walking two scope groups, which is what ↑↓ means there. + var selectionIndex: Int? + /// The template currently shaping the form, with everything needed to + /// un-apply it: what it set, and the fields as they were before it landed. + var applied: ProjectFeature.AppliedTemplate? + /// Set right after ⏎ fills the dialog from a template: the primary field's + /// focus follows, so the first thing the human edits is the brief they came + /// to edit. The consuming field clears it. + var requestsPrimaryFocus = false + /// The save-as-template sheet's context — from the dialog + /// (`saveTemplateTapped`) or a card's context menu (`saveLoopTemplateTapped`). + /// One sheet, one field of state, two places it can open. + var pendingSave: ProjectFeature.TemplateSaveContext? + /// The quiet line after a save — path plus the offer of the other location, + /// never a modal. Cleared by the next action the form takes. + var saveNotice: ProjectFeature.TemplateSaveNotice? +} + extension ProjectFeature { /// The loop type the form opens on: the last one a loop was actually created with. /// @@ -631,7 +718,7 @@ extension ProjectFeature { /// The Create button's whole handler — in the trailing extension beside /// `openNodeForm` and the rest of the form's helpers, and for the same reason. - private func confirmCreateNode(_ state: inout State) -> Effect { + func confirmCreateNode(_ state: inout State) -> Effect { let draft = state.draft // `isValid` carries the same rules the daemon enforces, so an incomplete form // simply doesn't submit — the Create button is disabled on it too, and this is @@ -657,6 +744,10 @@ extension ProjectFeature { state.draftParentNodeID = nil state.draftParentIsCustodial = false state.showingNewNodeForm = false + // The directory watch belongs to the open dialog, not to the store — creating a + // loop closes the form just as Cancel does, and leaving it running would keep + // re-reading the library for a form nobody is looking at. + let closedWatch = Effect.cancel(id: CancelID.templateWatch) // Inside a composite, the same commands are addressed at its sub-graph. This is // the app half of "add loops inside" — the step the dialog's own strip promises. let insideComposite = state.openCompositeID @@ -681,52 +772,54 @@ extension ProjectFeature { // still created — unbound rather than not at all — since losing the loop over a // branch that already exists would be the more annoying outcome. let request = state.newWorktreeRequest - return .run { send in - var resolved = draft - // A custody child carries its parent on the draft; the daemon draws the - // fired-at-birth link and writes the report-back memo, exactly as it does - // for a CLI-created child. No separate edge command, so nothing blocks. - if custodial, let parentNodeID { resolved.createdBy = parentNodeID } - if let request { - do { - resolved.worktree = try await gitClient.createWorktree( - request.repositoryPath, request.worktreePath, request.branch) - } catch { - await send(.worktreeCreationFailed(String(describing: error))) + return .merge( + closedWatch, + .run { send in + var resolved = draft + // A custody child carries its parent on the draft; the daemon draws the + // fired-at-birth link and writes the report-back memo, exactly as it does + // for a CLI-created child. No separate edge command, so nothing blocks. + if custodial, let parentNodeID { resolved.createdBy = parentNodeID } + if let request { + do { + resolved.worktree = try await gitClient.createWorktree( + request.repositoryPath, request.worktreePath, request.branch) + } catch { + await send(.worktreeCreationFailed(String(describing: error))) + } + } + func addressed(_ command: GraphCommand) -> GraphCommand { + insideComposite.map { .subGraphCommand(nodeID: $0, command: command) } ?? command } - } - func addressed(_ command: GraphCommand) -> GraphCommand { - insideComposite.map { .subGraphCommand(nodeID: $0, command: command) } ?? command - } - try? await orchestratorClient.send( - .graphCommand(projectPath: projectPath, command: addressed(.createNode(resolved)))) - if let parentNodeID, !custodial { try? await orchestratorClient.send( - .graphCommand( - projectPath: projectPath, - command: addressed( - .createEdge(from: parentNodeID, to: draft.id, spec: EdgeSpec())))) - } + .graphCommand(projectPath: projectPath, command: addressed(.createNode(resolved)))) + if let parentNodeID, !custodial { + try? await orchestratorClient.send( + .graphCommand( + projectPath: projectPath, + command: addressed( + .createEdge(from: parentNodeID, to: draft.id, spec: EdgeSpec())))) + } - // A blank title creates the node as "New Loop" and asks the loop's own - // backend for a real one — after creation, so a slow (or absent) CLI never - // holds the node itself hostage. The rename can target the node because the - // draft's id *is* the node's id (see `NodeDraft.id`); no answer just means - // the fallback name stays. - guard draft.title.trimmingCharacters(in: .whitespaces).isEmpty, - let basis = [ - draft.checkDescription, draft.triggerPrompt, draft.goal?.summary, - draft.firstInstruction, - ] - .compactMap({ $0 }) - .first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }), - let title = await titleSuggestionClient.suggest( - draft.effectiveBackend, basis, loopTitleDirectory.allTitles()) - else { return } - try? await orchestratorClient.send( - .graphCommand( - projectPath: projectPath, command: addressed(.renameNode(draft.id, title: title)))) - } + // A blank title creates the node as "New Loop" and asks the loop's own + // backend for a real one — after creation, so a slow (or absent) CLI never + // holds the node itself hostage. The rename can target the node because the + // draft's id *is* the node's id (see `NodeDraft.id`); no answer just means + // the fallback name stays. + guard draft.title.trimmingCharacters(in: .whitespaces).isEmpty, + let basis = [ + draft.checkDescription, draft.triggerPrompt, draft.goal?.summary, + draft.firstInstruction, + ] + .compactMap({ $0 }) + .first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }), + let title = await titleSuggestionClient.suggest( + draft.effectiveBackend, basis, loopTitleDirectory.allTitles()) + else { return } + try? await orchestratorClient.send( + .graphCommand( + projectPath: projectPath, command: addressed(.renameNode(draft.id, title: title)))) + }) } private func openNodeForm( @@ -764,6 +857,7 @@ extension ProjectFeature { state.draftStopAfter = "" state.draftSchedule = .daily state.draftScheduleTime = "09:00" + state.draftSubGraph = nil // The parent's backend when there is one; the human's default otherwise // (Settings → Sessions), never a hardcoded one. state.draftBackend = backend ?? GraphcodeSettingsStore.load().defaultBackend @@ -771,14 +865,29 @@ extension ProjectFeature { state.draftBranch = "" state.draftParentNodeID = parentNodeID state.draftParentIsCustodial = custodial + state.templates = TemplateFormState() state.showingNewNodeForm = true let repositoryPath = state.graph.project.path - return .run { send in - // A non-repo folder just yields nothing — a missing worktree list is not worth - // an error banner when the picker degrades to "None" on its own. - let worktrees = (try? await gitClient.listWorktrees(repositoryPath)) ?? [] - await send(.worktreesLoaded(worktrees)) - } + return .merge( + .run { send in + // A non-repo folder just yields nothing — a missing worktree list is not worth + // an error banner when the picker degrades to "None" on its own. + let worktrees = (try? await gitClient.listWorktrees(repositoryPath)) ?? [] + await send(.worktreesLoaded(worktrees)) + }, + // The template library rides in with the form: read once, then kept current by + // the directory watch, so an external edit or a `git pull` shows up without a + // relaunch (PROMPT_TEMPLATES.md § Storage). + .run { send in + await send(.templateLibraryChanged(await templateLibrary.load(repositoryPath))) + }, + .run { [projectPath = repositoryPath] send in + for await _ in templateLibrary.watch(projectPath) { + await send(.templateLibraryChanged(await templateLibrary.load(projectPath))) + } + } + .cancellable(id: CancelID.templateWatch, cancelInFlight: true) + ) } /// One-liner for the several actions that are just "route this straight to the diff --git a/graphcode/Sources/Features/Project/ProjectFeatureState.swift b/graphcode/Sources/Features/Project/ProjectFeatureState.swift index 308689bb..d12643c6 100644 --- a/graphcode/Sources/Features/Project/ProjectFeatureState.swift +++ b/graphcode/Sources/Features/Project/ProjectFeatureState.swift @@ -72,6 +72,17 @@ extension ProjectFeature.State { worktree: { if case .existing(let ref) = draftWorktree { return ref } return nil + }(), + subGraph: draftLoopType == .composite ? draftSubGraph : nil, + // Attribution only — the card can say where the brief came from. The follow + // travels too, for the two types that follow: timed and composite re-read the + // template on their next run; goal, turn and main snapshot at creation. + createdFromTemplateID: templates.applied?.id, + templateFollow: { + guard let applied = templates.applied, + draftLoopType == .timeBased || draftLoopType == .composite + else { return nil } + return TemplateFollow(id: applied.id, name: applied.name) }()) } @@ -84,6 +95,79 @@ extension ProjectFeature.State { return value } + // MARK: - Templates (New Designs v4) + + /// The field currently holding the template brief — the one `{token}` patterns + /// are looked for in, and the one Save-as-template reads. + var currentBriefText: String { + switch draftLoopType { + case .sketch: return draftSketchNote + case .goalBased: return draftGoal + case .timeBased: return draftTimedTask + case .turnBased: return draftFirstInstruction + case .composite: return draftTitle + } + } + + /// Which of the applied template's `{token}`s is still a hole. A token the human + /// has typed over is gone as text and so is the hole. + /// + /// Every field a template can land in, not only the brief: a done check reading + /// `make test-{suite}` is exactly as unfinished as a brief with a hole in it, and + /// starting the loop would run the literal text. + var unfilledTokens: [String] { + var seen = Set() + var ordered: [String] = [] + for field in [currentBriefText, draftPredicate, draftMetric, draftBranch] { + for token in PromptTemplate.tokens(in: field) where seen.insert(token).inserted { + ordered.append(token) + } + } + return ordered + } + + /// What the applied template set — the "from template" dots read this. A + /// property, not a method, because a store's members are reachable through + /// key-path lookup only. + var templateSetFields: Set { + templates.applied?.setFields ?? [] + } + + /// Whether the dialog's primary action is ready beyond `draft.isValid` — a + /// brief with an unfilled token blocks Start, PROMPT_TEMPLATES.md § What a + /// template carries. + var draftBlocksOnTokens: Bool { + !unfilledTokens.isEmpty + } + + /// The picker's rows, already grouped: **This project** first, then **All + /// projects**, each sorted by name. The query filters on name and body — a + /// template is findable by what it says, not only by what it is called. + var templatePickerRows: [ProjectFeature.TemplatePickerRow] { + let query = templates.query.trimmingCharacters(in: .whitespaces).lowercased() + let matches: (PromptTemplate) -> Bool = { template in + query.isEmpty + || template.name.lowercased().contains(query) + || template.body.lowercased().contains(query) + } + var project: [PromptTemplate] = [] + var home: [PromptTemplate] = [] + for template in templates.library where matches(template) { + if template.origin.isProject { project.append(template) } else { home.append(template) } + } + return + project + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + .map { ProjectFeature.TemplatePickerRow(template: $0, scope: .project) } + + home + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + .map { ProjectFeature.TemplatePickerRow(template: $0, scope: .home) } + } + + var hasProjectTemplates: Bool { + templates.library.contains(where: \.origin.isProject) + } + /// What a timed loop's session actually opens with, composed rather than typed. /// /// The old form had one free-text field whose placeholder was the whole documentation: @@ -222,6 +306,45 @@ extension ProjectFeature.State { /// draft types. Nested on `ProjectFeature` rather than `State` so views can name them /// without going through the state type. extension ProjectFeature { + /// The picker's one row: the template plus which scope group it sits in. + struct TemplatePickerRow: Equatable, Identifiable { + let template: PromptTemplate + let scope: TemplatePickerScope + + var id: UUID { template.id } + /// "Goal" · "Timed · daily" · "Composite · 3 loops" · "Main" — the type in + /// words, with the one qualifier that makes it specific. + var typeLabel: String { + switch template.shape { + case .sketch, nil: return "Main" + case .goalBased: return "Goal" + case .timeBased: + let cadence = template.settings?.cadence.map { + $0.trimmingCharacters(in: .whitespaces).lowercased() + } + return cadence.map { "Timed · \($0)" } ?? "Timed" + case .turnBased: return "Turn" + case .composite: + let count = template.settings?.carriedGraph?.nodes.count ?? 0 + return count > 0 ? "Composite · \(count) loops" : "Composite" + } + } + } + + /// The two scope groups the picker sorts by — a project's committed templates + /// above the home library, always. + enum TemplatePickerScope: Equatable { + case project + case home + + var displayName: String { + switch self { + case .project: return "This project" + case .home: return "All projects" + } + } + } + /// What pressing **Test** on a done check found. No exit code: the shell session /// reports pass/fail and nothing finer, and a made-up number is the one detail /// somebody would act on. diff --git a/graphcode/Sources/Features/Project/TemplatePickerView.swift b/graphcode/Sources/Features/Project/TemplatePickerView.swift new file mode 100644 index 00000000..f6d80084 --- /dev/null +++ b/graphcode/Sources/Features/Project/TemplatePickerView.swift @@ -0,0 +1,296 @@ +import ComposableArchitecture +import GraphcodeKit +import SwiftUI + +/// The ⌘T picker — search-first, replacing the dialog's body while it is open, never +/// a second window (PROMPT_TEMPLATES.md § Picker). +/// +/// Grouped by scope with the project's committed templates above the home library; +/// each row shows the shape as a swatch in that type's hue, the brief's first +/// sentence, and a metadata line: token chips, what the template sets, and how +/// many times it has been used. Keys: ↑↓ move, ⏎ fill, ⌘⏎ start now, ⎋ back. +struct TemplatePickerView: View { + let store: StoreOf + @FocusState private var searchFocused: Bool + + private var rows: [ProjectFeature.TemplatePickerRow] { + store.templatePickerRows + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + searchField + if rows.isEmpty { + emptyState + } else { + list + } + Divider().overlay(Color.white.opacity(0.07)) + footer + } + .onAppear { + // Search-first: the field owns the keyboard from the moment the picker + // opens, which is what makes ↑↓ and ⏎ usable before the mouse is. + searchFocused = true + } + } + + private var searchField: some View { + HStack(spacing: 9) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.45)) + TextField( + "Search templates", + text: Binding( + get: { store.templates.query }, + set: { store.send(.templateQueryChanged($0)) } + ) + ) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($searchFocused) + // The keys live on the search field: it owns focus from the moment the picker + // opens, so ↑↓ ⏎ ⌘⏎ ⎋ all answer before the mouse is needed. + .onKeyPress { event in + switch event.key { + case .upArrow: + store.send(.templateSelectionMoved(-1)) + return .handled + case .downArrow: + store.send(.templateSelectionMoved(1)) + return .handled + case .return: + pressCurrent(event.modifiers.contains(.command) ? .launch : .fill) + return .handled + case .escape: + store.send(.templatePickerClosed) + return .handled + default: + return .ignored + } + } + if !store.templates.query.isEmpty { + Button { + store.send(.templateQueryChanged("")) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.4)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 11) + .frame(height: 34) + .background(Theme.draftField, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.paneFocusTint, lineWidth: 1.5) + } + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.paneFocusTint.opacity(0.16), lineWidth: 3) + .padding(-1.5) + } + } + + private var list: some View { + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 4) { + ForEach(grouped, id: \.scope) { group in + Text(group.scope.displayName.uppercased()) + .font(.system(size: 10.5, weight: .bold)) + .tracking(0.5) + .foregroundStyle(.white.opacity(0.4)) + .padding( + .top, + group.scope == .home && !grouped[0].scope.isProjectEquivalent + ? 10 : 2 + ) + .padding(.bottom, 3) + .padding(.leading, 2) + ForEach(group.items) { row in + rowView(row, isSelected: flatIndex(row) == store.templates.selectionIndex) + .id(row.id) + } + } + } + .padding(.vertical, 2) + } + .onChange(of: store.templates.selectionIndex) { _, index in + guard let index, index < rows.count else { return } + withAnimation(.easeOut(duration: 0.12)) { + proxy.scrollTo(rows[index].id, anchor: .center) + } + } + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 6) { + Text( + store.templates.query.isEmpty + ? "No templates yet — save one from a loop that worked." + : "No template matches “\(store.templates.query)”." + ) + .font(.system(size: 12.5)) + .foregroundStyle(.white.opacity(0.55)) + .padding(.vertical, 14) + .padding(.horizontal, 2) + } + } + + private var footer: some View { + HStack(spacing: 10) { + Text("↑↓ to move · ⏎ to fill in · ⌘⏎ to start now") + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.5)) + Spacer(minLength: 8) + SettingsLink { + Text("Manage…") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0)) + } + .buttonStyle(.plain) + } + } + + // MARK: - Rows + + private struct Group { + let scope: ProjectFeature.TemplatePickerScope + var items: [ProjectFeature.TemplatePickerRow] + } + + private var grouped: [Group] { + var groups: [Group] = [] + for row in rows { + if let last = groups.last, last.scope.displayName == row.scope.displayName { + groups[groups.count - 1].items.append(row) + } else { + groups.append(Group(scope: row.scope, items: [row])) + } + } + return groups + } + + private func flatIndex(_ row: ProjectFeature.TemplatePickerRow) -> Int? { + rows.firstIndex(where: { $0.id == row.id }) + } + + private func rowView(_ row: ProjectFeature.TemplatePickerRow, isSelected: Bool) -> some View { + Button { + // The row that was clicked, not the row the keyboard is on. `pressCurrent` is + // for the keys, which have no other way of saying which row they mean. + store.send(.templateChosen(row.template.id)) + } label: { + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2) + .fill( + (row.template.shape ?? .sketch).accent.opacity( + row.template.shape == nil ? 0.8 : 1) + ) + .frame(width: 9, height: 9) + .padding(.top, 4) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 7) { + Text(row.template.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white.opacity(isSelected ? 0.95 : 0.88)) + .lineLimit(1) + Text(row.typeLabel) + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.45)) + } + Text(row.template.summaryLine) + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.6)) + .lineLimit(1) + .truncationMode(.tail) + metaChips(row) + } + Spacer(minLength: 0) + } + .padding(.vertical, 9) + .padding(.horizontal, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected ? Theme.paneFocusTint.opacity(0.1) : Color.white.opacity(0.03), + in: RoundedRectangle(cornerRadius: 9) + ) + .overlay { + RoundedRectangle(cornerRadius: 9) + .stroke( + isSelected ? Theme.paneFocusTint.opacity(0.42) : .white.opacity(0.07), lineWidth: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func metaChips(_ row: ProjectFeature.TemplatePickerRow) -> some View { + let chips = + tokenChips(row.template) + settingChips(row.template) + + [useChip(row.template)].compactMap { $0 } + if !chips.isEmpty { + HStack(spacing: 5) { + ForEach(chips, id: \.self) { chip in + Text(chip.text) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle( + chip.isToken ? Color(red: 0.549, green: 0.773, blue: 0.95) : .white.opacity(0.5) + ) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background( + chip.isToken + ? Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.14) + : Color.white.opacity(0.06), + in: RoundedRectangle(cornerRadius: 4)) + } + } + .padding(.top, 2) + } + } + + private struct Chip: Hashable { + let text: String + let isToken: Bool + } + + private func tokenChips(_ template: PromptTemplate) -> [Chip] { + template.tokens.map { Chip(text: "{\($0)}", isToken: true) } + } + + private func settingChips(_ template: PromptTemplate) -> [Chip] { + template.settingsSummary.map { Chip(text: $0, isToken: false) } + } + + private func useChip(_ template: PromptTemplate) -> Chip? { + guard template.useCount > 0 else { return nil } + return Chip(text: "used \(template.useCount)×", isToken: false) + } + + private enum Press { + case fill + case launch + } + + private func pressCurrent(_ press: Press) { + guard let index = store.templates.selectionIndex, index < rows.count else { return } + let id = rows[index].template.id + switch press { + case .fill: store.send(.templateChosen(id)) + case .launch: store.send(.templateLaunched(id)) + } + } +} + +extension ProjectFeature.TemplatePickerScope { + var isProjectEquivalent: Bool { + self == .project + } +} diff --git a/graphcode/Sources/Features/Project/TemplateSaveSheet.swift b/graphcode/Sources/Features/Project/TemplateSaveSheet.swift new file mode 100644 index 00000000..c3d1ebb9 --- /dev/null +++ b/graphcode/Sources/Features/Project/TemplateSaveSheet.swift @@ -0,0 +1,200 @@ +import ComposableArchitecture +import GraphcodeKit +import SwiftUI + +/// Save-as-template's sheet — name it, choose where it lands, save. Shared by the +/// dialog's own save button and a card's context menu; the template itself is +/// already built by the reducer, so the sheet edits only the two things a save +/// actually decides. +/// +/// Home is the default and the only guaranteed target: a project folder that won't +/// take a `.graphcode/templates` is not offered at all, because a save that silently +/// falls back to home would say one thing and do another. +struct TemplateSaveSheet: View { + @Bindable var store: StoreOf + @FocusState private var nameFocused: Bool + + var body: some View { + if let context = store.templates.pendingSave { + VStack(alignment: .leading, spacing: 14) { + Text("Save as template") + .font(.system(size: 15, weight: .semibold)) + Text(templateSummary) + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.6)) + .fixedSize(horizontal: false, vertical: true) + + DraftField(label: "Name") { + DraftTextField( + placeholder: "Review the diff on this branch", + text: Binding( + get: { store.templates.pendingSave?.name ?? "" }, + set: { + if var context = store.templates.pendingSave { + context.name = $0 + store.templates.pendingSave = context + } + } + ) + ) + .focused($nameFocused) + } + + DraftField( + label: "Where it lives", + help: + "Home is offered in every project. The project folder is what a team " + + "commits — sharing is a git action, not an app feature." + ) { + Picker( + "", + selection: Binding( + get: { store.templates.pendingSave?.scope ?? .home }, + set: { + if var context = store.templates.pendingSave { + context.scope = $0 + store.templates.pendingSave = context + } + } + ) + ) { + Text("Home — all projects").tag(TemplateOrigin.home) + if projectCanSave { + Text("This project (.graphcode/templates)") + .tag(TemplateOrigin.project(store.graph.project.path)) + } + } + .labelsHidden() + .pickerStyle(.radioGroup) + if !projectCanSave { + Text("This folder can't take a .graphcode/templates, so home is the only option.") + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.5)) + .fixedSize(horizontal: false, vertical: true) + } + } + + HStack { + Button("Cancel") { store.send(.saveTemplateCancelled) } + .buttonStyle(.plain) + .font(.system(size: 12.5)) + .foregroundStyle(.white.opacity(0.7)) + Spacer(minLength: 8) + Button { + store.send(.saveTemplateConfirmed) + } label: { + Text("Save template") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .frame(height: 32) + .background(Theme.paneFocusTint, in: RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .disabled(nameIsBlank) + } + } + .padding(20) + .frame(minWidth: 420, idealWidth: 440) + .background(Theme.sheet) + .onAppear { + if context.name.isEmpty { nameFocused = true } + } + } + } + + private var nameIsBlank: Bool { + (store.templates.pendingSave?.name ?? "").trimmingCharacters(in: .whitespaces).isEmpty + } + + /// Whether the project folder can actually take a `.graphcode/templates`. SwiftUI + /// ignores `.disabled` on an individual `Picker` tag, so an unwritable project is + /// kept out of the list entirely rather than offered and then quietly overruled. + var projectCanSave: Bool { + store.templates.pendingSave?.projectCanSave ?? false + } + + /// What is being saved, in one line — the shape it carries and what it sets. + private var templateSummary: String { + guard let context = store.templates.pendingSave else { return "" } + let label = ProjectFeature.TemplatePickerRow(template: context.template, scope: .home).typeLabel + let type = context.template.shape == nil ? "a Main loop's brief" : "a \(label) loop" + let settings = context.template.settingsSummary + let settingsPart = settings.isEmpty ? "" : ", setting " + settings.joined(separator: ", ") + let tokens = + context.template.tokens.isEmpty + ? "" + : " Its \(context.template.tokens.count) token\(context.template.tokens.count == 1 ? "" : "s") stay fill-in-at-use." + return "This saves \(type)\(settingsPart).\(tokens)" + } +} + +/// Presents the save sheet for a save started **outside** the New loop dialog — a +/// loop's context menu, which PROMPT_TEMPLATES.md § Save as template names first. +/// The dialog hosts its own copy while it is open, so this one stands down then: +/// two `.sheet`s bound to the same item present nothing at all. +struct TemplateSaveSheetHost: ViewModifier { + let store: StoreOf + + func body(content: Content) -> some View { + content.sheet( + item: Binding( + get: { store.showingNewNodeForm ? nil : store.templates.pendingSave }, + set: { if $0 == nil, !store.showingNewNodeForm { store.send(.saveTemplateCancelled) } } + ) + ) { _ in + TemplateSaveSheet(store: store) + } + } +} + +/// The quiet line after a save, for saves made with no dialog on screen to put it in: +/// where the file went, and the offer of the other location. Never a modal, and it +/// dismisses itself — see PROMPT_TEMPLATES.md § Storage. +struct TemplateSaveNoticeBar: View { + let store: StoreOf + + var body: some View { + if let notice = store.templates.saveNotice, !store.showingNewNodeForm { + HStack(spacing: 8) { + Text("Saved to") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.55)) + Text(TemplateSavePath.display(of: notice.template)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(.white.opacity(0.75)) + .lineLimit(1) + .truncationMode(.middle) + Button(notice.otherOffer) { store.send(.templateRelocationTapped) } + .buttonStyle(.plain) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9)) + Spacer(minLength: 8) + Button { + store.send(.templateSaveNoticeDismissed) + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white.opacity(0.5)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.06)) + } + } +} + +/// Where a template's file lives, as a person reads a path. Shared by the dialog's +/// footer line and the canvas notice so both name the same file the same way. +enum TemplateSavePath { + static func display(of template: PromptTemplate) -> String { + switch template.origin { + case .home: return "~/.graphcode/templates/\(template.fileName)" + case .project(let path): return "\(path)/.graphcode/templates/\(template.fileName)" + } + } +} diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index d0e98b20..961956d2 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -226,12 +226,128 @@ struct SettingsView: View { .font(.caption2) .foregroundStyle(.secondary) } + + TemplatesSettingsSection() } .formStyle(.grouped) } } +/// The template library, as Settings can see it: the home folder — yours, offered in +/// every project. Project templates are read from wherever the project lives and are +/// committed or removed like any other file the folder holds; the one thing worth +/// saying here is the half of follow-vs-snapshot that load-bearing (PROMPT_TEMPLATES.md +/// § Follow vs snapshot): a committed edit changes what runs on a teammate's machine. +struct TemplatesSettingsSection: View { + @State private var templates: [PromptTemplate] = [] + @State private var pendingDeletion: PromptTemplate? + + var body: some View { + Section { + if templates.isEmpty { + Text( + "No templates yet. Save one from the New loop dialog (⌘T), or right-click a " + + "loop that worked and choose Save as Template…." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } else { + ForEach(templates) { template in + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2) + .fill((template.shape ?? .sketch).accent) + .frame(width: 9, height: 9) + VStack(alignment: .leading, spacing: 1) { + Text(template.name).font(.callout) + Text(subtitle(template)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Button("Reveal") { reveal(template) } + .buttonStyle(.link) + .font(.caption) + Button("Delete", role: .destructive) { pendingDeletion = template } + .buttonStyle(.link) + .font(.caption) + } + .padding(.vertical, 2) + } + } + } header: { + Text("Templates") + } footer: { + Text( + "One template is one markdown file in ~/.graphcode/templates — editable outside " + + "the app, shareable by committing it into a project's .graphcode/templates. " + + "Timed and composite loops that started from one follow it and pick up edits " + + "on their next run; a loop can be detached from its card's menu. Each project " + + "reads its own committed templates first, so a committed edit changes what " + + "runs on a teammate's machine." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + .onAppear(perform: reload) + // A template is a file, and a project one is a file in somebody's checkout. The + // list is the only place they can be deleted, so the click asks first. + .confirmationDialog( + "Delete “\(pendingDeletion?.name ?? "")”?", + isPresented: Binding( + get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } }) + ) { + Button("Delete template", role: .destructive) { + if let template = pendingDeletion { try? TemplateStorage.shared.delete(template) } + pendingDeletion = nil + reload() + } + Button("Cancel", role: .cancel) { pendingDeletion = nil } + } message: { + Text(pendingDeletion.map { "This removes \(TemplateSavePath.display(of: $0))." } ?? "") + } + } + + private func subtitle(_ template: PromptTemplate) -> String { + var parts: [String] = [] + switch template.shape { + case .sketch, nil: parts.append("Main") + case .goalBased: parts.append("Goal") + case .timeBased: + let cadence = template.settings?.cadence.map { $0.lowercased() } ?? "" + parts.append(cadence.isEmpty ? "Timed" : "Timed · \(cadence)") + case .turnBased: parts.append("Turn") + case .composite: parts.append("Composite") + } + if template.useCount > 0 { parts.append("used \(template.useCount)×") } + parts.append(template.summaryLine) + return parts.joined(separator: " — ") + } + + /// The file this row stands for — which is not always in the home folder, so the + /// origin decides rather than the assumption that everything listed here is home's. + private func reveal(_ template: PromptTemplate) { + let storage = TemplateStorage.shared + let directory: URL + switch template.origin { + case .home: directory = storage.homeDirectory + case .project(let path): directory = storage.projectDirectory(path) + } + NSWorkspace.shared.activateFileViewerSelecting([ + directory.appendingPathComponent(template.fileName) + ]) + } + + private func reload() { + // The same use-count overlay the picker reads, so a template that says "used 4×" + // in one list doesn't say "used 0×" in the other. + templates = TemplateLibraryClient.overlayUseCounts( + TemplateStorage.shared.load(projectPath: nil)) + } +} + /// Makes the Settings window actually resizable, because the SwiftUI spelling doesn't. /// /// `.windowResizability(.contentMinSize)` on the `Settings` scene is silently ignored diff --git a/graphcode/Tests/TemplateApplyTests.swift b/graphcode/Tests/TemplateApplyTests.swift new file mode 100644 index 00000000..6ac0f3ca --- /dev/null +++ b/graphcode/Tests/TemplateApplyTests.swift @@ -0,0 +1,311 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +/// What applying a template does to the form, and how it is taken back — the +/// three rules of the applied state (PROMPT_TEMPLATES.md § Applied state): the +/// shape is stated and marked, every field the template set stays editable, and +/// `Undo the shape` / ✕ return exactly what they should. +@Suite +struct TemplateApplyTests { + private static let project = ProjectRef(path: "/tmp/template-apply", name: "apply") + + private func makeStore( + _ templates: [PromptTemplate] = [], loopType: LoopType = .sketch, + sketchNote: String = "" + ) -> TestStoreOf { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + state.draftLoopType = loopType + state.draftSketchNote = sketchNote + return TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in templates } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.templateLibrary.projectIsWritable = { _ in true } + $0.gitClient.listWorktrees = { _ in [] } + // A no-op orchestrator: any send that reaches it is asserted by the test + // that means it, and the rest must not record phantom issues. + $0.orchestratorClient.send = { _ in } + } + } + + private var goalTemplate: PromptTemplate { + PromptTemplate( + id: UUID(), name: "Review the diff", + body: "Read every changed file against the style guide, then list what must change.", + shape: .goalBased, + settings: TemplateSettings( + backend: .claudeCode, doneCheck: "make test", cadence: nil, pausesBeforeWritesOnly: nil, + branch: "review/patch", metric: "./scripts/score.sh"), + origin: .home) + } + + @Test + @MainActor + func applyingSetsTheTypeAndEverySetting() async { + let template = goalTemplate + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + let state = store.state + #expect(state.draftLoopType == .goalBased) + #expect(state.draftGoal.contains("style guide")) + #expect(state.draftPredicate == "make test") + #expect(state.draftMetric == "./scripts/score.sh") + #expect(state.isMetricExpanded) + #expect(state.draftWorktree == .newBranch) + #expect(state.draftBranch == "review/patch") + // Every field the template landed is marked, and nothing is locked — the + // marks are provenance, not permission. + #expect(state.templateSetFields.contains(.shape)) + #expect(state.templateSetFields.contains(.brief)) + #expect(state.templateSetFields.contains(.doneCheck)) + #expect(state.templateSetFields.contains(.metric)) + #expect(state.templateSetFields.contains(.branch)) + #expect(state.draft.isValid) + } + + /// An unfilled-token brief keeps Start off; filling it over is what unblocks + /// the dialog (PROMPT_TEMPLATES.md § What a template carries). + @Test + @MainActor + func anUnfilledTokenBlocksStartAndFillingUnblocksIt() async { + let tokenised = PromptTemplate( + id: UUID(), name: "Port {branch}", body: "Port {branch} to the new renderer.", + shape: .goalBased, origin: .home) + let library = [tokenised] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(tokenised.id)) + #expect(store.state.unfilledTokens == ["branch"]) + #expect(store.state.draftBlocksOnTokens) + #expect(store.state.draft.isValid) + // The two gates are different things: the draft is valid, the token is not. + #expect(!store.state.draftBlocksOnTokens || store.state.draft.isValid) + + await store.send(.binding(.set(\.draftGoal, "Port feat/x to the new renderer."))) + #expect(store.state.unfilledTokens.isEmpty) + #expect(!store.state.draftBlocksOnTokens) + } + + /// "Undo the shape": type and settings revert, the prompt text stays, and the + /// loop returns to Main — with the brief carried into the Main note. + @Test + @MainActor + func undoingTheShapeKeepsThePromptAndReturnsToMain() async { + let template = goalTemplate + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + await store.send(.templateShapeUndone) + let state = store.state + #expect(state.draftLoopType == .sketch) + // The prompt text survived the revert, and it lives in the Main note now. + #expect(state.draftSketchNote.contains("style guide")) + #expect(state.draftGoal.isEmpty) + #expect(state.draftPredicate.isEmpty) + #expect(state.draftMetric.isEmpty) + #expect(state.draftWorktree == .none) + // Only the brief still counts as template-set. + #expect(state.templateSetFields == [.brief]) + } + + /// The ✕ is the stronger undo: everything the template contributed goes, + /// including the text, back to the fields as they were before it landed. + @Test + @MainActor + func removingTheChipClearsEverythingTheTemplateContributed() async { + let template = goalTemplate + let library = [template] + let store = makeStore(library, sketchNote: "my own half-written note") + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + #expect(store.state.draftGoal.contains("style guide")) + await store.send(.templateChipRemoved) + let restored = store.state + // Everything back to before: type, settings, and the human's own text. + #expect(restored.draftLoopType == .sketch) + #expect(restored.draftSketchNote == "my own half-written note") + #expect(restored.draftGoal.isEmpty) + #expect(restored.draftPredicate.isEmpty) + #expect(restored.draftWorktree == .none) + #expect(restored.templates.applied == nil) + #expect(restored.templateSetFields.isEmpty) + } + + /// A Main template (no shape) applies text only — and its ✕ clears only the + /// text, because there was never a shape to revert. + @Test + @MainActor + func aShapelessTemplateCarriesOnlyItsText() async { + let main = PromptTemplate( + id: UUID(), name: "Where does this live?", + body: "Trace a symbol through the codebase and report every reader and writer.", + shape: nil, origin: .home) + let library = [main] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(main.id)) + #expect(store.state.draftLoopType == .sketch) + #expect(store.state.templates.applied?.carriesShape == false) + // No shape to undo — the action leaves the brief exactly where it is. + await store.send(.templateShapeUndone) + #expect(store.state.draftSketchNote.contains("Trace a symbol")) + await store.send(.templateChipRemoved) + #expect(store.state.draftSketchNote.isEmpty) + #expect(store.state.templates.applied == nil) + } + + /// A timed template lands its cadence in the real interval control — the + /// five-segment picker when the template's cadence is one of them, Custom… + /// with the value typed in when it isn't. + @Test + @MainActor + func aTimedTemplateSetsItsCadence() async { + let hourly = PromptTemplate( + id: UUID(), name: "Hourly sweep", body: "Check the queue.", + shape: .timeBased, settings: TemplateSettings(cadence: "1h"), origin: .home) + let odd = PromptTemplate( + id: UUID(), name: "Odd sweep", body: "Check the queue.", + shape: .timeBased, settings: TemplateSettings(cadence: "45m"), origin: .home) + + let library = [hourly, odd] + let store = makeStore(library) + store.exhaustivity = .off + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(hourly.id)) + #expect(store.state.draftInterval == .hourly) + #expect(store.state.templateSetFields.contains(.cadence)) + // Composed prompt is the real thing: GraphCode writes the /loop directive. + #expect(store.state.composedTriggerPrompt == "/loop 1h Check the queue.") + + await store.send(.templateChosen(odd.id)) + #expect(store.state.draftInterval == .custom) + #expect(store.state.draftCustomInterval == "45m") + #expect(store.state.composedTriggerPrompt == "/loop 45m Check the queue.") + } + + /// A template is "used" when it is applied, not when it is saved — that is what + /// the picker's "used N×" counts. + @Test + @MainActor + func applyingATemplateCountsAsAUse() async { + let template = goalTemplate + let library = [template] + let used = LockIsolated<[String]>([]) + let store = makeStore(library) + store.exhaustivity = .off + store.dependencies.templateLibrary.recordUse = { applied in + used.withValue { $0.append(applied.name) } + } + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + await store.receive(\.templateLibraryChanged) + #expect(used.value == ["Review the diff"]) + } + + /// A `{token}` left in a done check is exactly as unfinished as one left in the + /// brief — starting would run the literal text. + @Test + @MainActor + func aTokenLeftInASettingBlocksStartToo() async { + let template = PromptTemplate( + id: UUID(), name: "Suite check", body: "Get the suite green.", shape: .goalBased, + settings: TemplateSettings(doneCheck: "make test-{suite}"), origin: .home) + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + #expect(store.state.unfilledTokens == ["suite"]) + #expect(store.state.draftBlocksOnTokens) + + await store.send(.binding(.set(\.draftPredicate, "make test-parser"))) + #expect(store.state.unfilledTokens.isEmpty) + } + + /// ✕ answers "put the form back the way I found it", and that means before *any* + /// template landed — not before the most recent one. + @Test + @MainActor + func theChipRestoresThePreTemplateDraftAfterTwoApplies() async { + let first = PromptTemplate( + id: UUID(), name: "First", body: "First brief.", shape: .goalBased, origin: .home) + let second = PromptTemplate( + id: UUID(), name: "Second", body: "Second brief.", shape: .turnBased, origin: .home) + let library = [first, second] + let store = makeStore(library, loopType: .sketch, sketchNote: "my own half-written note") + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(first.id)) + await store.send(.templateChosen(second.id)) + await store.send(.templateChipRemoved) + #expect(store.state.draftLoopType == .sketch) + #expect(store.state.draftSketchNote == "my own half-written note") + #expect(store.state.draftGoal.isEmpty) + #expect(store.state.draftFirstInstruction.isEmpty) + } + + /// A composite template carries its children into the draft, and the loop it + /// creates follows the template — that is how an orchestration is shared. + @Test + @MainActor + func aCompositeTemplateCarriesItsChildrenIntoTheDraft() async { + let graph = LoopGraph( + project: ProjectRef(path: "review", name: "review"), + nodes: [ + LoopNode(title: "Reviewer", loopType: .goalBased, goal: GoalSpec(summary: "Find issues")) + ]) + let composite = PromptTemplate( + id: UUID(), name: "Review, fix, verify", body: "Hand findings along.", + shape: .composite, + settings: TemplateSettings(graphJSON: TemplateSettings.graphJSON(for: graph)), + origin: .home) + let library = [composite] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(composite.id)) + #expect(store.state.draftLoopType == .composite) + // The composite's brief is its name, carried from the template. + #expect(store.state.draftTitle == "Review, fix, verify") + #expect(store.state.draftSubGraph?.nodes.map(\.title) == ["Reviewer"]) + #expect(store.state.templateSetFields.contains(.subGraph)) + // The carried loops land inside on Create — re-identified, so a second loop + // from the same template never collides with the first. + let draft = store.state.draft + #expect(draft.subGraph?.nodes.count == 1) + #expect(draft.createdFromTemplateID == composite.id) + #expect(draft.templateFollow?.name == "Review, fix, verify") + } +} diff --git a/graphcode/Tests/TemplateFollowTests.swift b/graphcode/Tests/TemplateFollowTests.swift new file mode 100644 index 00000000..a19db527 --- /dev/null +++ b/graphcode/Tests/TemplateFollowTests.swift @@ -0,0 +1,368 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import GraphcodeKit + +/// Follow vs snapshot — the half of the template design that lives in the daemon +/// (PROMPT_TEMPLATES.md § Follow vs snapshot). +/// +/// **Timed and composite loops follow their template** and pick up edits on the +/// next run; **Main, goal and turn loops snapshot at creation** and a running +/// session can never have its brief swapped underneath it. A deleted template is +/// a warning on the card, not a failure: the loop keeps its last-known snapshot. +@Suite +struct TemplateFollowTests { + /// Injected reads against a scratch directory; nothing here touches the real + /// `~/.graphcode`. + private let storage: TemplateStorage + private let home: URL + private let projectPath: String + + init() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("template-follow-tests-\(UUID().uuidString)", isDirectory: true) + home = root.appendingPathComponent("home", isDirectory: true) + projectPath = root.appendingPathComponent("repo", isDirectory: true).path + try? FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try? FileManager.default.createDirectory( + at: URL(fileURLWithPath: projectPath, isDirectory: true), withIntermediateDirectories: true) + storage = TemplateStorage( + homeDirectory: home, + projectDirectory: { path in + URL(fileURLWithPath: path, isDirectory: true) + .appendingPathComponent(".graphcode", isDirectory: true) + .appendingPathComponent("templates", isDirectory: true) + }) + } + + private func saveNightly(_ template: PromptTemplate) throws { + try storage.save(template, to: .home, projectPath: projectPath) + } + + private func makeStore( + resolve: (@Sendable (UUID, String?) -> PromptTemplate?)? = nil + ) -> GraphStore { + GraphStore( + onEnsureSession: { _, _ in }, + onResolveTemplate: + resolve + ?? { id, _ in self.storage.template(withID: id, projectPath: self.projectPath) }) + } + + @Test + func aTimedLoopPicksUpEditOnItsNextRun() async throws { + let id = UUID() + try saveNightly( + PromptTemplate( + id: id, name: "Nightly dependency review", + body: "Check for updates worth taking and say what would break.", + shape: .timeBased, settings: TemplateSettings(cadence: "daily"))) + let started = LockIsolated<[LoopNode]>([]) + let store = GraphStore( + onEnsureSession: { node, _ in started.withValue { $0.append(node) } }, + onResolveTemplate: { templateID, _ in + self.storage.template(withID: templateID, projectPath: self.projectPath) + }) + await store.handle( + .createNode( + NodeDraft( + title: "Nightly dependency review", loopType: .timeBased, + triggerPrompt: "/loop daily Check for updates worth taking and say what would break.", + templateFollow: TemplateFollow(id: id, name: "Nightly dependency review")))) + let before = await store.graph + #expect(before.nodes[0].templateFollow?.missing == false) + + // The template is edited — the *next run* is what picks it up. + try saveNightly( + PromptTemplate( + id: id, name: "Nightly dependency review", body: "Check dependencies and triage.", + shape: .timeBased, settings: TemplateSettings(cadence: "daily"))) + + await store.ensureUnattendedSessions() + let after = await store.graph + #expect(after.nodes[0].triggerPrompt == "/loop daily Check dependencies and triage.") + // The launch carried the refreshed brief, not the snapshot. + #expect(started.value.last?.triggerPrompt == "/loop daily Check dependencies and triage.") + } + + @Test + func aGoalLoopSnapshotsAtCreationAndNeverFollows() async throws { + let id = UUID() + try saveNightly( + PromptTemplate(id: id, name: "Nightly", body: "irrelevant", shape: .timeBased)) + let store = makeStore() + await store.handle( + .createNode( + NodeDraft( + title: "Green build", loopType: .goalBased, + goal: GoalSpec(summary: "CI passes", predicate: "make test"), + // A goal loop cannot follow, whatever the draft carried — makeNode + // drops the follow for every type that snapshots. + createdFromTemplateID: id, templateFollow: TemplateFollow(id: id, name: "Nightly")))) + + let graph = await store.graph + #expect(graph.nodes[0].createdFromTemplateID == id) + #expect(graph.nodes[0].templateFollow == nil) + } + + @Test + func aDeletedTemplateWarnsAndRunsItsSnapshot() async throws { + let id = UUID() + try saveNightly( + PromptTemplate( + id: id, name: "Nightly dependency review", body: "Check dependencies.", + shape: .timeBased, settings: TemplateSettings(cadence: "daily"))) + let store = makeStore() + await store.handle( + .createNode( + NodeDraft( + title: "Nightly", loopType: .timeBased, triggerPrompt: "/loop daily Check dependencies.", + templateFollow: TemplateFollow(id: id, name: "Nightly dependency review")))) + + // The file goes away — the next run keeps the snapshot and flips the warning. + try FileManager.default.removeItem( + at: storage.homeDirectory.appendingPathComponent("nightly-dependency-review.md")) + await store.ensureUnattendedSessions() + + let graph = await store.graph + #expect(graph.nodes[0].templateFollow?.missing == true) + #expect(graph.nodes[0].triggerPrompt == "/loop daily Check dependencies.") + } + + @Test + func detachingConvertsTheLoopToASnapshotInPlace() async throws { + let id = UUID() + try saveNightly( + PromptTemplate( + id: id, name: "Nightly dependency review", body: "Check dependencies.", + shape: .timeBased, settings: TemplateSettings(cadence: "daily"))) + let store = makeStore() + await store.handle( + .createNode( + NodeDraft( + title: "Nightly", loopType: .timeBased, triggerPrompt: "/loop daily Check dependencies.", + templateFollow: TemplateFollow(id: id, name: "Nightly dependency review")))) + let nodeID = await store.graph.nodes[0].id + + await store.handle(.detachTemplate(nodeID)) + let graph = await store.graph + #expect(graph.nodes[0].templateFollow == nil) + #expect(graph.nodes[0].triggerPrompt == "/loop daily Check dependencies.") + } + + @Test + func aCompositePilotReReadsTheTemplateItFollows() async throws { + let id = UUID() + let reviewerGraph = LoopGraph( + project: ProjectRef(path: "review", name: "review"), + nodes: [LoopNode(title: "Reviewer v1", loopType: .goalBased, goal: GoalSpec(summary: "Find"))] + ) + try saveNightly( + PromptTemplate( + id: id, name: "Review, fix, verify", body: "Hand findings along.", shape: .composite, + settings: TemplateSettings(graphJSON: TemplateSettings.graphJSON(for: reviewerGraph)))) + + let store = makeStore() + let stale = LoopGraph( + project: ProjectRef(path: "stale", name: "stale"), + nodes: [LoopNode(title: "Old child", loopType: .sketch)]) + await store.handle( + .createNode( + NodeDraft( + title: "Review, fix, verify", loopType: .composite, + triggerPrompt: "Intended schedule: daily at 09:00", + subGraph: stale, + templateFollow: TemplateFollow(id: id, name: "Review, fix, verify")))) + let compositeID = await store.graph.nodes[0].id + + await store.handle(.pilotComposite(compositeID)) + let graph = await store.graph + // The pilot is the composite's next run: the sub-graph the template now + // carries replaced the stale one. + #expect(graph.nodes[0].subGraph?.nodes.map(\.title) == ["Reviewer v1"]) + #expect(graph.nodes[0].templateFollow?.missing == false) + } + + /// The pilot is a run boundary, not a rebuild. A template whose graph hasn't + /// changed must leave the children exactly where they are: node ids are `zmx` + /// session names, so re-identifying them orphans every session the last pass + /// started and strands its memory under an id no card can reach. + @Test + func anUnchangedCompositeTemplateLeavesItsChildrenAlone() async throws { + let id = UUID() + let carried = LoopGraph( + project: ProjectRef(path: "review", name: "review"), + nodes: [LoopNode(title: "Reviewer", loopType: .goalBased, goal: GoalSpec(summary: "Find"))]) + try saveNightly( + PromptTemplate( + id: id, name: "Review, fix, verify", body: "Hand findings along.", shape: .composite, + settings: TemplateSettings(graphJSON: TemplateSettings.graphJSON(for: carried)))) + + let killed = LockIsolated<[UUID]>([]) + let store = GraphStore( + onEnsureSession: { _, _ in }, + onTerminateSession: { node, _ in killed.withValue { $0.append(node.id) } }, + onResolveTemplate: { templateID, _ in + self.storage.template(withID: templateID, projectPath: self.projectPath) + }) + await store.handle( + .createNode( + NodeDraft( + title: "Review, fix, verify", loopType: .composite, + subGraph: carried, + templateFollow: TemplateFollow(id: id, name: "Review, fix, verify")))) + let compositeID = await store.graph.nodes[0].id + let childrenBefore = await store.graph.nodes[0].subGraph?.nodes.map(\.id) + + await store.handle(.pilotComposite(compositeID)) + await store.handle(.pilotComposite(compositeID)) + + let childrenAfter = await store.graph.nodes[0].subGraph?.nodes.map(\.id) + #expect(childrenAfter == childrenBefore) + #expect(killed.value.isEmpty) + } + + /// And when the template's graph *has* changed, the outgoing children are torn + /// down rather than left running against a graph that no longer holds them. + @Test + func aChangedCompositeTemplateTearsDownTheOldChildren() async throws { + let id = UUID() + let first = LoopGraph( + project: ProjectRef(path: "review", name: "review"), + nodes: [LoopNode(title: "Reviewer v1", loopType: .goalBased, goal: GoalSpec(summary: "Find"))] + ) + try saveNightly( + PromptTemplate( + id: id, name: "Review, fix, verify", body: "Hand findings along.", shape: .composite, + settings: TemplateSettings(graphJSON: TemplateSettings.graphJSON(for: first)))) + + let killed = LockIsolated<[String]>([]) + let store = GraphStore( + onEnsureSession: { _, _ in }, + onTerminateSession: { node, _ in killed.withValue { $0.append(node.title) } }, + onResolveTemplate: { templateID, _ in + self.storage.template(withID: templateID, projectPath: self.projectPath) + }) + let stale = LoopGraph( + project: ProjectRef(path: "stale", name: "stale"), + nodes: [LoopNode(title: "Old child", loopType: .sketch)]) + await store.handle( + .createNode( + NodeDraft( + title: "Review, fix, verify", loopType: .composite, subGraph: stale, + templateFollow: TemplateFollow(id: id, name: "Review, fix, verify")))) + let compositeID = await store.graph.nodes[0].id + + await store.handle(.pilotComposite(compositeID)) + let graph = await store.graph + #expect(graph.nodes[0].subGraph?.nodes.map(\.title) == ["Reviewer v1"]) + #expect(killed.value.contains("Old child")) + } + + /// A composite template that carries no graph is unfinished, not missing: the file + /// was found, so the card must not warn that it wasn't. + @Test + func aCompositeTemplateWithNoGraphIsNotReportedMissing() async throws { + let id = UUID() + try saveNightly( + PromptTemplate( + id: id, name: "Empty orchestration", body: "Nothing carried yet.", shape: .composite)) + let store = makeStore() + let stale = LoopGraph( + project: ProjectRef(path: "stale", name: "stale"), + nodes: [LoopNode(title: "Mine", loopType: .sketch)]) + await store.handle( + .createNode( + NodeDraft( + title: "Empty orchestration", loopType: .composite, subGraph: stale, + templateFollow: TemplateFollow(id: id, name: "Empty orchestration")))) + let compositeID = await store.graph.nodes[0].id + + await store.handle(.pilotComposite(compositeID)) + let graph = await store.graph + #expect(graph.nodes[0].templateFollow?.missing == false) + #expect(graph.nodes[0].subGraph?.nodes.map(\.title) == ["Mine"]) + } + + /// The `missing` warning is a fact about the graph, so it has to reach the clients + /// watching it. The session sweeps are not commands and do not broadcast on their + /// own; without an explicit one the warning would sit in the daemon forever. + @Test + func aMissingTemplateIsBroadcastToClients() async throws { + let id = UUID() + try saveNightly( + PromptTemplate( + id: id, name: "Nightly", body: "Check dependencies.", shape: .timeBased, + settings: TemplateSettings(cadence: "daily"))) + let broadcasts = LockIsolated<[LoopGraph]>([]) + let store = GraphStore( + onGraphChanged: { graph in broadcasts.withValue { $0.append(graph) } }, + onEnsureSession: { _, _ in }, + onResolveTemplate: { templateID, _ in + self.storage.template(withID: templateID, projectPath: self.projectPath) + }) + await store.handle( + .createNode( + NodeDraft( + title: "Nightly", loopType: .timeBased, triggerPrompt: "/loop daily Check dependencies.", + templateFollow: TemplateFollow(id: id, name: "Nightly")))) + try FileManager.default.removeItem( + at: storage.homeDirectory.appendingPathComponent("nightly.md")) + + broadcasts.withValue { $0.removeAll() } + await store.ensureUnattendedSessions() + #expect(broadcasts.value.last?.nodes[0].templateFollow?.missing == true) + + // A sweep that changed nothing is not a write: the graph is persisted on every + // broadcast, and these run on a timer. + broadcasts.withValue { $0.removeAll() } + await store.ensureUnattendedSessions() + #expect(broadcasts.value.isEmpty) + } + + @Test + func aRefreshRefusesToMangleTheLoop() async throws { + // (resolvedForLaunch is the actor's own answer to "what would launch now") + // A body still carrying {tokens} is not a brief — the snapshot stands. + let id = UUID() + try saveNightly( + PromptTemplate( + id: id, name: "Nightly", body: "Check {area} every night.", + shape: .timeBased, settings: TemplateSettings(cadence: "daily"))) + let node = LoopNode( + title: "Nightly", loopType: .timeBased, triggerPrompt: "/loop daily Check dependencies.", + templateFollow: TemplateFollow(id: id, name: "Nightly")) + let store = makeStore() + let first = await store.resolvedForLaunch(node) + #expect(first.triggerPrompt == "/loop daily Check dependencies.") + + // A template that has since committed to a different shape cannot be what a + // timed loop follows — the snapshot stands there too. + try saveNightly( + PromptTemplate( + id: id, name: "Nightly", body: "Now a goal brief.", shape: .goalBased, + settings: TemplateSettings(cadence: "daily"))) + let second = await store.resolvedForLaunch(node) + #expect(second.triggerPrompt == "/loop daily Check dependencies.") + } + + @Test + func theRefreshCarriesTheStopAfterPromiseForward() async throws { + let id = UUID() + try saveNightly( + PromptTemplate( + id: id, name: "Nightly", body: "Check dependencies and triage.", + shape: .timeBased, settings: TemplateSettings(cadence: "daily"))) + let node = LoopNode( + title: "Nightly", loopType: .timeBased, + triggerPrompt: "/loop daily Check dependencies. Stop after 20 runs.", + templateFollow: TemplateFollow(id: id, name: "Nightly")) + let store = makeStore() + let resolved = await store.resolvedForLaunch(node) + #expect( + resolved.triggerPrompt + == "/loop daily Check dependencies and triage. Stop after 20 runs.") + } +} diff --git a/graphcode/Tests/TemplatePickerTests.swift b/graphcode/Tests/TemplatePickerTests.swift new file mode 100644 index 00000000..a5bf3867 --- /dev/null +++ b/graphcode/Tests/TemplatePickerTests.swift @@ -0,0 +1,207 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +/// The ⌘T picker and what it fills: grouping and sort order, search, and the +/// unfilled-token gate that keeps ⌘⏎ and Start honest (PROMPT_TEMPLATES.md § Tests). +@Suite +struct TemplatePickerTests { + private static let project = ProjectRef(path: "/tmp/template-picker", name: "picker") + + private func makeStore(_ templates: [PromptTemplate]) -> TestStoreOf { + TestStore( + initialState: ProjectFeature.State(graph: LoopGraph(project: Self.project)) + ) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in templates } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.templateLibrary.projectIsWritable = { _ in true } + $0.gitClient.listWorktrees = { _ in [] } + // A no-op orchestrator: any send that reaches it is asserted by the test + // that means it, and the rest must not record phantom issues. + $0.orchestratorClient.send = { _ in } + } + } + + private func homeTemplate( + _ name: String, body: String, shape: LoopType? = nil + ) -> PromptTemplate { + PromptTemplate(id: UUID(), name: name, body: body, shape: shape, origin: .home) + } + + @Test + @MainActor + func projectTemplatesSortAboveHomeOnesAndWithinByName() async { + let library = [ + homeTemplate("Zebra check", body: "zebra body"), + homeTemplate("Alpha check", body: "alpha body"), + PromptTemplate( + id: UUID(), name: "Aardvark review", body: "committed body", shape: .goalBased, + origin: .project(Self.project.path)), + ] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + // Opening the picker re-reads the library; the test makes the same moment + // deterministic by delivering the load itself. + await store.send(.templateLibraryChanged(library)) + // Grouped: the project's committed templates first, then the home ones — + // each group alphabetised by name. + let rows = store.state.templatePickerRows + #expect(rows.map(\.template.name) == ["Aardvark review", "Alpha check", "Zebra check"]) + #expect(rows[0].scope == .project) + #expect(rows[1].scope == .home) + #expect(rows[2].scope == .home) + #expect(store.state.templates.selectionIndex == 0) + } + + @Test + @MainActor + func searchMatchesNameAndBody() async { + let library = [ + homeTemplate("Review the diff", body: "Read every changed file against the style guide"), + homeTemplate("Nightly dependency review", body: "Check for updates worth taking"), + ] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateQueryChanged("style")) + // "style" appears in a body, not in a name — the brief is searchable too. + #expect(store.state.templatePickerRows.map(\.template.name) == ["Review the diff"]) + #expect(store.state.templates.selectionIndex == 0) + } + + @Test + @MainActor + func keyboardSelectionWalksBothGroups() async { + let library = [ + homeTemplate("First", body: "one"), + homeTemplate("Second", body: "two"), + homeTemplate("Third", body: "three"), + ] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateSelectionMoved(1)) + #expect(store.state.templates.selectionIndex == 1) + await store.send(.templateSelectionMoved(5)) + // The last row is the furthest ↓ goes. + #expect(store.state.templates.selectionIndex == 2) + await store.send(.templateSelectionMoved(-9)) + // ↑ from the top stays at the top rather than wrapping into a trap. + #expect(store.state.templates.selectionIndex == 0) + } + + @Test + @MainActor + func fillingSetsTheBriefAndFocusesIt() async { + let template = PromptTemplate( + id: UUID(), name: "Trace a symbol", body: "Trace {symbol} through the codebase.", + shape: nil, origin: .home) + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + #expect(store.state.draftSketchNote == "Trace {symbol} through the codebase.") + #expect(store.state.templates.requestsPrimaryFocus) + #expect(store.state.unfilledTokens == ["symbol"]) + #expect(store.state.draftBlocksOnTokens) + #expect(store.state.templates.applied?.name == "Trace a symbol") + // The form was sitting on Goal; a shapeless template means Main, and the + // switch it made is a shape it set — so Undo can put the form back. + #expect(store.state.draftLoopType == .sketch) + #expect(store.state.templates.applied?.setFields == [.shape, .brief]) + } + + /// ⌘⏎ is "start now", and a brief with a hole in it is not one: with an unfilled + /// token the launch degrades to the fill, and nothing is created. + @Test + @MainActor + func launchWithAnUnfilledTokenOnlyFills() async { + let template = PromptTemplate( + id: UUID(), name: "Trace a symbol", body: "Trace {symbol} through the codebase.", + shape: nil, origin: .home) + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + store.dependencies.orchestratorClient.send = { _ in + Issue.record("nothing should be created while a token is unfilled") + return () + } + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateLaunched(template.id)) + #expect(store.state.draftSketchNote == "Trace {symbol} through the codebase.") + #expect(!store.state.showingNewNodeForm) + } + + /// ⌘⏎ with nothing left to fill starts the loop immediately — the whole point + /// of the shortcut. + @Test + @MainActor + func launchWithNothingUnfilledStartsImmediately() async { + let sent = LockIsolated<[GraphCommand]>([]) + let template = PromptTemplate( + id: UUID(), name: "Trace a symbol", body: "Trace it through the codebase.", + shape: nil, origin: .home) + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + store.dependencies.orchestratorClient.send = { command in + if case .graphCommand(_, let inner) = command { + sent.withValue { + if case .createNode = inner { $0.append(inner) } + } + } + return () + } + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateLaunched(template.id)) + #expect(sent.value.count == 1) + #expect(!store.state.showingNewNodeForm) + } + + @Test + @MainActor + func theTypeLabelNamesTheShapeAndItsQualifier() { + let timed = PromptTemplate( + id: UUID(), name: "Nightly", body: "Check dependencies.", + shape: .timeBased, settings: TemplateSettings(cadence: "daily"), origin: .home) + let composite = PromptTemplate( + id: UUID(), name: "Pipeline", body: "Hand work along.", shape: .composite, + settings: TemplateSettings( + graphJSON: TemplateSettings.graphJSON( + for: LoopGraph( + project: ProjectRef(path: "sub", name: "sub"), + nodes: [ + LoopNode(title: "A"), LoopNode(title: "B"), LoopNode(title: "C"), + ]))), + origin: .home) + let main = PromptTemplate( + id: UUID(), name: "Poke", body: "Where does this live?", origin: .home) + + #expect( + ProjectFeature.TemplatePickerRow(template: timed, scope: .home).typeLabel == "Timed · daily") + #expect( + ProjectFeature.TemplatePickerRow(template: composite, scope: .home).typeLabel + == "Composite · 3 loops") + #expect(ProjectFeature.TemplatePickerRow(template: main, scope: .home).typeLabel == "Main") + #expect(timed.settingsSummary == ["runs every daily"]) + #expect(main.settingsSummary.isEmpty) + } +} diff --git a/graphcode/Tests/TemplateSaveTests.swift b/graphcode/Tests/TemplateSaveTests.swift new file mode 100644 index 00000000..cb4911b2 --- /dev/null +++ b/graphcode/Tests/TemplateSaveTests.swift @@ -0,0 +1,299 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +/// Save-as-template: what each entry point captures, where the file lands, and the +/// quiet line afterwards (PROMPT_TEMPLATES.md § Save as template, § Storage). +/// Applying lives next door in `TemplateApplyTests`. +@Suite +struct TemplateSaveTests { + private static let project = ProjectRef(path: "/tmp/template-save", name: "save") + + private func makeStore( + _ templates: [PromptTemplate] = [], loopType: LoopType = .sketch + ) -> TestStoreOf { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + state.draftLoopType = loopType + return TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in templates } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.templateLibrary.projectIsWritable = { _ in true } + $0.gitClient.listWorktrees = { _ in [] } + $0.orchestratorClient.send = { _ in } + } + } + + /// Saving from the dialog offers the human's token values back as tokens, not + /// baked-in literals (PROMPT_TEMPLATES.md § Save as template). + @Test + @MainActor + func savingFromTheDialogOffersTypedValuesBackAsTokens() async throws { + let template = PromptTemplate( + id: UUID(), name: "Review the branch", body: "Review the changes for {branch}.", + shape: .goalBased, origin: .home) + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + // The human fills the token by typing over it. + await store.send(.binding(.set(\.draftGoal, "Review the changes for feat/x."))) + await store.send(.saveTemplateTapped) + let context = try #require(store.state.templates.pendingSave) + // The saved file asks the question again instead of hard-coding this branch. + #expect(context.template.body == "Review the changes for {branch}.") + #expect(context.name == "Review the branch") + } + + /// Text that isn't a clean fill of the template's tokens is saved as written — + /// recovery must never guess. + @Test + @MainActor + func savingRewrittenTextKeepsItAsWritten() async throws { + let template = PromptTemplate( + id: UUID(), name: "Review the branch", body: "Review the changes for {branch}.", + shape: .goalBased, origin: .home) + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + await store.send(.binding(.set(\.draftGoal, "Audit the whole diff history instead."))) + await store.send(.saveTemplateTapped) + let context = try #require(store.state.templates.pendingSave) + #expect(context.template.body == "Audit the whole diff history instead.") + } + + @Test + @MainActor + func aCardSaveCapturesTheShapeAndItsSettings() async throws { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + let node = LoopNode( + title: "Review the diff", loopType: .goalBased, + goal: GoalSpec( + summary: "Read every changed file", predicate: "make test", + metricCommand: "./scripts/score.sh"), + backend: .copilotCLI) + state.graph.nodes.append(node) + let store = TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in [] } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.templateLibrary.projectIsWritable = { _ in true } + $0.gitClient.listWorktrees = { _ in [] } + } + store.exhaustivity = .off + + await store.send(.saveLoopTemplateTapped(node.id)) + let context = try #require(store.state.templates.pendingSave) + #expect(context.name == "Review the diff") + #expect(context.template.shape == .goalBased) + #expect(context.template.body == "Read every changed file") + #expect(context.template.settings?.doneCheck == "make test") + #expect(context.template.settings?.metric == "./scripts/score.sh") + // Carried because it differs from the default — a loop that deliberately runs on + // another agent is a fact about the brief. + #expect(context.template.settings?.backend == .copilotCLI) + #expect(context.scope == .home) + } + + @Test + @MainActor + func aMainCardSaveCapturesTextOnly() async throws { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + let node = LoopNode( + title: "Where does this live?", loopType: .sketch, firstInstruction: "Find the cap.") + state.graph.nodes.append(node) + let store = TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in [] } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.templateLibrary.projectIsWritable = { _ in true } + $0.gitClient.listWorktrees = { _ in [] } + } + store.exhaustivity = .off + + await store.send(.saveLoopTemplateTapped(node.id)) + let context = try #require(store.state.templates.pendingSave) + #expect(context.template.shape == nil) + #expect(context.template.body == "Find the cap.") + // The agent is only carried when the template *names* one. This loop runs on the + // app's own default, so the template says nothing and whoever uses it keeps + // theirs — see `TemplateSettings.backend`. + #expect(context.template.settings?.backend == nil) + #expect(context.template.settings?.doneCheck == nil) + } + + @Test + @MainActor + func aTimedCardSaveCapturesItsCadence() async throws { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + let node = LoopNode( + title: "Nightly dependency review", loopType: .timeBased, + triggerPrompt: "/loop daily Check for updates worth taking") + state.graph.nodes.append(node) + let store = TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in [] } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.templateLibrary.projectIsWritable = { _ in true } + $0.gitClient.listWorktrees = { _ in [] } + } + store.exhaustivity = .off + + await store.send(.saveLoopTemplateTapped(node.id)) + let context = try #require(store.state.templates.pendingSave) + #expect(context.template.shape == .timeBased) + #expect(context.template.body == "Check for updates worth taking") + #expect(context.template.settings?.cadence == "daily") + } + + @Test + @MainActor + func savingLandsHomeAndOffersTheProject() async throws { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + let template = PromptTemplate( + id: UUID(), name: "Review the diff", body: "Read the diff.", shape: .goalBased, + settings: TemplateSettings(doneCheck: "make test"), origin: .home) + state.templates.pendingSave = ProjectFeature.TemplateSaveContext( + name: "Review the diff", scope: .home, template: template, projectCanSave: true) + let store = TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in [] } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.templateLibrary.projectIsWritable = { _ in true } + $0.templateLibrary.save = { saved, origin, _ in + #expect(origin == .home) + var landed = saved + landed.origin = origin + return (landed, origin) + } + $0.gitClient.listWorktrees = { _ in [] } + } + + let savedBox = LockIsolated(nil) + store.dependencies.templateLibrary.save = { saved, origin, _ in + #expect(origin == .home) + savedBox.withValue { $0 = saved.name } + var landed = saved + landed.origin = origin + return (landed, origin) + } + + store.exhaustivity = .off + await store.send(.saveTemplateConfirmed) + #expect(await savedBox.value == "Review the diff") + // The save's effect reports what landed — receive is how this suite waits + // for an effect's actions. + await store.receive(\.templateLibraryChanged) + await store.receive(\.templateSaved) + let notice = try #require(store.state.templates.saveNotice) + #expect(notice.landedInProject == false) + #expect(notice.otherOffer == "Put it in the project instead") + #expect(store.state.templates.pendingSave == nil) + } + + /// Renaming in the save sheet has to reach the filename too — a template saved as + /// "Nightly sweep" must not land in `untitled.md`. + @Test + @MainActor + func renamingInTheSheetLandsUnderTheNewFilename() async throws { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + state.templates.pendingSave = ProjectFeature.TemplateSaveContext( + name: "Untitled", scope: .home, + template: PromptTemplate(name: "Untitled", body: "Read the diff."), projectCanSave: false) + let store = TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in [] } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.gitClient.listWorktrees = { _ in [] } + } + let written = LockIsolated(nil) + store.dependencies.templateLibrary.save = { saved, origin, _ in + written.withValue { $0 = saved.fileName } + var landed = saved + landed.origin = origin + return (landed, origin) + } + store.exhaustivity = .off + + await store.send( + .binding( + .set( + \.templates.pendingSave, + { + var context = state.templates.pendingSave! + context.name = "Nightly sweep" + return context + }()))) + await store.send(.saveTemplateConfirmed) + #expect(written.value == "nightly-sweep.md") + } + + /// The quiet line says where the file *is*, which is not always where it was asked + /// to go: a project that can't take one sends the template home. + @Test + @MainActor + func relocationReportsWhereItActuallyLanded() async throws { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + let template = PromptTemplate(name: "Review the diff", body: "Read the diff.") + state.templates.saveNotice = ProjectFeature.TemplateSaveNotice( + template: template, landedInProject: false) + let store = TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in [] } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.gitClient.listWorktrees = { _ in [] } + // The project refused it; the move fell back to home and says so. + $0.templateLibrary.move = { template, _, _ in + var landed = template + landed.origin = .home + return landed + } + } + store.exhaustivity = .off + + await store.send(.templateRelocationTapped) + await store.receive(\.templateLibraryChanged) + await store.receive(\.templateSaved) + let notice = try #require(store.state.templates.saveNotice) + #expect(notice.landedInProject == false) + #expect(notice.otherOffer == "Put it in the project instead") + } + + /// The quiet line is quiet, not permanent — it shares the footer with the reason + /// Create is disabled. + @Test + @MainActor + func theSaveNoticeCanBeDismissed() async { + var state = ProjectFeature.State(graph: LoopGraph(project: Self.project)) + state.templates.saveNotice = ProjectFeature.TemplateSaveNotice( + template: PromptTemplate(name: "x", body: "y"), landedInProject: false) + let store = TestStore(initialState: state) { + ProjectFeature() + } withDependencies: { + $0.templateLibrary.load = { _ in [] } + $0.templateLibrary.watch = { _ in AsyncStream { $0.finish() } } + $0.gitClient.listWorktrees = { _ in [] } + } + store.exhaustivity = .off + await store.send(.templateSaveNoticeDismissed) + #expect(store.state.templates.saveNotice == nil) + } + +} diff --git a/graphcode/Tests/TemplateStorageTests.swift b/graphcode/Tests/TemplateStorageTests.swift new file mode 100644 index 00000000..e1c0f4c3 --- /dev/null +++ b/graphcode/Tests/TemplateStorageTests.swift @@ -0,0 +1,358 @@ +import Foundation +import GraphcodeKit +import Testing + +/// Where templates live, how they are read back, and the two rules the design's +/// Storage section hangs everything on: **home is where the app writes by default; +/// the project is also read, and a project file with the same name wins.** +/// +/// Every path here is injected into a scratch directory — the real `~/.graphcode` +/// is never touched, for the same reason `SupportDirectoryTests` exists at all. +@Suite +struct TemplateStorageTests { + private let home: URL + private let projectPath: String + + init() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("template-storage-tests-\(UUID().uuidString)", isDirectory: true) + home = root.appendingPathComponent("home", isDirectory: true) + projectPath = root.appendingPathComponent("repo", isDirectory: true).path + try? FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try? FileManager.default.createDirectory( + at: URL(fileURLWithPath: projectPath, isDirectory: true), withIntermediateDirectories: true) + } + + private var storage: TemplateStorage { + TemplateStorage( + homeDirectory: home, + projectDirectory: { path in + URL(fileURLWithPath: path, isDirectory: true) + .appendingPathComponent(".graphcode", isDirectory: true) + .appendingPathComponent("templates", isDirectory: true) + }) + } + + private var projectTemplatesURL: URL { + storage.projectDirectory(projectPath) + } + + private func write(_ text: String, to url: URL) { + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try? text.write(to: url, atomically: true, encoding: .utf8) + } + + private func goalTemplate(_ name: String, body: String = "Read every changed file for {branch}.") + -> PromptTemplate + { + PromptTemplate( + id: UUID(), name: name, body: body, shape: .goalBased, + settings: TemplateSettings(doneCheck: "make test"), origin: .home) + } + + // MARK: - Saving + + @Test + func homeIsTheDefaultWriteTarget() throws { + let (_, origin) = try storage.save( + goalTemplate("review-diff"), to: .home, projectPath: projectPath) + #expect(origin == .home) + #expect( + FileManager.default.fileExists( + atPath: home.appendingPathComponent("review-diff.md").path)) + #expect( + !FileManager.default.fileExists( + atPath: projectTemplatesURL.appendingPathComponent("review-diff.md").path)) + } + + @Test + func projectSaveWritesOnlyWhereItWasAsked() throws { + let (_, origin) = try storage.save( + goalTemplate("review-diff"), to: .project(projectPath), projectPath: projectPath) + #expect(origin == .project(projectPath)) + #expect( + FileManager.default.fileExists( + atPath: projectTemplatesURL.appendingPathComponent("review-diff.md").path)) + } + + /// A read-only checkout falls back to home rather than losing the save — the + /// rationale in the design is that plenty of repos will not take a new dotfolder, + /// and the app must be fully usable with home alone. + @Test + func aReadOnlyProjectFolderFallsBackToHome() throws { + let directory = projectTemplatesURL + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + guard setReadOnly(directory) else { + Issue.record("could not make the scratch folder read-only on this platform") + return + } + defer { setWritable(directory) } + + let (_, origin) = try storage.save( + goalTemplate("review-diff"), to: .project(projectPath), projectPath: projectPath) + #expect(origin == .home) + #expect( + FileManager.default.fileExists( + atPath: home.appendingPathComponent("review-diff.md").path)) + } + + private func setReadOnly(_ url: URL) -> Bool { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o555], ofItemAtPath: url.path) + return !FileManager.default.isWritableFile(atPath: url.path) + } + + private func setWritable(_ url: URL) { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + /// The relocation that isn't one. "Put it in the project instead" against a folder + /// that can't take a `.graphcode/templates` resolves back to home — which is where + /// the file already is. Writing the copy and then deleting "the original" would + /// delete the only copy, so the move has to notice it is a no-op. + @Test + func relocatingIntoAnUnwritableProjectKeepsTheTemplate() throws { + let (saved, _) = try storage.save( + goalTemplate("review-diff"), to: .home, projectPath: projectPath) + let home = self.home.appendingPathComponent("review-diff.md") + #expect(FileManager.default.fileExists(atPath: home.path)) + + let unwritable = URL(fileURLWithPath: "/System/Library/graphcode-not-a-real-project").path + let landed = try storage.move(saved, to: .project(unwritable), projectPath: unwritable) + + #expect(landed.origin == .home) + #expect(FileManager.default.fileExists(atPath: home.path)) + #expect(storage.load(projectPath: nil).map(\.name) == ["review-diff"]) + } + + /// The relocation that is one: home → project moves the file and says so. + @Test + func relocatingIntoAWritableProjectMovesTheFile() throws { + let (saved, _) = try storage.save( + goalTemplate("review-diff"), to: .home, projectPath: projectPath) + let landed = try storage.move(saved, to: .project(projectPath), projectPath: projectPath) + + #expect(landed.origin == .project(projectPath)) + #expect( + FileManager.default.fileExists( + atPath: projectTemplatesURL.appendingPathComponent("review-diff.md").path)) + #expect( + !FileManager.default.fileExists( + atPath: home.appendingPathComponent("review-diff.md").path)) + } + + /// Two different briefs that happen to share a name are two templates. The second + /// save takes the next free filename rather than writing over the first. + @Test + func aNameCollisionTakesTheNextFilenameRatherThanOverwriting() throws { + let (first, _) = try storage.save( + PromptTemplate(name: "Review the diff", body: "The first one."), to: .home, + projectPath: nil) + let (second, _) = try storage.save( + PromptTemplate(name: "Review the diff", body: "A different one."), to: .home, + projectPath: nil) + + #expect(first.fileName == "review-the-diff.md") + #expect(second.fileName == "review-the-diff-2.md") + let bodies = Set(storage.load(projectPath: nil).map(\.body)) + #expect(bodies == ["The first one.", "A different one."]) + } + + /// Asking whether a project can take a template must not put a `.graphcode` in + /// somebody's checkout: nothing lands there by default (§ Storage). + @Test + func askingWhetherTheProjectIsWritableCreatesNothing() { + #expect(storage.canWrite(to: projectTemplatesURL)) + #expect(!FileManager.default.fileExists(atPath: projectTemplatesURL.path)) + #expect( + !FileManager.default.fileExists( + atPath: URL(fileURLWithPath: projectPath).appendingPathComponent(".graphcode").path)) + } + + @Test + func aRenamedTemplateTakesItsNewFilename() { + let template = PromptTemplate(name: "Review the diff", body: "Read it.") + #expect(template.fileName == "review-the-diff.md") + #expect(template.renamed(to: "Nightly sweep").fileName == "nightly-sweep.md") + #expect(template.renamed(to: "Nightly sweep").name == "Nightly sweep") + } + + /// The picker's second line is a sentence, and a command with dots in it is not a + /// sentence boundary. + @Test + func theSummaryLineIsTheFirstSentence() { + let template = PromptTemplate( + name: "x", body: "Read every changed file. Then list what must change.") + #expect(template.summaryLine == "Read every changed file.") + #expect( + PromptTemplate(name: "x", body: "Run ./scripts/score.sh and report the number.") + .summaryLine == "Run ./scripts/score.sh and report the number.") + } + + /// A value that already looks quoted has to survive the trip — the reader strips + /// the outer quotes, so the writer has to add its own. + @Test + func aValueThatLooksQuotedRoundTrips() throws { + let template = PromptTemplate( + name: "x", body: "body", shape: .goalBased, + settings: TemplateSettings(doneCheck: "\"make test\"")) + let parsed = try #require( + TemplateFileCodec.decode(TemplateFileCodec.encode(template), origin: .home)) + #expect(parsed.settings?.doneCheck == "\"make test\"") + } + + // MARK: - Reading + + @Test + func projectTemplatesSortAboveHomeOnes() { + write( + "---\nname: Committed brief\nshape: goal\n---\n\nFrom the project folder.", + to: projectTemplatesURL.appendingPathComponent("committed.md")) + write( + "---\nname: Personal brief\nshape: goal\n---\n\nFrom home.", + to: home.appendingPathComponent("personal.md")) + + let loaded = storage.load(projectPath: projectPath) + #expect(loaded.map(\.name) == ["Committed brief", "Personal brief"]) + #expect(loaded[0].origin.isProject) + #expect(loaded[1].origin == .home) + } + + /// The collision rule: same filename, project wins. A team's committed version + /// outranks a personal one — the person can still see theirs by renaming, and the + /// shared one is the one everyone gets. + @Test + func theProjectCopyWinsOnFilenameCollision() { + write( + "---\nname: Committed brief\nshape: goal\n---\n\nTeam's version.", + to: projectTemplatesURL.appendingPathComponent("review.md")) + write( + "---\nname: Personal brief\nshape: goal\n---\n\nMine.", + to: home.appendingPathComponent("review.md")) + + let loaded = storage.load(projectPath: projectPath) + #expect(loaded.count == 1) + #expect(loaded[0].body == "Team's version.") + #expect(loaded[0].origin.isProject) + } + + /// A project with no template folder is normal — most repos never get one — and + /// the library is then exactly home's. + @Test + func aProjectWithoutTemplatesReadsHomeAlone() { + write( + "---\nname: Personal brief\nshape: goal\n---\n\nFrom home.", + to: home.appendingPathComponent("personal.md")) + #expect(storage.load(projectPath: projectPath).map(\.name) == ["Personal brief"]) + } + + @Test + func aTemplateCanBeFoundByItsIdAfterARename() { + let id = UUID() + write( + "---\nid: \(id.uuidString)\nname: Nightly review\nshape: timed\ncadence: daily\n---\n\n" + + "Check for dependency updates worth taking.", + to: projectTemplatesURL.appendingPathComponent("nightly-review.md")) + #expect(storage.template(withID: id, projectPath: projectPath)?.name == "Nightly review") + } + + /// A half-written or hand-mangled file is skipped, not fatal: one bad file must + /// not take the whole library down. + @Test + func anUnreadableFileIsSkippedNotFatal() { + write( + "---\nname: Broken template\nshape: goal\n", // front matter never closes + to: home.appendingPathComponent("broken.md")) + write( + "---\nname: Good template\nshape: goal\n---\n\nDo the work.", + to: home.appendingPathComponent("good.md")) + let loaded = storage.load(projectPath: nil) + #expect(loaded.map(\.name) == ["Good template"]) + } + + // MARK: - Codec + + @Test + func theFileRoundTripsThroughItsCodec() throws { + let template = PromptTemplate( + id: UUID(), name: "Review the diff", body: "Check {branch} against the style guide.", + shape: .goalBased, + settings: TemplateSettings( + backend: .claudeCode, doneCheck: "make test", cadence: nil, pausesBeforeWritesOnly: nil, + branch: "review/patch", metric: "make score"), + origin: .home) + + let text = TemplateFileCodec.encode(template) + let parsed = TemplateFileCodec.decode(text, origin: .home) + + let roundTripped = try #require(parsed) + #expect(roundTripped.id == template.id) + #expect(roundTripped.name == template.name) + #expect(roundTripped.body == template.body) + #expect(roundTripped.shape == template.shape) + #expect(roundTripped.settings == template.settings) + #expect(roundTripped.tokens == ["branch"]) + } + + /// The shape is written with the human word and read back from either that or + /// the raw value — both have shipped, and a hand-edited file should load. + @Test + func shapeWordsAreReadBackEitherWay() { + #expect(TemplateShapeWord.parse("goal") == .goalBased) + #expect(TemplateShapeWord.parse("timed") == .timeBased) + #expect(TemplateShapeWord.parse("timeBased") == .timeBased) + #expect(TemplateShapeWord.parse("main") == .sketch) + #expect(TemplateShapeWord.parse("composite") == .composite) + #expect(TemplateShapeWord.parse("nonsense") == nil) + } + + /// A file that is only a prompt is a complete template — front matter is for the + /// shape, not the text. + @Test + func aBarePromptFileLoadsAsMain() throws { + let parsed = try #require( + TemplateFileCodec.decode( + "Trace a symbol through the codebase and report every reader and writer.", + origin: .home)) + #expect(parsed.shape == nil) + #expect(parsed.name.hasPrefix("Trace a symbol through the codebase and report")) + #expect(parsed.body.contains("Trace a symbol")) + } + + /// A composite template carries its orchestration — the graph in front matter + /// decodes back to the loops and edges it shipped with. + @Test + func aCompositeTemplateCarriesItsGraph() throws { + let child = LoopNode( + title: "Reviewer", loopType: .goalBased, goal: GoalSpec(summary: "Find issues")) + let graph = LoopGraph( + project: ProjectRef(path: "review-subgraph", name: "review"), + nodes: [child]) + let json = try #require(TemplateSettings.graphJSON(for: graph)) + + let text = """ + --- + id: \(UUID().uuidString) + name: Review, fix, verify + shape: composite + graph: \(json) + --- + + A reviewer hands findings to a fixer, which hands the build to a verifier. + """ + let parsed = try #require(TemplateFileCodec.decode(text, origin: .home)) + let carried = try #require(parsed.settings?.carriedGraph) + #expect(carried.nodes.map(\.title) == ["Reviewer"]) + #expect(carried.nodes[0].loopType == .goalBased) + } + + @Test + func tokensAreExtractedInOrderOfFirstAppearance() { + #expect( + PromptTemplate.tokens(in: "Port {branch} for {branch} and {ticket}. Plain {not a token}.") + == ["branch", "ticket"]) + #expect(PromptTemplate.tokens(in: "No tokens here.").isEmpty) + } +} From dfdbe7fd8e8e6a2f39aa1fcf29de6e1dcdf7b73f Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 08:18:29 -0700 Subject: [PATCH 2/5] =?UTF-8?q?Match=20the=20v4=20design=20canvas:=20?= =?UTF-8?q?=E2=87=A5=20token=20navigation=20and=20the=20applied-state=20co?= =?UTF-8?q?py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked the implementation against `GraphCode New Loop v4.dc.html` (and README.md § 3 / MAIN_LOOP.md) rather than PROMPT_TEMPLATES.md alone. The markdown is terse where the canvas is exact, and three things were off. - **`⇥` now walks the unfilled tokens.** The spec says tokens "are ⇥-navigable" and the canvas prints "One token left to fill · ⇥ to jump to it"; nothing implemented it, so the promise was going to be a lie. Tab cycles the fields still holding a `{token}` in form order — brief, done check, metric, branch — and is swallowed only while one is unfilled, so it stays the ordinary focus walk everywhere else. The focus request is per-field and consumed by whichever field answers it. - **The applied strip says what the canvas says.** One shared verb ("sets a done check and a worktree", not "sets … and sets …"), and the sentence finishes with the design's own "Undo the shape to keep just the prompt." - **`Detach` sits beside the Follows chip on the card**, which is where the canvas puts it — the fact and the way out of it belong together. It stays in the context menu too. README § 3 confirms the Goal form's labels are "What does done look like?" and "Done check"; the canvas's "Prompt" / "Done when" in the applied artboard is the designer's shorthand, not a field rename, so those are left alone. 1348 tests (up from 1345). swiftlint 0 errors, swift-format clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJR8Wuwc4qnaSBUMcYf7Af --- .../Features/Canvas/LoopCardView.swift | 17 ++++- .../Features/Project/NodeDraftFields.swift | 72 +++++++++++++++---- .../Features/Project/NodeDraftForm.swift | 38 ++++++---- .../Project/NodeDraftTypeFields.swift | 18 +++-- .../Features/Project/ProjectCanvasCards.swift | 4 +- .../Project/ProjectFeature+Templates.swift | 22 +++++- .../Features/Project/ProjectFeature.swift | 13 ++-- .../Project/ProjectFeatureState.swift | 40 ++++++++++- graphcode/Tests/TemplateApplyTests.swift | 67 +++++++++++++++++ graphcode/Tests/TemplatePickerTests.swift | 2 +- 10 files changed, 246 insertions(+), 47 deletions(-) diff --git a/graphcode/Sources/Features/Canvas/LoopCardView.swift b/graphcode/Sources/Features/Canvas/LoopCardView.swift index 5d3ba296..36b3d515 100644 --- a/graphcode/Sources/Features/Canvas/LoopCardView.swift +++ b/graphcode/Sources/Features/Canvas/LoopCardView.swift @@ -35,6 +35,9 @@ struct LoopCardView: View { var reclaimOffer: WorktreeAssessment? var onReclaim: (() -> Void)? var onKeep: (() -> Void)? + /// Set where the card can act on a template follow — `Detach` sits beside the + /// Follows chip (PROMPT_TEMPLATES.md § Follow vs snapshot). + var onDetachTemplate: (() -> Void)? enum Metrics { static let size = CGSize(width: 250, height: 106) @@ -231,7 +234,9 @@ struct LoopCardView: View { .lineLimit(1) .truncationMode(.middle) if let follow = node.templateFollow { - FollowsChip(follow: follow) + // The design puts `Detach` right beside the chip, not only in the context + // menu: the fact and the way out of it belong together. + FollowsChip(follow: follow, detach: onDetachTemplate) } if isRemote { Image(systemName: "network").font(.system(size: 9)).foregroundStyle(.white.opacity(0.4)) @@ -251,6 +256,9 @@ struct LoopCardView: View { /// not a sixth kind of it. struct FollowsChip: View { let follow: TemplateFollow + /// `nil` where the card can't act — the overview's read-only rows draw the fact + /// without the affordance. + var detach: (() -> Void)? var body: some View { HStack(spacing: 4) { @@ -261,6 +269,13 @@ struct FollowsChip: View { .font(.system(size: 10, design: .monospaced)) .foregroundStyle(missing ? Color(red: 1.0, green: 0.804, blue: 0.478) : .white.opacity(0.6)) .lineLimit(1) + if let detach { + Button("Detach", action: detach) + .buttonStyle(.plain) + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9)) + .help("Stop reading the template — the brief it has now becomes its own") + } } .padding(.vertical, 1) .padding(.horizontal, 6) diff --git a/graphcode/Sources/Features/Project/NodeDraftFields.swift b/graphcode/Sources/Features/Project/NodeDraftFields.swift index 384f8ad1..6dec1372 100644 --- a/graphcode/Sources/Features/Project/NodeDraftFields.swift +++ b/graphcode/Sources/Features/Project/NodeDraftFields.swift @@ -1,3 +1,4 @@ +import ComposableArchitecture import SwiftUI /// The dialog's field pattern: label above, field full width, help below. @@ -90,9 +91,13 @@ struct DraftTextField: View { let placeholder: String @Binding var text: String var isMono = false - /// The picker's ⏎ asks the brief's field to take focus; the field consumes the - /// request by clearing it, so exactly one field answers. + /// The picker's ⏎ asks the brief's field to take focus, and `⇥` asks whichever + /// field holds the next unfilled `{token}`; the field consumes the request by + /// clearing it, so exactly one field answers. var takesFocusRequest: Binding? = nil + /// `⇥` while a token is still unfilled. Answering `true` swallows the key, so the + /// jump replaces the ordinary focus walk rather than fighting it. + var onTokenJump: (() -> Bool)? = nil @FocusState private var isFocused: Bool @@ -102,12 +107,8 @@ struct DraftTextField: View { .font(.system(size: isMono ? 12 : 13, design: isMono ? .monospaced : .default)) .focused($isFocused) .draftFieldBox(isFocused: isFocused) - .onAppear { - if let takesFocusRequest, takesFocusRequest.wrappedValue { - isFocused = true - takesFocusRequest.wrappedValue = false - } - } + .onKeyPress(.tab) { onTokenJump?() == true ? .handled : .ignored } + .claimingFocus(when: takesFocusRequest, focus: $isFocused) } } @@ -118,6 +119,8 @@ struct DraftProseField: View { @Binding var text: String /// See `DraftTextField.takesFocusRequest` — the brief is where ⏎ lands the human. var takesFocusRequest: Binding? = nil + /// See `DraftTextField.onTokenJump`. + var onTokenJump: (() -> Bool)? = nil @FocusState private var isFocused: Bool @@ -128,12 +131,28 @@ struct DraftProseField: View { .font(.system(size: 13)) .focused($isFocused) .draftFieldBox(isFocused: isFocused, minHeight: 54) - .onAppear { - if let takesFocusRequest, takesFocusRequest.wrappedValue { - isFocused = true - takesFocusRequest.wrappedValue = false - } - } + .onKeyPress(.tab) { onTokenJump?() == true ? .handled : .ignored } + .claimingFocus(when: takesFocusRequest, focus: $isFocused) + } +} + +extension View { + /// Takes focus whenever the request flips true — on appear *and* while already on + /// screen, which is what makes `⇥` able to move between two visible fields — and + /// clears the request so exactly one field answers it. + fileprivate func claimingFocus( + when request: Binding?, focus: FocusState.Binding + ) -> some View { + onAppear { + guard let request, request.wrappedValue else { return } + focus.wrappedValue = true + request.wrappedValue = false + } + .onChange(of: request?.wrappedValue ?? false) { _, wants in + guard wants, let request else { return } + focus.wrappedValue = true + request.wrappedValue = false + } } } @@ -179,3 +198,28 @@ struct DraftWarningNote: View { } } } + +/// The two things the draft's text fields need from the store to make `⇥` walk the +/// unfilled `{token}`s: which field is being asked for focus, and what to do when the +/// key is pressed. Bindings rather than plain values because a field *consumes* its +/// request — exactly one answers each jump. +extension StoreOf { + func templateFocus(_ field: ProjectFeature.TemplateTokenField) -> Binding { + Binding( + get: { self.templates.focusRequest == field }, + set: { stillWanted in + guard !stillWanted, self.templates.focusRequest == field else { return } + self.send(.templateFocusConsumed) + }) + } + + /// `true` when the key was ours to take: only while a token is actually unfilled, + /// so ⇥ is the ordinary focus walk in every other state. + var tokenJump: () -> Bool { + { + guard self.draftBlocksOnTokens else { return false } + self.send(.templateTokenJumpRequested) + return true + } + } +} diff --git a/graphcode/Sources/Features/Project/NodeDraftForm.swift b/graphcode/Sources/Features/Project/NodeDraftForm.swift index b13e9ec6..bf116cad 100644 --- a/graphcode/Sources/Features/Project/NodeDraftForm.swift +++ b/graphcode/Sources/Features/Project/NodeDraftForm.swift @@ -157,11 +157,14 @@ struct NodeDraftForm: View { .buttonStyle(.plain) .font(.system(size: 11.5, weight: .semibold)) .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9)) + Text("to keep just the prompt.") + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.75)) } } - if !store.unfilledTokens.isEmpty { + if let prompt = store.unfilledTokenPrompt { HStack(spacing: 5) { - Text("Fill in:") + Text(prompt) .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.55)) ForEach(store.unfilledTokens, id: \.self) { token in @@ -185,25 +188,32 @@ struct NodeDraftForm: View { } } - /// The shape in words, exactly the strip's promise: what type it made the loop - /// and what it set. "Undo the shape to keep just the prompt" rides on it. + /// The shape in words, exactly the strip's promise: "This template makes it a + /// **Goal** loop and sets a done check and a worktree." One shared verb — the + /// design's sentence says "sets a done check and a worktree", not "sets … and + /// sets …". private func shapeSentence(_ applied: ProjectFeature.AppliedTemplate) -> AttributedString { var text = AttributedString("This template") if applied.setFields.contains(.shape) || applied.shape != nil { text += AttributedString(" makes it a \((applied.shape ?? .sketch).displayName) loop") } - let settings: [String] = [ - applied.setFields.contains(.doneCheck) ? "sets a done check" : nil, - applied.setFields.contains(.cadence) ? "sets a cadence" : nil, - applied.setFields.contains(.pausesBeforeWritesOnly) ? "pauses only before writes" : nil, - applied.setFields.contains(.branch) ? "sets a worktree" : nil, - applied.setFields.contains(.metric) ? "tracks a metric" : nil, - applied.setFields.contains(.backend) ? "names the agent" : nil, - applied.setFields.contains(.subGraph) ? "carries its loops" : nil, + let sets: [String] = [ + applied.setFields.contains(.doneCheck) ? "a done check" : nil, + applied.setFields.contains(.cadence) ? "a cadence" : nil, + applied.setFields.contains(.branch) ? "a worktree" : nil, + applied.setFields.contains(.metric) ? "a metric" : nil, + applied.setFields.contains(.backend) ? "the agent" : nil, + applied.setFields.contains(.subGraph) ? "its loops" : nil, ].compactMap { $0 } - if !settings.isEmpty { + var clauses: [String] = [] + if !sets.isEmpty { clauses.append("sets " + Self.list(sets)) } + // The one that isn't a thing being set — it is a rhythm, so it keeps its own verb. + if applied.setFields.contains(.pausesBeforeWritesOnly) { + clauses.append("pauses only before writes") + } + if !clauses.isEmpty { if text.characters.count > "This template".count { text += AttributedString(" and") } - text += AttributedString(" " + Self.list(settings)) + text += AttributedString(" " + Self.list(clauses)) } text += AttributedString(".") return text diff --git a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift index adad2af6..0af5fc50 100644 --- a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift +++ b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift @@ -17,7 +17,8 @@ struct SketchDraftFields: View { ) { DraftProseField( placeholder: "e.g. where does the usage cap get read from?", - text: $store.draftSketchNote, takesFocusRequest: $store.templates.requestsPrimaryFocus) + text: $store.draftSketchNote, takesFocusRequest: store.templateFocus(.brief), + onTokenJump: store.tokenJump) } } } @@ -40,7 +41,7 @@ struct GoalDraftFields: View { ) { DraftProseField( placeholder: "the crash rate is back under 1%", text: $store.draftGoal, - takesFocusRequest: $store.templates.requestsPrimaryFocus) + takesFocusRequest: store.templateFocus(.brief), onTokenJump: store.tokenJump) } DraftField( @@ -50,7 +51,8 @@ struct GoalDraftFields: View { ) { HStack(spacing: 8) { DraftTextField( - placeholder: "swift test 2>/dev/null", text: $store.draftPredicate, isMono: true) + placeholder: "swift test 2>/dev/null", text: $store.draftPredicate, isMono: true, + takesFocusRequest: store.templateFocus(.doneCheck), onTokenJump: store.tokenJump) testButton } } @@ -104,7 +106,8 @@ struct GoalDraftFields: View { fromTemplate: store.templateSetFields.contains(.metric) ) { DraftTextField( - placeholder: "./scripts/score.sh", text: $store.draftMetric, isMono: true) + placeholder: "./scripts/score.sh", text: $store.draftMetric, isMono: true, + takesFocusRequest: store.templateFocus(.metric), onTokenJump: store.tokenJump) } Picker("", selection: $store.draftMetricDirection) { Text("Higher is better").tag(MetricDirection.maximize) @@ -201,7 +204,8 @@ struct TimedDraftFields: View { ) { DraftProseField( placeholder: "Check for new crash reports and triage anything new", - text: $store.draftTimedTask, takesFocusRequest: $store.templates.requestsPrimaryFocus) + text: $store.draftTimedTask, takesFocusRequest: store.templateFocus(.brief), + onTokenJump: store.tokenJump) } if store.draftRequiresDaemonHeartbeat { @@ -277,7 +281,7 @@ struct TurnDraftFields: View { DraftProseField( placeholder: "Port the settings screen to the new design system", text: $store.draftFirstInstruction, - takesFocusRequest: $store.templates.requestsPrimaryFocus) + takesFocusRequest: store.templateFocus(.brief), onTokenJump: store.tokenJump) } DraftField( @@ -352,7 +356,7 @@ struct CompositeDraftFields: View { DraftField(label: "Name", fromTemplate: store.templateSetFields.contains(.title)) { DraftTextField( placeholder: "Nightly sweep", text: $store.draftTitle, - takesFocusRequest: $store.templates.requestsPrimaryFocus) + takesFocusRequest: store.templateFocus(.brief), onTokenJump: store.tokenJump) } if let carried = store.draftSubGraph { diff --git a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift index 6ba28963..249ced95 100644 --- a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift +++ b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift @@ -39,7 +39,9 @@ extension ProjectCanvasView { onMarkAsEntry: { store.send(.markAsEntryTapped(node.id)) }, reclaimOffer: store.worktreeReclaimOffers[node.id], onReclaim: { store.send(.reclaimWorktreeTapped(node.id)) }, - onKeep: { store.send(.keepWorktreeTapped(node.id)) } + onKeep: { store.send(.keepWorktreeTapped(node.id)) }, + onDetachTemplate: node.templateFollow == nil + ? nil : { store.send(.detachTemplateTapped(node.id)) } ) .contentShape(Rectangle()) // A composite has no session of its own to open (`LoopNode.firstInstruction` is nil diff --git a/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift b/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift index 00c50d15..b36e1c80 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift @@ -132,13 +132,31 @@ extension ProjectFeature { _ state: inout State, _ action: Action ) -> Effect { switch action { + case .templateTokenJumpRequested: + // ⇥ walks the fields still holding a hole, cycling from wherever it last + // landed — a token in a done check is as much a hole as one in the brief. + let fields = state.tokenFields + guard let first = fields.first else { return .none } + guard let current = state.templates.focusRequest, + let index = fields.firstIndex(of: current) + else { + state.templates.focusRequest = first + return .none + } + state.templates.focusRequest = fields[(index + 1) % fields.count] + return .none + + case .templateFocusConsumed: + state.templates.focusRequest = nil + return .none + case .templateChosen(let id): guard let template = state.templates.library.first(where: { $0.id == id }) else { return .none } state.templates.isPickerOpen = false applyTemplate(&state, template) - state.templates.requestsPrimaryFocus = true + state.templates.focusRequest = .brief return countUse(of: template, in: state.graph.project.path) case .templateLaunched(let id): @@ -152,7 +170,7 @@ extension ProjectFeature { // unfilled-token gate the Create button obeys. The fill still happened, so the // human lands on the brief with the holes to fill, exactly as ⏎ leaves them. guard state.unfilledTokens.isEmpty, state.draft.isValid else { - state.templates.requestsPrimaryFocus = true + state.templates.focusRequest = .brief return counted } return .merge(counted, confirmCreateNode(&state)) diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index 700c7683..82668d4a 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -281,6 +281,8 @@ struct ProjectFeature { case templatePickerClosed case templateQueryChanged(String) case templateSelectionMoved(Int) + case templateTokenJumpRequested + case templateFocusConsumed case templateChosen(UUID) case templateLaunched(UUID) case templateChipRemoved @@ -392,7 +394,8 @@ struct ProjectFeature { // Template actions were handled by the switch above; they land here as // no-ops so this switch stays exhaustive, and nothing runs twice. case .templatesButtonTapped, .templatePickerClosed, .templateQueryChanged, - .templateSelectionMoved, .templateChosen, .templateLaunched, .templateChipRemoved, + .templateSelectionMoved, .templateTokenJumpRequested, .templateFocusConsumed, + .templateChosen, .templateLaunched, .templateChipRemoved, .templateShapeUndone, .saveTemplateTapped, .saveLoopTemplateTapped, .saveTemplateConfirmed, .saveTemplateCancelled, .templateSaved, .templateSaveNoticeDismissed, .templateRelocationTapped, .templateLibraryChanged, @@ -622,10 +625,10 @@ struct TemplateFormState: Equatable { /// The template currently shaping the form, with everything needed to /// un-apply it: what it set, and the fields as they were before it landed. var applied: ProjectFeature.AppliedTemplate? - /// Set right after ⏎ fills the dialog from a template: the primary field's - /// focus follows, so the first thing the human edits is the brief they came - /// to edit. The consuming field clears it. - var requestsPrimaryFocus = false + /// Which field is being asked to take focus: the brief right after ⏎ fills the + /// dialog, and then whichever field `⇥` walks to next while tokens are unfilled + /// (PROMPT_TEMPLATES.md § What a template carries). The consuming field clears it. + var focusRequest: ProjectFeature.TemplateTokenField? /// The save-as-template sheet's context — from the dialog /// (`saveTemplateTapped`) or a card's context menu (`saveLoopTemplateTapped`). /// One sheet, one field of state, two places it can open. diff --git a/graphcode/Sources/Features/Project/ProjectFeatureState.swift b/graphcode/Sources/Features/Project/ProjectFeatureState.swift index d12643c6..4f376618 100644 --- a/graphcode/Sources/Features/Project/ProjectFeatureState.swift +++ b/graphcode/Sources/Features/Project/ProjectFeatureState.swift @@ -118,14 +118,24 @@ extension ProjectFeature.State { var unfilledTokens: [String] { var seen = Set() var ordered: [String] = [] - for field in [currentBriefText, draftPredicate, draftMetric, draftBranch] { - for token in PromptTemplate.tokens(in: field) where seen.insert(token).inserted { + for field in ProjectFeature.TemplateTokenField.allCases { + for token in PromptTemplate.tokens(in: tokenFieldText(field)) + where seen.insert(token).inserted { ordered.append(token) } } return ordered } + /// The design's own line: "One token left to fill · ⇥ to jump to it". + var unfilledTokenPrompt: String? { + let count = unfilledTokens.count + guard count > 0 else { return nil } + return count == 1 + ? "One token left to fill · ⇥ to jump to it" + : "\(count) tokens left to fill · ⇥ to jump to them" + } + /// What the applied template set — the "from template" dots read this. A /// property, not a method, because a store's members are reachable through /// key-path lookup only. @@ -164,6 +174,23 @@ extension ProjectFeature.State { .map { ProjectFeature.TemplatePickerRow(template: $0, scope: .home) } } + /// The fields still holding a `{token}`, in the order `⇥` walks them — the order + /// they appear in the form, so tabbing reads down the dialog. + var tokenFields: [ProjectFeature.TemplateTokenField] { + ProjectFeature.TemplateTokenField.allCases.filter { + !PromptTemplate.tokens(in: tokenFieldText($0)).isEmpty + } + } + + func tokenFieldText(_ field: ProjectFeature.TemplateTokenField) -> String { + switch field { + case .brief: return currentBriefText + case .doneCheck: return draftPredicate + case .metric: return draftMetric + case .branch: return draftBranch + } + } + var hasProjectTemplates: Bool { templates.library.contains(where: \.origin.isProject) } @@ -331,6 +358,15 @@ extension ProjectFeature { } } + /// A field a template's `{token}`s can be sitting in, in form order — what `⇥` + /// walks while any of them is still unfilled. + enum TemplateTokenField: String, CaseIterable, Equatable, Sendable { + case brief + case doneCheck + case metric + case branch + } + /// The two scope groups the picker sorts by — a project's committed templates /// above the home library, always. enum TemplatePickerScope: Equatable { diff --git a/graphcode/Tests/TemplateApplyTests.swift b/graphcode/Tests/TemplateApplyTests.swift index 6ac0f3ca..ea68ed2d 100644 --- a/graphcode/Tests/TemplateApplyTests.swift +++ b/graphcode/Tests/TemplateApplyTests.swift @@ -250,6 +250,73 @@ struct TemplateApplyTests { #expect(store.state.unfilledTokens.isEmpty) } + /// `⇥` walks the fields that still hold a hole, in form order, and cycles — the + /// design's "One token left to fill · ⇥ to jump to it" has to be true. + @Test + @MainActor + func tabWalksTheFieldsHoldingUnfilledTokens() async { + let template = PromptTemplate( + id: UUID(), name: "Suite check", body: "Get {area} green.", shape: .goalBased, + settings: TemplateSettings(doneCheck: "make test-{suite}"), origin: .home) + let library = [template] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + await store.send(.templateChosen(template.id)) + // ⏎ lands on the brief. + #expect(store.state.templates.focusRequest == .brief) + #expect(store.state.tokenFields == [.brief, .doneCheck]) + + await store.send(.templateTokenJumpRequested) + #expect(store.state.templates.focusRequest == .doneCheck) + // Cycles rather than dead-ending on the last one. + await store.send(.templateTokenJumpRequested) + #expect(store.state.templates.focusRequest == .brief) + + // A field the human has filled drops out of the walk. + await store.send(.binding(.set(\.draftGoal, "Get the parser green."))) + #expect(store.state.tokenFields == [.doneCheck]) + await store.send(.templateTokenJumpRequested) + #expect(store.state.templates.focusRequest == .doneCheck) + } + + /// The field that answers a focus request clears it, so one jump moves one field. + @Test + @MainActor + func aFocusRequestIsConsumedByTheFieldThatAnswersIt() async { + let store = makeStore() + store.exhaustivity = .off + await store.send(.templateTokenJumpRequested) + await store.send(.templateFocusConsumed) + #expect(store.state.templates.focusRequest == nil) + } + + /// The line the design writes, verbatim. + @Test + @MainActor + func theUnfilledTokenLineIsTheDesignsOwn() async { + let one = PromptTemplate( + id: UUID(), name: "One", body: "Review {branch}.", shape: .goalBased, origin: .home) + let two = PromptTemplate( + id: UUID(), name: "Two", body: "Review {branch} for {ticket}.", shape: .goalBased, + origin: .home) + let library = [one, two] + let store = makeStore(library) + store.exhaustivity = .off + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + + await store.send(.templateChosen(one.id)) + #expect(store.state.unfilledTokenPrompt == "One token left to fill · ⇥ to jump to it") + await store.send(.templateChosen(two.id)) + #expect(store.state.unfilledTokenPrompt == "2 tokens left to fill · ⇥ to jump to them") + + await store.send(.binding(.set(\.draftGoal, "Review main for GC-1."))) + #expect(store.state.unfilledTokenPrompt == nil) + } + /// ✕ answers "put the form back the way I found it", and that means before *any* /// template landed — not before the most recent one. @Test diff --git a/graphcode/Tests/TemplatePickerTests.swift b/graphcode/Tests/TemplatePickerTests.swift index a5bf3867..76563529 100644 --- a/graphcode/Tests/TemplatePickerTests.swift +++ b/graphcode/Tests/TemplatePickerTests.swift @@ -115,7 +115,7 @@ struct TemplatePickerTests { await store.send(.templateLibraryChanged(library)) await store.send(.templateChosen(template.id)) #expect(store.state.draftSketchNote == "Trace {symbol} through the codebase.") - #expect(store.state.templates.requestsPrimaryFocus) + #expect(store.state.templates.focusRequest == .brief) #expect(store.state.unfilledTokens == ["symbol"]) #expect(store.state.draftBlocksOnTokens) #expect(store.state.templates.applied?.name == "Trace a symbol") From 20d443102b3f69eff903445dd18ab8f7d72eb0f4 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 08:29:08 -0700 Subject: [PATCH 3/5] Template editor in Settings, with the usage line the design calls load-bearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last gap from the v4 design canvas. The ⌘T picker's "Manage…" footer pointed at a Settings list that could only reveal and delete; a template's own text could not be changed anywhere in the app. - `TemplateEditorView` — name, shape, brief, and the shape's own settings (done check + metric, cadence, pause scope) plus the branch. Saving goes through the new `TemplateStorage.update(_:replacing:)`, which **keeps the id**: that is what makes every timed and composite loop following the template go on following it across an edit. A rename moves the file, and removes the old one only once the new one is written and only when it is genuinely a different file — the trap `move` had. - `TemplateUsage` — the number behind the design's sentence, "3 scheduled loops use this — they'll pick up changes on their next run", counted across every known project and at any depth inside composites. It sits directly above Save, because the reach of an edit has to be read on the way to the button. The other half is stated too: loops that snapshotted keep their brief, so "3 loops use this" is never read as "and the other four change too". A project template adds the line about a committed edit reaching a teammate's machine. - The Settings list now shows **project templates as well as home's**, marked "in project". A list that hid them made "project templates are read too" invisible in the one place templates are managed. Project paths and graphs come from `ProjectPersistence` — Settings has no store, and wiring the whole app's state in to count integers would be the larger change. - Deleting a followed template now says what actually happens: the loops keep running on the brief they last read and warn on their cards. Deleting is not a way to stop a nightly run, and the dialog no longer lets anyone assume it is. `TemplatesSettingsSection` moved to its own file with the editor; `SettingsView.swift` is back under the file-length guideline. 1356 tests (up from 1348). swiftlint 0 errors, swift-format clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJR8Wuwc4qnaSBUMcYf7Af --- .../Sources/Templates/TemplateStorage.swift | 28 ++ .../Sources/Templates/TemplateUsage.swift | 69 +++ .../Features/Settings/SettingsView.swift | 114 ----- .../Features/Settings/TemplatesSettings.swift | 392 ++++++++++++++++++ graphcode/Tests/TemplateEditingTests.swift | 78 ++++ graphcode/Tests/TemplateUsageTests.swift | 99 +++++ 6 files changed, 666 insertions(+), 114 deletions(-) create mode 100644 GraphcodeKit/Sources/Templates/TemplateUsage.swift create mode 100644 graphcode/Sources/Features/Settings/TemplatesSettings.swift create mode 100644 graphcode/Tests/TemplateEditingTests.swift create mode 100644 graphcode/Tests/TemplateUsageTests.swift diff --git a/GraphcodeKit/Sources/Templates/TemplateStorage.swift b/GraphcodeKit/Sources/Templates/TemplateStorage.swift index b30e3be5..c69e46b2 100644 --- a/GraphcodeKit/Sources/Templates/TemplateStorage.swift +++ b/GraphcodeKit/Sources/Templates/TemplateStorage.swift @@ -138,6 +138,34 @@ public struct TemplateStorage: Sendable { return candidate } + /// Saves an edit to a template that already exists — the Settings editor's Save. + /// + /// Distinct from `save` in the one way that matters: **the id is kept**, so every + /// timed and composite loop following this template goes on following it across + /// the edit. That is the whole point of editing rather than saving a new one. + /// The file stays in its own location; only a rename moves it, and the old file is + /// removed only once the new one is written and only when it is a different file. + @discardableResult + public func update( + _ edited: PromptTemplate, replacing original: PromptTemplate + ) throws -> PromptTemplate { + let directory = directoryURL(for: original.origin) + var saved = edited + saved.id = original.id + saved.origin = original.origin + saved.fileName = + edited.name == original.name + ? original.fileName + : availableFileName( + PromptTemplate.fileName(for: edited.name), in: directory, keeping: original.id) + let target = directory.appendingPathComponent(saved.fileName) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try TemplateFileCodec.encode(saved).write(to: target, atomically: true, encoding: .utf8) + let source = directory.appendingPathComponent(original.fileName) + if source != target { try? FileManager.default.removeItem(at: source) } + return saved + } + /// Removes a template file. Used by Settings' manage list; deleting a project /// template deletes the file in the checkout — an explicit act on an explicit /// location, same as saving there. diff --git a/GraphcodeKit/Sources/Templates/TemplateUsage.swift b/GraphcodeKit/Sources/Templates/TemplateUsage.swift new file mode 100644 index 00000000..707937cd --- /dev/null +++ b/GraphcodeKit/Sources/Templates/TemplateUsage.swift @@ -0,0 +1,69 @@ +import Foundation + +/// How many loops a template is answerable for — the number behind the template +/// editor's load-bearing line (PROMPT_TEMPLATES.md § Follow vs snapshot): +/// +/// > "3 scheduled loops use this — they'll pick up changes on their next run." +/// +/// The design calls that line load-bearing rather than decorative, and it is: a +/// committed edit to a project template changes what runs on a teammate's machine, +/// so the person editing has to be told how far the edit reaches *before* they save. +/// +/// Two counts, because they are two different promises: +/// - `following` — timed and composite loops that re-read the file on their next +/// run. Editing the body changes what these do. This is the number in the line. +/// - `snapshots` — main, goal and turn loops created from it, which took a copy at +/// creation and are untouched by any edit. Counted so the editor can say the edit +/// *won't* reach them rather than leaving it ambiguous. +public struct TemplateUsage: Equatable, Sendable { + public var following: Int + public var snapshots: Int + + public init(following: Int = 0, snapshots: Int = 0) { + self.following = following + self.snapshots = snapshots + } + + public var isEmpty: Bool { following == 0 && snapshots == 0 } + + /// The design's own sentence, or `nil` when nothing follows this template and + /// there is nothing load-bearing to say. + public var followingLine: String? { + switch following { + case 0: return nil + case 1: return "1 scheduled loop uses this — it'll pick up changes on its next run." + default: + return "\(following) scheduled loops use this — they'll pick up changes on their next run." + } + } + + /// The other half, for the loops an edit will *not* reach. Stated so "3 loops use + /// this" is never read as "and the other four change too". + public var snapshotLine: String? { + switch snapshots { + case 0: return nil + case 1: return "1 loop started from it and keeps the brief it was created with." + default: return "\(snapshots) loops started from it and keep the brief they were created with." + } + } + + /// Counts across every graph handed in — the app passes every project it knows + /// about, because a template in `~/.graphcode/templates` is offered in all of them + /// and its reach is not one project's business. + /// + /// Composites are searched at any depth: a following loop inside a composite's + /// sub-graph re-reads its template exactly like a top-level one. + public static func of(_ templateID: UUID, in graphs: [LoopGraph]) -> TemplateUsage { + var usage = TemplateUsage() + for graph in graphs { + for node in graph.nodesAtAnyDepth { + if node.templateFollow?.id == templateID { + usage.following += 1 + } else if node.createdFromTemplateID == templateID { + usage.snapshots += 1 + } + } + } + return usage + } +} diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index 961956d2..8a847547 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -234,120 +234,6 @@ struct SettingsView: View { } -/// The template library, as Settings can see it: the home folder — yours, offered in -/// every project. Project templates are read from wherever the project lives and are -/// committed or removed like any other file the folder holds; the one thing worth -/// saying here is the half of follow-vs-snapshot that load-bearing (PROMPT_TEMPLATES.md -/// § Follow vs snapshot): a committed edit changes what runs on a teammate's machine. -struct TemplatesSettingsSection: View { - @State private var templates: [PromptTemplate] = [] - @State private var pendingDeletion: PromptTemplate? - - var body: some View { - Section { - if templates.isEmpty { - Text( - "No templates yet. Save one from the New loop dialog (⌘T), or right-click a " - + "loop that worked and choose Save as Template…." - ) - .font(.caption2) - .foregroundStyle(.secondary) - } else { - ForEach(templates) { template in - HStack(spacing: 8) { - RoundedRectangle(cornerRadius: 2) - .fill((template.shape ?? .sketch).accent) - .frame(width: 9, height: 9) - VStack(alignment: .leading, spacing: 1) { - Text(template.name).font(.callout) - Text(subtitle(template)) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer(minLength: 8) - Button("Reveal") { reveal(template) } - .buttonStyle(.link) - .font(.caption) - Button("Delete", role: .destructive) { pendingDeletion = template } - .buttonStyle(.link) - .font(.caption) - } - .padding(.vertical, 2) - } - } - } header: { - Text("Templates") - } footer: { - Text( - "One template is one markdown file in ~/.graphcode/templates — editable outside " - + "the app, shareable by committing it into a project's .graphcode/templates. " - + "Timed and composite loops that started from one follow it and pick up edits " - + "on their next run; a loop can be detached from its card's menu. Each project " - + "reads its own committed templates first, so a committed edit changes what " - + "runs on a teammate's machine." - ) - .font(.caption2) - .foregroundStyle(.secondary) - } - .onAppear(perform: reload) - // A template is a file, and a project one is a file in somebody's checkout. The - // list is the only place they can be deleted, so the click asks first. - .confirmationDialog( - "Delete “\(pendingDeletion?.name ?? "")”?", - isPresented: Binding( - get: { pendingDeletion != nil }, - set: { if !$0 { pendingDeletion = nil } }) - ) { - Button("Delete template", role: .destructive) { - if let template = pendingDeletion { try? TemplateStorage.shared.delete(template) } - pendingDeletion = nil - reload() - } - Button("Cancel", role: .cancel) { pendingDeletion = nil } - } message: { - Text(pendingDeletion.map { "This removes \(TemplateSavePath.display(of: $0))." } ?? "") - } - } - - private func subtitle(_ template: PromptTemplate) -> String { - var parts: [String] = [] - switch template.shape { - case .sketch, nil: parts.append("Main") - case .goalBased: parts.append("Goal") - case .timeBased: - let cadence = template.settings?.cadence.map { $0.lowercased() } ?? "" - parts.append(cadence.isEmpty ? "Timed" : "Timed · \(cadence)") - case .turnBased: parts.append("Turn") - case .composite: parts.append("Composite") - } - if template.useCount > 0 { parts.append("used \(template.useCount)×") } - parts.append(template.summaryLine) - return parts.joined(separator: " — ") - } - - /// The file this row stands for — which is not always in the home folder, so the - /// origin decides rather than the assumption that everything listed here is home's. - private func reveal(_ template: PromptTemplate) { - let storage = TemplateStorage.shared - let directory: URL - switch template.origin { - case .home: directory = storage.homeDirectory - case .project(let path): directory = storage.projectDirectory(path) - } - NSWorkspace.shared.activateFileViewerSelecting([ - directory.appendingPathComponent(template.fileName) - ]) - } - - private func reload() { - // The same use-count overlay the picker reads, so a template that says "used 4×" - // in one list doesn't say "used 0×" in the other. - templates = TemplateLibraryClient.overlayUseCounts( - TemplateStorage.shared.load(projectPath: nil)) - } -} - /// Makes the Settings window actually resizable, because the SwiftUI spelling doesn't. /// /// `.windowResizability(.contentMinSize)` on the `Settings` scene is silently ignored diff --git a/graphcode/Sources/Features/Settings/TemplatesSettings.swift b/graphcode/Sources/Features/Settings/TemplatesSettings.swift new file mode 100644 index 00000000..a595e0a7 --- /dev/null +++ b/graphcode/Sources/Features/Settings/TemplatesSettings.swift @@ -0,0 +1,392 @@ +import GraphcodeKit +import SwiftUI + +/// The template library, as Settings can see it — the **Manage…** destination the +/// ⌘T picker's footer points at (PROMPT_TEMPLATES.md § Picker). +/// +/// Both locations are listed, not just home: a project's committed templates are +/// what a team actually shares, and a list that hid them would make "project +/// templates are read too" invisible in the one place templates are managed. +/// +/// Each row opens `TemplateEditorView`, which is where § Follow vs snapshot's +/// load-bearing line lives — "3 scheduled loops use this — they'll pick up changes +/// on their next run." The reach of an edit has to be visible *before* it is saved, +/// because a committed edit to a project template changes what runs on a teammate's +/// machine. +struct TemplatesSettingsSection: View { + @State private var templates: [PromptTemplate] = [] + @State private var usage: [UUID: TemplateUsage] = [:] + @State private var pendingDeletion: PromptTemplate? + @State private var editing: PromptTemplate? + + var body: some View { + Section { + if templates.isEmpty { + Text( + "No templates yet. Save one from the New loop dialog (⌘T), or right-click a " + + "loop that worked and choose Save as Template…." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } else { + ForEach(templates) { template in + row(template) + } + } + } header: { + Text("Templates") + } footer: { + Text( + "One template is one markdown file — editable here or in any editor, shareable " + + "by committing it into a project's .graphcode/templates. Timed and composite " + + "loops that started from one follow it and pick up edits on their next run; a " + + "loop can be detached from its card. Each project reads its own committed " + + "templates first, so a committed edit changes what runs on a teammate's machine." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + .onAppear(perform: reload) + .sheet(item: $editing) { template in + TemplateEditorView( + template: template, + usage: usage[template.id] ?? TemplateUsage(), + onSave: { edited in + _ = try? TemplateStorage.shared.update(edited, replacing: template) + editing = nil + reload() + }, + onCancel: { editing = nil }) + } + // A template is a file, and a project one is a file in somebody's checkout. The + // list is the only place they can be deleted, so the click asks first. + .confirmationDialog( + "Delete “\(pendingDeletion?.name ?? "")”?", + isPresented: Binding( + get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } }) + ) { + Button("Delete template", role: .destructive) { + if let template = pendingDeletion { try? TemplateStorage.shared.delete(template) } + pendingDeletion = nil + reload() + } + Button("Cancel", role: .cancel) { pendingDeletion = nil } + } message: { + Text(deletionWarning) + } + } + + private func row(_ template: PromptTemplate) -> some View { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2) + .fill((template.shape ?? .sketch).accent) + .frame(width: 9, height: 9) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(template.name).font(.callout) + if template.origin.isProject { + Text("in project") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 3)) + } + } + Text(subtitle(template)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Button("Edit") { editing = template } + .buttonStyle(.link) + .font(.caption) + Button("Reveal") { reveal(template) } + .buttonStyle(.link) + .font(.caption) + Button("Delete", role: .destructive) { pendingDeletion = template } + .buttonStyle(.link) + .font(.caption) + } + .padding(.vertical, 2) + } + + /// Deleting a followed template does not stop the loops following it — they keep + /// their last-known snapshot and warn — so the dialog says so rather than letting + /// someone guess that deleting is a way to stop a nightly run. + private var deletionWarning: String { + guard let template = pendingDeletion else { return "" } + var text = "This removes \(TemplateSavePath.display(of: template))." + let following = usage[template.id]?.following ?? 0 + if following > 0 { + text += + following == 1 + ? " 1 loop follows it; it will keep running on the brief it last read, and say so on its card." + : " \(following) loops follow it; they'll keep running on the brief they last read, and say so on their cards." + } + return text + } + + private func subtitle(_ template: PromptTemplate) -> String { + var parts: [String] = [] + switch template.shape { + case .sketch, nil: parts.append("Main") + case .goalBased: parts.append("Goal") + case .timeBased: + let cadence = template.settings?.cadence.map { $0.lowercased() } ?? "" + parts.append(cadence.isEmpty ? "Timed" : "Timed · \(cadence)") + case .turnBased: parts.append("Turn") + case .composite: parts.append("Composite") + } + if template.useCount > 0 { parts.append("used \(template.useCount)×") } + if let following = usage[template.id]?.followingLine { parts.append(following) } + parts.append(template.summaryLine) + return parts.joined(separator: " — ") + } + + /// The file this row stands for — which is not always in the home folder, so the + /// origin decides rather than the assumption that everything listed here is home's. + private func reveal(_ template: PromptTemplate) { + let storage = TemplateStorage.shared + let directory: URL + switch template.origin { + case .home: directory = storage.homeDirectory + case .project(let path): directory = storage.projectDirectory(path) + } + NSWorkspace.shared.activateFileViewerSelecting([ + directory.appendingPathComponent(template.fileName) + ]) + } + + /// Home's templates plus every known project's, and the loop counts behind them. + /// + /// Settings has no project of its own, so "known" is the recent-projects list — + /// the same folders the sidebar offers. Read straight off disk rather than through + /// a store: these are small local JSON files, and wiring the whole app's state into + /// the Settings window to count integers would be the larger change. + private func reload() { + let persistence = ProjectPersistence(baseDirectory: SupportDirectory.url) + let projects = persistence.loadRecentProjects() + let storage = TemplateStorage.shared + + var seen = Set() + var found: [PromptTemplate] = [] + for path in projects.map(\.path) where seen.insert(.project(path)).inserted { + found += storage.load(projectPath: path).filter(\.origin.isProject) + } + found += storage.load(projectPath: nil) + + let graphs = projects.compactMap { persistence.loadGraph(path: $0.path) } + templates = TemplateLibraryClient.overlayUseCounts(found) + usage = Dictionary( + uniqueKeysWithValues: templates.map { ($0.id, TemplateUsage.of($0.id, in: graphs)) }) + } +} + +/// The template editor — the one screen where a template's own text can be changed, +/// and where the reach of that change is stated before it is saved. +/// +/// PROMPT_TEMPLATES.md § Follow vs snapshot calls the usage line load-bearing rather +/// than decorative, and this is why: editing a project template that three nightly +/// loops follow changes what runs on every machine that has the file, including a +/// teammate's after a `git pull`. The line is placed above Save, not below the title, +/// so it is read on the way to the button. +struct TemplateEditorView: View { + let template: PromptTemplate + let usage: TemplateUsage + let onSave: (PromptTemplate) -> Void + let onCancel: () -> Void + + @State private var name: String + @State private var brief: String + @State private var shape: LoopType? + @State private var doneCheck: String + @State private var cadence: String + @State private var metric: String + @State private var branch: String + @State private var pausesBeforeWritesOnly: Bool + + init( + template: PromptTemplate, usage: TemplateUsage, + onSave: @escaping (PromptTemplate) -> Void, onCancel: @escaping () -> Void + ) { + self.template = template + self.usage = usage + self.onSave = onSave + self.onCancel = onCancel + _name = State(initialValue: template.name) + _brief = State(initialValue: template.body) + _shape = State(initialValue: template.shape) + _doneCheck = State(initialValue: template.settings?.doneCheck ?? "") + _cadence = State(initialValue: template.settings?.cadence ?? "") + _metric = State(initialValue: template.settings?.metric ?? "") + _branch = State(initialValue: template.settings?.branch ?? "") + _pausesBeforeWritesOnly = State( + initialValue: template.settings?.pausesBeforeWritesOnly ?? false) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Form { + Section { + TextField("Name", text: $name) + Picker("Shape", selection: $shape) { + Text("Main").tag(LoopType?.none) + Text("Goal").tag(LoopType?.some(.goalBased)) + Text("Timed").tag(LoopType?.some(.timeBased)) + Text("Turn").tag(LoopType?.some(.turnBased)) + Text("Composite").tag(LoopType?.some(.composite)) + } + } footer: { + Text("The type the brief assumes. Main carries the text alone.") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Section { + TextEditor(text: $brief) + .font(.system(size: 12)) + .frame(minHeight: 120) + } header: { + Text("Brief") + } footer: { + tokenFooter + } + + shapeSettings + + Section { + TextField("Branch", text: $branch, prompt: Text("optional — a new branch to cut")) + } footer: { + Text("A worktree the loop works in. Left blank, it runs in the checkout.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + + Divider() + footer + } + .frame(minWidth: 520, idealWidth: 560, minHeight: 520, idealHeight: 640) + } + + @ViewBuilder + private var shapeSettings: some View { + switch shape { + case .goalBased: + Section { + TextField("Done check", text: $doneCheck, prompt: Text("make test")) + .font(.system(size: 12, design: .monospaced)) + TextField("Measured by", text: $metric, prompt: Text("./scripts/score.sh")) + .font(.system(size: 12, design: .monospaced)) + } footer: { + Text("Runs periodically while the loop works. Exit 0 means done.") + .font(.caption2) + .foregroundStyle(.secondary) + } + case .timeBased: + Section { + TextField("Cadence", text: $cadence, prompt: Text("1h · daily · 45m")) + } footer: { + Text( + "Written into the loop's own /loop directive. Loops following this template " + + "pick a changed cadence up on their next run." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + case .turnBased: + Section { + Toggle("Pause only before it writes files", isOn: $pausesBeforeWritesOnly) + } + case .composite, .sketch, .none: + EmptyView() + } + } + + @ViewBuilder + private var tokenFooter: some View { + let tokens = PromptTemplate.tokens(in: brief) + if tokens.isEmpty { + Text("Write {like_this} to leave a hole for whoever uses the template to fill.") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + Text( + "Fills in at use time: " + tokens.map { "{\($0)}" }.joined(separator: " ") + + ". Unfilled tokens block Start." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 8) { + // The load-bearing half of follow-vs-snapshot, above the button that commits + // the edit — see this type's own doc comment. + if let line = usage.followingLine { + Label { + Text(line).font(.caption) + } icon: { + Circle().fill(Color(red: 0.039, green: 0.518, blue: 1.0)).frame(width: 5, height: 5) + } + .foregroundStyle(.primary) + } + if let line = usage.snapshotLine { + Text(line).font(.caption2).foregroundStyle(.secondary) + } + if template.origin.isProject { + Text( + "This template lives in the project. Committing an edit changes what runs on " + + "everyone else's machine too." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + HStack { + Text(TemplateSavePath.display(of: template)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 12) + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + Button("Save") { onSave(edited) } + .keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(16) + } + + /// The edited template. `id` is restored by `TemplateStorage.update`, which is what + /// keeps every following loop attached across the edit. + private var edited: PromptTemplate { + func trimmed(_ value: String) -> String? { + let text = value.trimmingCharacters(in: .whitespaces) + return text.isEmpty ? nil : text + } + var settings = TemplateSettings() + settings.backend = template.settings?.backend + settings.graphJSON = template.settings?.graphJSON + settings.branch = trimmed(branch) + if shape == .goalBased { + settings.doneCheck = trimmed(doneCheck) + settings.metric = trimmed(metric) + } + if shape == .timeBased { settings.cadence = trimmed(cadence) } + if shape == .turnBased { settings.pausesBeforeWritesOnly = pausesBeforeWritesOnly } + return PromptTemplate( + id: template.id, + name: name.trimmingCharacters(in: .whitespaces), + body: brief.trimmingCharacters(in: .whitespacesAndNewlines), + shape: shape, + settings: settings.isEmpty ? nil : settings, + origin: template.origin) + } +} diff --git a/graphcode/Tests/TemplateEditingTests.swift b/graphcode/Tests/TemplateEditingTests.swift new file mode 100644 index 00000000..e9b0bbd1 --- /dev/null +++ b/graphcode/Tests/TemplateEditingTests.swift @@ -0,0 +1,78 @@ +import Foundation +import GraphcodeKit +import Testing + +/// Editing a template that already exists — the Settings editor's Save. The rule +/// this suite exists for: **the id survives**, so every timed and composite loop +/// following the template goes on following it across the edit +/// (PROMPT_TEMPLATES.md § Follow vs snapshot). +@Suite +struct TemplateEditingTests { + private let home: URL + private let storage: TemplateStorage + + init() { + home = FileManager.default.temporaryDirectory + .appendingPathComponent("template-editing-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + storage = TemplateStorage( + homeDirectory: home, + projectDirectory: { _ in + URL(fileURLWithPath: "/nonexistent", isDirectory: true) + }) + } + + private func goalTemplate(_ name: String, body: String) -> PromptTemplate { + PromptTemplate( + id: UUID(), name: name, body: body, shape: .goalBased, + settings: TemplateSettings(doneCheck: "make test"), origin: .home) + } + + // MARK: - Editing + + /// Editing keeps the id, which is the whole point: every timed and composite loop + /// following this template goes on following it across the edit. + @Test + func editingKeepsTheIdSoFollowersStayAttached() throws { + let (saved, _) = try storage.save( + goalTemplate("nightly", body: "Check dependencies."), to: .home, projectPath: nil) + var edited = saved + edited.body = "Check dependencies and triage." + edited.id = UUID() // even if a caller hands over a different id, the file's wins + + let written = try storage.update(edited, replacing: saved) + #expect(written.id == saved.id) + #expect(written.fileName == saved.fileName) + #expect( + storage.template(withID: saved.id, projectPath: nil)?.body + == "Check dependencies and triage.") + } + + /// A rename moves the file and takes the old one with it — but only once the new + /// one is written, and only when it really is a different file. + @Test + func renamingInTheEditorMovesTheFile() throws { + let (saved, _) = try storage.save( + goalTemplate("nightly", body: "Check dependencies."), to: .home, projectPath: nil) + let written = try storage.update(saved.renamed(to: "Nightly sweep"), replacing: saved) + + #expect(written.fileName == "nightly-sweep.md") + #expect(!FileManager.default.fileExists(atPath: home.appendingPathComponent("nightly.md").path)) + #expect(storage.load(projectPath: nil).map(\.name) == ["Nightly sweep"]) + // The id survived the rename, so a following loop still resolves it. + #expect(storage.template(withID: saved.id, projectPath: nil)?.name == "Nightly sweep") + } + + /// An edit that doesn't rename must not delete what it just wrote — the same trap + /// `move` had. + @Test + func anEditThatDoesNotRenameKeepsTheFile() throws { + let (saved, _) = try storage.save( + goalTemplate("nightly", body: "One."), to: .home, projectPath: nil) + var edited = saved + edited.body = "Two." + _ = try storage.update(edited, replacing: saved) + #expect(FileManager.default.fileExists(atPath: home.appendingPathComponent("nightly.md").path)) + #expect(storage.load(projectPath: nil).map(\.body) == ["Two."]) + } +} diff --git a/graphcode/Tests/TemplateUsageTests.swift b/graphcode/Tests/TemplateUsageTests.swift new file mode 100644 index 00000000..98081f91 --- /dev/null +++ b/graphcode/Tests/TemplateUsageTests.swift @@ -0,0 +1,99 @@ +import Foundation +import GraphcodeKit +import IdentifiedCollections +import Testing + +/// The number behind the template editor's load-bearing line (PROMPT_TEMPLATES.md +/// § Follow vs snapshot): "3 scheduled loops use this — they'll pick up changes on +/// their next run." +/// +/// The design calls that line load-bearing rather than decorative, so what it counts +/// has to be exactly right: loops that will actually change, not loops that merely +/// came from the template once. +@Suite +struct TemplateUsageTests { + private func graph(_ nodes: [LoopNode]) -> LoopGraph { + LoopGraph( + project: ProjectRef(path: "/tmp/usage", name: "usage"), + nodes: IdentifiedArray(uniqueElements: nodes)) + } + + @Test + func followersAndSnapshotsAreCountedApart() { + let id = UUID() + let other = UUID() + let usage = TemplateUsage.of( + id, + in: [ + graph([ + LoopNode( + title: "Nightly", loopType: .timeBased, createdFromTemplateID: id, + templateFollow: TemplateFollow(id: id, name: "Nightly")), + LoopNode( + title: "Weekly", loopType: .timeBased, createdFromTemplateID: id, + templateFollow: TemplateFollow(id: id, name: "Nightly")), + // Snapshotted at creation — an edit never reaches this one. + LoopNode(title: "Green build", loopType: .goalBased, createdFromTemplateID: id), + // Someone else's template entirely. + LoopNode( + title: "Other", loopType: .timeBased, createdFromTemplateID: other, + templateFollow: TemplateFollow(id: other, name: "Other")), + LoopNode(title: "Hand-made", loopType: .goalBased), + ]) + ]) + + #expect(usage.following == 2) + #expect(usage.snapshots == 1) + #expect( + usage.followingLine + == "2 scheduled loops use this — they'll pick up changes on their next run." + ) + #expect(usage.snapshotLine == "1 loop started from it and keeps the brief it was created with.") + } + + /// A follower inside a composite re-reads its template exactly like a top-level + /// one, so the count has to see it. + @Test + func followersInsideACompositeAreCounted() { + let id = UUID() + let child = LoopNode( + title: "Child", loopType: .timeBased, + templateFollow: TemplateFollow(id: id, name: "Nightly")) + let composite = LoopNode( + title: "Group", loopType: .composite, + subGraph: LoopGraph( + project: ProjectRef(path: "sub", name: "sub"), + nodes: IdentifiedArray(uniqueElements: [child]))) + + #expect(TemplateUsage.of(id, in: [graph([composite])]).following == 1) + } + + /// Counted across every project, because a home template is offered in all of them. + @Test + func usageSpansEveryProjectHandedIn() { + let id = UUID() + let follower = { + LoopNode( + title: "Nightly", loopType: .timeBased, + templateFollow: TemplateFollow(id: id, name: "Nightly")) + } + let usage = TemplateUsage.of(id, in: [graph([follower()]), graph([follower()])]) + #expect(usage.following == 2) + } + + @Test + func nothingUsingItSaysNothing() { + let usage = TemplateUsage.of(UUID(), in: [graph([LoopNode(title: "Alone")])]) + #expect(usage.isEmpty) + #expect(usage.followingLine == nil) + #expect(usage.snapshotLine == nil) + } + + /// The singular reads as a sentence, not as "1 loops". + @Test + func theSingularIsWrittenOut() { + #expect( + TemplateUsage(following: 1).followingLine + == "1 scheduled loop uses this — it'll pick up changes on its next run.") + } +} From cf323e9b73c7d037725e416893f4d3b086849be9 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 08:36:23 -0700 Subject: [PATCH 4/5] Keep the template watcher off the Linux build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swift build` compiles GraphcodeKit for CI on Linux, where `DispatchSource.makeFileSystemObjectSource` and `O_EVTONLY` do not exist. The watch is a UI affordance — it keeps the ⌘T picker's library live — and the only thing that reads templates on Linux is the daemon's by-id resolve, which re-reads the directory on every call. So the watcher is Darwin-only and the Linux stream finishes immediately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJR8Wuwc4qnaSBUMcYf7Af --- .../Sources/Templates/TemplateStorage.swift | 159 +++++++++--------- 1 file changed, 84 insertions(+), 75 deletions(-) diff --git a/GraphcodeKit/Sources/Templates/TemplateStorage.swift b/GraphcodeKit/Sources/Templates/TemplateStorage.swift index c69e46b2..ba75ea1c 100644 --- a/GraphcodeKit/Sources/Templates/TemplateStorage.swift +++ b/GraphcodeKit/Sources/Templates/TemplateStorage.swift @@ -260,92 +260,101 @@ public struct TemplateStorage: Sendable { /// (which is what `git checkout` and an atomic editor save both look like) all put /// the watch back on the right descriptor and report the change. public func watch(projectPath: String?) -> AsyncStream { - let directories = - [homeDirectory] - + (projectPath.map { [projectDirectory($0)] } ?? []) - return AsyncStream { continuation in - let arming = DirectoryWatch(directories: directories) { continuation.yield() } - continuation.onTermination = { _ in arming.stop() } - } + #if canImport(Darwin) + let directories = + [homeDirectory] + + (projectPath.map { [projectDirectory($0)] } ?? []) + return AsyncStream { continuation in + let arming = DirectoryWatch(directories: directories) { continuation.yield() } + continuation.onTermination = { _ in arming.stop() } + } + #else + // Linux builds the non-UI sources for CI, and the only thing that reads + // templates there is the daemon's by-id resolve, which re-reads the directory + // on every call. There is no library to keep live because there is no picker. + return AsyncStream { $0.finish() } + #endif } } -/// Keeps one `DispatchSource` per directory that currently exists, and re-arms as -/// directories come and go. Separate from `TemplateStorage` because it owns mutable -/// state with a lifetime — the storage type itself is a value anyone may copy. -private final class DirectoryWatch: @unchecked Sendable { - /// Slow on purpose: this only has to notice a directory *appearing*, which the - /// dispatch sources cannot do for themselves. Everything that happens inside an - /// already-watched directory is reported immediately by its source. - private static let rearmInterval: DispatchTimeInterval = .seconds(2) +#if canImport(Darwin) + /// Keeps one `DispatchSource` per directory that currently exists, and re-arms as + /// directories come and go. Separate from `TemplateStorage` because it owns mutable + /// state with a lifetime — the storage type itself is a value anyone may copy. + private final class DirectoryWatch: @unchecked Sendable { + /// Slow on purpose: this only has to notice a directory *appearing*, which the + /// dispatch sources cannot do for themselves. Everything that happens inside an + /// already-watched directory is reported immediately by its source. + private static let rearmInterval: DispatchTimeInterval = .seconds(2) - private let queue = DispatchQueue(label: "graphcode.template-watch") - private let directories: [URL] - private let onChange: () -> Void - private var sources: [URL: DispatchSourceFileSystemObject] = [:] - private var timer: DispatchSourceTimer? - private var stopped = false + private let queue = DispatchQueue(label: "graphcode.template-watch") + private let directories: [URL] + private let onChange: () -> Void + private var sources: [URL: DispatchSourceFileSystemObject] = [:] + private var timer: DispatchSourceTimer? + private var stopped = false - init(directories: [URL], onChange: @escaping () -> Void) { - self.directories = directories - self.onChange = onChange - queue.async { [weak self] in self?.arm(reporting: false) } - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + Self.rearmInterval, repeating: Self.rearmInterval) - timer.setEventHandler { [weak self] in self?.arm(reporting: true) } - timer.resume() - self.timer = timer - } + init(directories: [URL], onChange: @escaping () -> Void) { + self.directories = directories + self.onChange = onChange + queue.async { [weak self] in self?.arm(reporting: false) } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + Self.rearmInterval, repeating: Self.rearmInterval) + timer.setEventHandler { [weak self] in self?.arm(reporting: true) } + timer.resume() + self.timer = timer + } - func stop() { - queue.async { [self] in - stopped = true - timer?.cancel() - timer = nil - for source in sources.values { source.cancel() } - sources.removeAll() + func stop() { + queue.async { [self] in + stopped = true + timer?.cancel() + timer = nil + for source in sources.values { source.cancel() } + sources.removeAll() + } } - } - /// Brings `sources` back in line with which directories exist. `reporting` is false - /// only for the very first pass, where the caller has just read the library itself - /// and a yield would be a redundant re-read. - private func arm(reporting: Bool) { - guard !stopped else { return } - var changed = false - for url in directories { - let exists = FileManager.default.fileExists(atPath: url.path) - let watched = sources[url] != nil - if exists, !watched, let source = makeSource(for: url) { - sources[url] = source - changed = true - } else if !exists, watched { - sources[url]?.cancel() - sources[url] = nil - changed = true + /// Brings `sources` back in line with which directories exist. `reporting` is false + /// only for the very first pass, where the caller has just read the library itself + /// and a yield would be a redundant re-read. + private func arm(reporting: Bool) { + guard !stopped else { return } + var changed = false + for url in directories { + let exists = FileManager.default.fileExists(atPath: url.path) + let watched = sources[url] != nil + if exists, !watched, let source = makeSource(for: url) { + sources[url] = source + changed = true + } else if !exists, watched { + sources[url]?.cancel() + sources[url] = nil + changed = true + } } + if changed, reporting { onChange() } } - if changed, reporting { onChange() } - } - private func makeSource(for url: URL) -> DispatchSourceFileSystemObject? { - let descriptor = open(url.path, O_EVTONLY) - guard descriptor >= 0 else { return nil } - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: descriptor, eventMask: [.write, .rename, .delete], queue: queue) - source.setEventHandler { [weak self] in - guard let self else { return } - // A directory that was renamed or deleted out from under the descriptor keeps - // reporting nothing useful; dropping it here lets the next re-arm re-open the - // path, which is how a `git checkout` that swaps the folder is picked up. - if source.data.contains(.delete) || source.data.contains(.rename) { - source.cancel() - sources[url] = nil + private func makeSource(for url: URL) -> DispatchSourceFileSystemObject? { + let descriptor = open(url.path, O_EVTONLY) + guard descriptor >= 0 else { return nil } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: [.write, .rename, .delete], queue: queue) + source.setEventHandler { [weak self] in + guard let self else { return } + // A directory that was renamed or deleted out from under the descriptor keeps + // reporting nothing useful; dropping it here lets the next re-arm re-open the + // path, which is how a `git checkout` that swaps the folder is picked up. + if source.data.contains(.delete) || source.data.contains(.rename) { + source.cancel() + sources[url] = nil + } + onChange() } - onChange() + source.setCancelHandler { close(descriptor) } + source.resume() + return source } - source.setCancelHandler { close(descriptor) } - source.resume() - return source } -} +#endif From ca62defb04dc65054369c6eedf04b6838b7770e5 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 08:39:45 -0700 Subject: [PATCH 5/5] Spell the token pattern so SwiftPM can build it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux CI job builds GraphcodeKit through SwiftPM, which does not enable bare-slash-regex; Xcode does, by default, which is why this compiled locally and failed in CI. `{token}` is now an NSRegularExpression — the same type the file already uses to recover token values — behind a `tokenRanges(in:)` helper both call sites share. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJR8Wuwc4qnaSBUMcYf7Af --- .../Sources/Templates/PromptTemplate.swift | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/GraphcodeKit/Sources/Templates/PromptTemplate.swift b/GraphcodeKit/Sources/Templates/PromptTemplate.swift index aee88250..30ef1ce6 100644 --- a/GraphcodeKit/Sources/Templates/PromptTemplate.swift +++ b/GraphcodeKit/Sources/Templates/PromptTemplate.swift @@ -60,9 +60,9 @@ public struct PromptTemplate: Codable, Equatable, Identifiable, Sendable { public static func tokens(in text: String) -> [String] { var seen = Set() var ordered: [String] = [] - for range in text.ranges(of: Self.tokenPattern) { - let token = String(text[range]).dropFirst().dropLast() - if seen.insert(String(token)).inserted { ordered.append(String(token)) } + for range in tokenRanges(in: text) { + let token = String(text[range].dropFirst().dropLast()) + if seen.insert(token).inserted { ordered.append(token) } } return ordered } @@ -72,7 +72,7 @@ public struct PromptTemplate: Codable, Equatable, Identifiable, Sendable { /// match exactly — someone who rewrote more than the tokens gets `nil` and /// their text is saved as they wrote it, never guessed at. public static func tokenValues(of filled: String, against brief: String) -> [String: String]? { - let tokenRanges = Array(brief.ranges(of: Self.tokenPattern)) + let tokenRanges = Self.tokenRanges(in: brief) guard !tokenRanges.isEmpty else { return [:] } var pattern = "^" var tokensFound: [String] = [] @@ -105,7 +105,23 @@ public struct PromptTemplate: Codable, Equatable, Identifiable, Sendable { return values } - static let tokenPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/ + /// `{token}` — a brace-wrapped identifier. + /// + /// An `NSRegularExpression` rather than a `/…/` literal: SwiftPM builds this module + /// for the Linux CI job without bare-slash-regex enabled, and Xcode turning it on by + /// default hid that until CI said so. The pattern is a constant, so `try?` never + /// actually returns nil — it is here to keep a force-try out of the file. + private static let tokenExpression: NSRegularExpression? = try? NSRegularExpression( + pattern: #"\{[A-Za-z_][A-Za-z0-9_]*\}"#) + + /// Where each `{token}` sits, in order of appearance. + static func tokenRanges(in text: String) -> [Range] { + guard let tokenExpression else { return [] } + return + tokenExpression + .matches(in: text, range: NSRange(text.startIndex..., in: text)) + .compactMap { Range($0.range, in: text) } + } /// A copy under a new name, **with its filename re-derived**. `fileName` is stored /// rather than computed so a template loaded from disk keeps the name the file