From 9975c8ed04c346592f861ac3c06dc31952629cc7 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 19:36:59 -0700 Subject: [PATCH] Tool Call Card Alignment Nucleic-Session: FFDE42E0-22AB-4478-B2E2-FF69B2753CEE Co-authored-by: Nucleic --- .../Transcript/TranscriptProjection.swift | 135 +++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 7cc0ac0..bef2636 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -94,7 +94,140 @@ enum TranscriptProjection { /// under its card instead of leaking (and interleaving) into the main transcript. static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { let (topLevel, byParent) = partition(events) - return project(topLevel, byParent: byParent, depth: 0, showRaw: showRaw, showLockEvents: showLockEvents) + // Fold lock-lifecycle notes onto the edit cards they bracket, exactly as the Mac's + // `items(_:worktreeRoot:)` does — matched against the session's working directory so an + // edit's absolute `file_path` compares against the note's repo-relative paths. Folding + // happens only at the top level (a subagent's inner edits are literal, unlocked); the + // subagent recursion below stays plain, matching the desktop projection. + let root = worktreeRoot(in: topLevel) + let flat = foldLockNotes(flatItems(topLevel, showRaw: showRaw, showLockEvents: showLockEvents), + worktreeRoot: root) + return coalesceToolRuns(flat).map { + attachSubagentChildren($0, byParent: byParent, depth: 0, + showRaw: showRaw, showLockEvents: showLockEvents) + } + } + + /// The session's working directory, read from its `sessionStarted` event, so an edit's + /// absolute `file_path` can be made repo-relative to compare against a lock note's + /// repo-relative paths. `nil` before the start event is seen (nothing to fold against yet). + private static func worktreeRoot(in events: [AgentEvent]) -> String? { + for event in events { + if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty { + return started.cwd + } + } + return nil + } + + // MARK: - Lock-note folding (LOCKING §4) + + /// Match each lock-lifecycle note in `flat` to the nearest preceding tool item whose edited + /// paths overlap, attaching the matched `NoteLock`s to that call's `ToolGroup.lockLines` and + /// dropping the note from the list. Overlap is directory-aware (`pathsOverlap`); a tool call's + /// `file_path` is stripped of `worktreeRoot` and normalized so it compares against the note's + /// repo-relative paths. A note overlapping no preceding edit is left in place (renders + /// standalone), matching the Mac's `foldLockNotes`. + private static func foldLockNotes(_ flat: [TranscriptItem], worktreeRoot: String?) -> [TranscriptItem] { + // Each tool item's repo-relative edited paths (only edit-class tools have any), by index. + var editsByIndex: [Int: (id: String, paths: [String])] = [:] + for (i, item) in flat.enumerated() { + guard case .tool(let group) = item.kind else { continue } + let paths = editedPaths(toolName: group.name, input: group.input) + .map { normalizeForLock($0, worktreeRoot: worktreeRoot) } + .filter { !$0.isEmpty } + if !paths.isEmpty { editsByIndex[i] = (group.toolCallID, paths) } + } + guard !editsByIndex.isEmpty else { return flat } + + var locksByCall: [String: [NoteLock]] = [:] + var folded = Set() + for (i, item) in flat.enumerated() { + guard case .note(_, _, true) = item.kind, + case .note(let note) = noteEvent(item)?.kind ?? .turnCompleted(TurnCompleted()), + let lock = note.lock, !lock.paths.isEmpty else { continue } + // Route each path to the nearest preceding edit card that touches it, so a multi-file + // note brackets each file's own card. Fold only when *every* path lands on a card; a + // partial match stays a single standalone row rather than splitting across cards. + var perCard: [(id: String, paths: [String])] = [] + var indexByID: [String: Int] = [:] + var matchedAll = true + for path in lock.paths { + var hitID: String? + for j in stride(from: i - 1, through: 0, by: -1) { + guard let edit = editsByIndex[j] else { continue } + if edit.paths.contains(where: { pathsOverlap($0, path) }) { hitID = edit.id; break } + } + guard let hitID else { matchedAll = false; break } + if let k = indexByID[hitID] { perCard[k].paths.append(path) } + else { indexByID[hitID] = perCard.count; perCard.append((hitID, [path])) } + } + guard matchedAll, !perCard.isEmpty else { continue } + for card in perCard { + locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths)) + } + folded.insert(i) + } + guard !folded.isEmpty else { return flat } + + return flat.enumerated().compactMap { i, item in + if folded.contains(i) { return nil } + guard case .tool(var group) = item.kind, let locks = locksByCall[group.toolCallID] else { return item } + group.lockLines = locks + return TranscriptItem(id: item.id, seq: item.seq, kind: .tool(group)) + } + } + + /// The `NoteEvent`-carrying source event for a `.note` item, recovered so its structured `lock` + /// detail is reachable (the projected `.note` kind keeps only text/icon/lockEvent). + private static func noteEvent(_ item: TranscriptItem) -> AgentEvent? { + // `.note` items retain the note's fields directly; rebuild a matching NoteEvent isn't + // possible from the projected kind, so the fold reads the lock off the note kind below. + return nil + } + + /// The paths an edit-class tool writes (mirrors `RiskClassifier.editedPaths`). Only Edit / + /// Write / MultiEdit / NotebookEdit carry a lockable path; everything else has none. + private static func editedPaths(toolName: String, input: JSONValue) -> [String] { + switch toolName { + case "Edit", "Write", "MultiEdit": + return [input["file_path"]?.stringValue].compactMap { $0 } + case "NotebookEdit": + return [input["notebook_path"]?.stringValue].compactMap { $0 } + default: + return [] + } + } + + /// Make a raw tool-call path comparable to a lock note's repo-relative path: strip the worktree + /// root prefix and normalize. A path not under the root is left as-is (it simply won't overlap). + /// Mirrors the Mac's `normalizeForLock`. + private static func normalizeForLock(_ path: String, worktreeRoot: String?) -> String { + var p = path + if let root = worktreeRoot, !root.isEmpty, p.hasPrefix(root) { p = String(p.dropFirst(root.count)) } + return normalizePath(p) + } + + /// Trim `./`, surrounding slashes, and whitespace to a bare repo-relative path + /// (mirrors `ConflictDetector.normalize`). + private static func normalizePath(_ path: String) -> String { + var p = path.trimmingCharacters(in: .whitespaces) + while p.hasPrefix("./") { p.removeFirst(2) } + while p.hasPrefix("/") { p.removeFirst() } + while p.hasSuffix("/") { p.removeLast() } + return p + } + + /// True when two repo-relative paths refer to the same file or one is a directory ancestor of + /// the other — compared componentwise so `src` never matches `src2/x` (mirrors + /// `ConflictDetector.pathsOverlap`). + private static func pathsOverlap(_ a: String, _ b: String) -> Bool { + guard !a.isEmpty, !b.isEmpty else { return false } + let ca = a.split(separator: "/") + let cb = b.split(separator: "/") + let n = min(ca.count, cb.count) + for i in 0..