diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 03593aca..c7ccdf74 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -8,6 +8,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 @@ -135,6 +161,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? /// The newest Artifactory post this loop has read — `ArtifactoryPost.id` of the last /// post a `graphcode artifactory sync` showed it. `nil` has not synced yet and makes /// every post unread; the cursor only moves through sync, so a loop that ignores @@ -179,6 +212,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { presence: PresenceReading? = nil, metricHistory: [MetricSample] = [], createdBy: UUID? = nil, + createdFromTemplateID: UUID? = nil, + templateFollow: TemplateFollow? = nil, lastArtifactoryRead: Int? = nil, artifactoryWatch: ArtifactoryWatch? = nil, stallReason: String? = nil, @@ -206,6 +241,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.presence = presence self.metricHistory = metricHistory self.createdBy = createdBy + self.createdFromTemplateID = createdFromTemplateID + self.templateFollow = templateFollow self.lastArtifactoryRead = lastArtifactoryRead self.artifactoryWatch = artifactoryWatch self.stallReason = stallReason @@ -449,6 +486,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case lastArtifactoryRead, artifactoryWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds, stallReason + case createdFromTemplateID, templateFollow } /// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph` @@ -488,6 +526,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) // Absent from graphs saved before the Artifactory existed — every loop simply has // not read anything yet, which is what `nil` says. lastArtifactoryRead = try container.decodeIfPresent(Int.self, forKey: .lastArtifactoryRead) 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 ca26b4cd..c621d71a 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -215,6 +215,7 @@ public actor GraphStore { @Sendable (LoopNode, LoopSummary, String?, String?) async -> SummaryBoard? )? = nil, onBoardsEnabled: (@Sendable () -> Bool)? = nil, + onResolveTemplate: (@Sendable (UUID, String?) -> PromptTemplate?)? = nil, onArtifactoryEnabled: (@Sendable () -> Bool)? = nil, goalCache: GoalEvaluationCache? = nil, recurrence: RecurrenceSink? = nil, @@ -242,6 +243,7 @@ public actor GraphStore { self.onHeartbeatEnabled = onHeartbeatEnabled self.onComposeBoard = onComposeBoard self.onBoardsEnabled = onBoardsEnabled + self.onResolveTemplate = onResolveTemplate self.onArtifactoryEnabled = onArtifactoryEnabled self.goalCache = goalCache ?? GoalEvaluationCache() self.recurrence = recurrence @@ -273,8 +275,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 @@ -428,6 +626,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) @@ -627,6 +828,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 setNodeState(nodeID, .running) @@ -2809,6 +3013,7 @@ public actor GraphStore { if !node.isResolved { armHeartbeat(for: node) } ensureSession(node) } + broadcastIfTemplatesRefreshed() armPilotedSubGraphRecurrence(graph.nodes) } @@ -2847,6 +3052,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 1203cc19..e0f80022 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 78c13174..73145711 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -476,6 +476,12 @@ public actor ProjectRegistry { // 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) + }, // Read fresh per command, the way the heartbeat toggle is: the app resolving // the beta ramp (or a hand edit) applies to the next post with no restart. onArtifactoryEnabled: { GraphcodeSettingsStore.load().artifactoryEnabled }) diff --git a/GraphcodeKit/Sources/Templates/PromptTemplate.swift b/GraphcodeKit/Sources/Templates/PromptTemplate.swift new file mode 100644 index 00000000..30ef1ce6 --- /dev/null +++ b/GraphcodeKit/Sources/Templates/PromptTemplate.swift @@ -0,0 +1,489 @@ +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 tokenRanges(in: text) { + let token = String(text[range].dropFirst().dropLast()) + if seen.insert(token).inserted { ordered.append(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 = Self.tokenRanges(in: brief) + guard !tokenRanges.isEmpty else { return [:] } + var pattern = "^" + var tokensFound: [String] = [] + var cursor = brief.startIndex + for range in tokenRanges { + let literal = String(brief[cursor.. [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 + /// actually has; the cost is that renaming has to say so, and every rename in the + /// app goes through here rather than assigning `name` and stranding the old slug. + public func renamed(to newName: String) -> 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..ba75ea1c --- /dev/null +++ b/GraphcodeKit/Sources/Templates/TemplateStorage.swift @@ -0,0 +1,360 @@ +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 + } + + /// 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. + 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 { + #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 + } +} + +#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 + + 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 + } + } +#endif 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/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..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) @@ -230,6 +233,11 @@ struct LoopCardView: View { .foregroundStyle(.white.opacity(0.52)) .lineLimit(1) .truncationMode(.middle) + if let follow = node.templateFollow { + // 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)) } @@ -242,6 +250,45 @@ 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 + /// `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) { + 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) + 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) + .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 68365c14..0aff7874 100644 --- a/graphcode/Sources/Features/Overview/GraphOverviewCards.swift +++ b/graphcode/Sources/Features/Overview/GraphOverviewCards.swift @@ -225,6 +225,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..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. @@ -12,11 +13,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 +35,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 +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, 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 @@ -85,6 +107,8 @@ struct DraftTextField: View { .font(.system(size: isMono ? 12 : 13, design: isMono ? .monospaced : .default)) .focused($isFocused) .draftFieldBox(isFocused: isFocused) + .onKeyPress(.tab) { onTokenJump?() == true ? .handled : .ignored } + .claimingFocus(when: takesFocusRequest, focus: $isFocused) } } @@ -93,6 +117,10 @@ 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 + /// See `DraftTextField.onTokenJump`. + var onTokenJump: (() -> Bool)? = nil @FocusState private var isFocused: Bool @@ -103,6 +131,28 @@ struct DraftProseField: View { .font(.system(size: 13)) .focused($isFocused) .draftFieldBox(isFocused: isFocused, minHeight: 54) + .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 + } } } @@ -148,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 ed2ec2c4..bf116cad 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,168 @@ 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)) + Text("to keep just the prompt.") + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.75)) + } + } + if let prompt = store.unfilledTokenPrompt { + HStack(spacing: 5) { + Text(prompt) + .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: "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 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 } + 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(clauses)) + } + 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 +246,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 +257,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 +306,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 +349,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 +371,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 4fee12af..6f4ac396 100644 --- a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift +++ b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift @@ -12,11 +12,13 @@ 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.templateFocus(.brief), + onTokenJump: store.tokenJump) } } } @@ -34,19 +36,23 @@ 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.templateFocus(.brief), onTokenJump: store.tokenJump) } 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( - 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 } } @@ -96,10 +102,12 @@ 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) + 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) @@ -175,7 +183,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) { @@ -192,10 +201,14 @@ 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.templateFocus(.brief), + onTokenJump: store.tokenJump) } if store.draftRequiresDaemonHeartbeat { @@ -264,13 +277,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.templateFocus(.brief), onTokenJump: store.tokenJump) } - DraftField(label: "Pause") { + DraftField( + label: "Pause", fromTemplate: store.templateSetFields.contains(.pausesBeforeWritesOnly) + ) { VStack(alignment: .leading, spacing: 6) { radio( "After every turn", isOn: !store.draftPausesBeforeWritesOnly, @@ -337,8 +356,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.templateFocus(.brief), onTokenJump: store.tokenJump) + } + + 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 9fb25de2..7086c0ab 100644 --- a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift +++ b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift @@ -44,7 +44,9 @@ extension ProjectCanvasView { id: node.id, hasSubmodules: store.worktreeReclaimOffers[node.id]?.facts.hasSubmodules == true)) }, - 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 @@ -113,6 +115,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..b36e1c80 --- /dev/null +++ b/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift @@ -0,0 +1,425 @@ +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 .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.focusRequest = .brief + 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.focusRequest = .brief + 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 960f18f2..431dfaf0 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. @@ -266,6 +276,29 @@ 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 templateTokenJumpRequested + case templateFocusConsumed + 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 @@ -276,9 +309,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: @@ -341,11 +389,22 @@ 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, .templateTokenJumpRequested, .templateFocusConsumed, + .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 @@ -551,6 +610,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? + /// 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. + 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. /// @@ -634,7 +724,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 @@ -660,6 +750,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 @@ -684,52 +778,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( @@ -767,6 +863,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 @@ -774,14 +871,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..4f376618 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,106 @@ 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 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. + 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) } + } + + /// 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) + } + /// 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 +333,54 @@ 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" + } + } + } + + /// 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 { + 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 68c19caa..16501577 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -243,6 +243,8 @@ struct SettingsView: View { .font(.caption2) .foregroundStyle(.secondary) } + + TemplatesSettingsSection() } .formStyle(.grouped) } 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/TemplateApplyTests.swift b/graphcode/Tests/TemplateApplyTests.swift new file mode 100644 index 00000000..ea68ed2d --- /dev/null +++ b/graphcode/Tests/TemplateApplyTests.swift @@ -0,0 +1,378 @@ +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) + } + + /// `⇥` 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 + @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/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/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..76563529 --- /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.focusRequest == .brief) + #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) + } +} 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.") + } +}