From 4a8e8e910fdbc2a07899c473c83a082bd83d5ebf Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:17:14 -0700 Subject: [PATCH 1/7] Start Nod chat pane stream Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi From 9dbe6a59c57c1ed245703aab569f30d466d28802 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:30:56 -0700 Subject: [PATCH 2/7] Add Nod's chat pane in the agent slot of a chat-surface loop The transcript is folded from events.jsonl by a pure NodTranscript reducer; work cards, the composer and failure banners send NodCommands over control.sock through NodClient. NodGraphSlots is where the graph layer plugs in. Open in zsh tab types the command into a plain shell tab. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../LoopWorkspace/LoopWorkspaceFeature.swift | 51 ++ .../LoopWorkspace/LoopWorkspaceView.swift | 27 +- .../Sources/Features/Nod/NodChatFeature.swift | 372 ++++++++++++ .../Features/Nod/NodChatPaneView.swift | 431 ++++++++++++++ .../Features/Nod/NodChatPresentation.swift | 266 +++++++++ .../Sources/Features/Nod/NodClient.swift | 174 ++++++ .../Features/Nod/NodComposerView.swift | 481 +++++++++++++++ .../Sources/Features/Nod/NodGraphSlots.swift | 46 ++ graphcode/Sources/Features/Nod/NodStyle.swift | 93 +++ .../Sources/Features/Nod/NodTranscript.swift | 311 ++++++++++ .../Sources/Features/Nod/NodWorkCards.swift | 550 ++++++++++++++++++ .../Ghostty/TerminalSurfaceStore.swift | 24 + 12 files changed, 2824 insertions(+), 2 deletions(-) create mode 100644 graphcode/Sources/Features/Nod/NodChatFeature.swift create mode 100644 graphcode/Sources/Features/Nod/NodChatPaneView.swift create mode 100644 graphcode/Sources/Features/Nod/NodChatPresentation.swift create mode 100644 graphcode/Sources/Features/Nod/NodClient.swift create mode 100644 graphcode/Sources/Features/Nod/NodComposerView.swift create mode 100644 graphcode/Sources/Features/Nod/NodGraphSlots.swift create mode 100644 graphcode/Sources/Features/Nod/NodStyle.swift create mode 100644 graphcode/Sources/Features/Nod/NodTranscript.swift create mode 100644 graphcode/Sources/Features/Nod/NodWorkCards.swift diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 4f43c624..4a409b35 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -75,6 +75,10 @@ struct LoopWorkspaceFeature { /// derived from `projectPath`'s last component so it matches the sidebar exactly, /// including the global graph — whose path is a reserved URL, not a folder. var projectName: String + /// Nod's chat, for a loop whose backend opens in one (`AgentSurface.chat`). Built when + /// the agent pane first appears rather than here, so every place that makes this + /// state gets it without knowing what a chat is. + var nodChat: NodChatFeature.State? var id: UUID { node.id } } @@ -138,6 +142,8 @@ struct LoopWorkspaceFeature { case restartLoopTapped case showInGraphTapped case railTargetTapped(UUID) + case chatSurfaceAppeared + case nodChat(NodChatFeature.Action) } @Dependency(\.terminalLayoutStore) var terminalLayoutStore @@ -342,12 +348,22 @@ struct LoopWorkspaceFeature { } return .none + case .chatSurfaceAppeared: + return chatSurfaceAppeared(&state) + + case .nodChat(.delegate(let delegate)): + return nodChatDelegate(&state, delegate) + + case .nodChat: + return .none + case .stopLoopTapped, .restartLoopTapped, .showInGraphTapped, .railTargetTapped, .primaryExitAcknowledged, .lastTabClosed: // Handled by `AppFeature`'s parent `Reduce` — see the actions' own doc comment. return .none } } + .ifLet(\.nodChat, action: \.nodChat) { NodChatFeature() } } private func stepPaneFocus(_ state: inout State, by offset: Int) { @@ -372,3 +388,38 @@ struct LoopWorkspaceFeature { terminalLayoutStore.save(state.layout, forNode: state.node.id) } } + +extension LoopWorkspaceFeature { + func chatSurfaceAppeared(_ state: inout State) -> Effect { + guard state.node.backend.surface == .chat else { return .none } + let node = state.node + if state.nodChat == nil { + state.nodChat = NodChatFeature.State( + nodeID: node.id, loopTitle: node.title, loopType: node.loopType, + branch: node.worktreeBinding?.branch, goal: node.goal?.summary) + } else { + state.nodChat?.loopTitle = node.title + state.nodChat?.goal = node.goal?.summary + } + return .none + } + + /// The chat's requests that are about panes and tabs. The rest — the goal, sign-in, + /// forks into siblings, plans as Composites — belong to the levels that own those, and + /// pass through untouched. + func nodChatDelegate(_ state: inout State, _ delegate: NodChatFeature.Action.Delegate) + -> Effect + { + guard case .openInShellTab(let command) = delegate else { return .none } + let shellTab = state.layout.tabs.first { tab in + !tab.surfaces.contains(where: \.launchesClaudeCode) + } + let tab = shellTab ?? TabLayout(primary: SurfaceRef(id: UUID(), launchesClaudeCode: false)) + if shellTab == nil { state.layout.tabs.append(tab) } + state.layout.selectedTabID = tab.id + persist(state) + // Typed, not run: the human sees the command at the prompt and presses ⏎ themselves. + terminalSurfaceClient.typeText(tab.focusedSurface.id, command) + return .none + } +} diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index 335267e4..d11697c8 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -57,6 +57,9 @@ struct LoopWorkspaceView: View { .onChange(of: store.id) { _, _ in store.send(.mailroomContentChanged(hasContent: mailroomHasContent)) } + .onChange(of: store.node.goal?.summary) { _, _ in + if store.nodChat != nil { store.send(.chatSurfaceAppeared) } + } .onDisappear { store.send(.workspaceLeft) } // The folder header goes in the toolbar, not in the `VStack` above, and the pane // does *not* claim the titlebar inset. Both were tried: `.ignoresSafeArea(.top)` @@ -318,12 +321,16 @@ struct LoopWorkspaceView: View { // 22pt of "which pane is this and does it have the keyboard". The veil below says // only the second half, and two identical black rectangles said neither. PaneHeaderView( - title: ref.launchesClaudeCode ? "agent" : "shell", + title: ref.launchesClaudeCode ? (isChat ? "chat" : "agent") : "shell", isFocused: isFocused && tab.id == store.layout.selectedTabID, detail: ref.launchesClaudeCode ? store.node.backend.displayName.lowercased() : "zsh", canClose: tab.isSplit, onClose: { store.send(.paneClosed(tabID: tab.id, surfaceID: ref.id)) }) - terminal(tab: tab, ref: ref) + if ref.launchesClaudeCode && isChat { + chat(tab: tab, ref: ref) + } else { + terminal(tab: tab, ref: ref) + } } // `ref.id` (not just this slot's structural position) is a surface's real // identity — without this, collapsing a split (which reassigns `tab.primary` to @@ -332,6 +339,22 @@ struct LoopWorkspaceView: View { .id(ref.id) } + /// Nod's loops open in a chat where a CLI's terminal would be — the same tab strip and + /// splits around it, so a zsh tab is still one ⌘-number away. + private var isChat: Bool { store.node.backend.surface == .chat } + + @ViewBuilder + private func chat(tab: TabLayout, ref: SurfaceRef) -> some View { + if let chatStore = store.scope(state: \.nodChat, action: \.nodChat) { + NodChatPaneView(store: chatStore, projectName: store.projectName) + .simultaneousGesture( + TapGesture().onEnded { store.send(.paneFocused(tabID: tab.id, surfaceID: ref.id)) }) + } else { + NodStyle.paneBackground + .onAppear { store.send(.chatSurfaceAppeared) } + } + } + private func terminal(tab: TabLayout, ref: SurfaceRef) -> some View { GhosttyTerminalView( // The pane's own id, which is also the key its live surface is held under while diff --git a/graphcode/Sources/Features/Nod/NodChatFeature.swift b/graphcode/Sources/Features/Nod/NodChatFeature.swift new file mode 100644 index 00000000..b7d33ad6 --- /dev/null +++ b/graphcode/Sources/Features/Nod/NodChatFeature.swift @@ -0,0 +1,372 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit + +/// Nod's chat pane: the transcript folded from `events.jsonl`, the work cards' decisions +/// and the composer, all sent back as `NodCommand`s over `control.sock`. +/// +/// Everything the pane cannot do itself — open a shell tab, edit the goal, sign in, fork +/// into a sibling loop, run a plan as a Composite — goes up as a delegate action, so the +/// workspace and the graph layer decide what those mean. +@Reducer +struct NodChatFeature { + @ObservableState + struct State: Equatable { + var nodeID: UUID + var stateDirectory: URL + var loopTitle: String + var loopType: LoopType + var branch: String? + var goal: String? + var editPolicy: NodSettings.EditPolicy = .reviewHunks + + var transcript = NodTranscript() + /// The model picked from the chip since the session started; the log only names the + /// model a run started on. + var chosenModel: String? + + var draft = "" + var attachments: [NodAttachment] = [] + /// Loops and files `@` can name, supplied by whoever knows the graph. + var mentionCandidates: [NodMention] = [] + + var expandedTools: Set = [] + var expandedWork: Set = [] + var isCompactedExpanded = false + var isGoalExpanded = false + var forkMenuMessageID: String? + var commentingHunkID: String? + var hunkComment = "" + var sendError: String? + + init( + nodeID: UUID, stateDirectory: URL? = nil, loopTitle: String, loopType: LoopType, + branch: String? = nil, goal: String? = nil + ) { + self.nodeID = nodeID + self.stateDirectory = stateDirectory ?? NodStateDirectory.url(forNode: nodeID) + self.loopTitle = loopTitle + self.loopType = loopType + self.branch = branch + self.goal = goal + } + + var model: String? { chosenModel ?? transcript.session?.model } + + var trigger: NodComposerTrigger? { NodComposerTrigger.detect(in: draft) } + + /// Failures and permission asks open on their own; everything else waits for a click. + func isToolExpanded(_ card: NodTranscript.ToolCard) -> Bool { + expandedTools.contains(card.call.callID) || card.status == .error + } + } + + enum Action: Equatable { + case task + case eventsReceived([NodEventRecord]) + + case draftChanged(String) + /// ⏎ — waits for the running turn to end. + case returnPressed + /// ⌘⏎ — lands at the next tool boundary without interrupting. + case commandReturnPressed + /// esc — closes an open menu first, and only then stops Nod. + case escapePressed + case stopTapped + case slashCommandChosen(NodSlashCommand) + /// `alternate` is ⇥'s other action: attach a running loop, message a finished one. + case mentionChosen(NodMention, alternate: Bool) + case attachmentAdded(NodAttachment) + case attachmentRemoved(Int) + case modelChosen(String) + + case toolToggled(callID: String) + case workToggled(turn: Int) + case compactedToggled + case goalHeaderToggled + case openInShellTabTapped(command: String) + case hunkDecided(hunkID: String, NodHunkDecision) + case hunkCommentStarted(hunkID: String) + case hunkCommentChanged(String) + case hunkCommentSubmitted + case hunkCommentCancelled + case permissionDecided(askID: String, NodPermissionDecision) + case markGoalDoneTapped + case editGoalTapped + case forkMenuToggled(messageID: String?) + case forkChosen(messageID: String, asSibling: Bool) + case runPlanTapped(planID: String, mode: NodCommand.RunPlan.Mode) + case sendDraftTapped(draftID: String, text: String) + case compactNowTapped + case signInTapped + case raiseCapTapped + case errorDismissed + + case commandFinished(NodCommandOutcome) + case delegate(Delegate) + + enum Delegate: Equatable { + /// "Open in zsh tab": type the command into a plain shell tab beside the chat. + case openInShellTab(command: String) + case editGoal + case signIn + case raiseSpendCap + case forkAsSibling(messageID: String) + case runPlanAsComposite(planID: String) + /// `/handoff`, `/ask`, `/promote` — verbs that act on other loops. + case graphCommand(name: String, argument: String) + case messageLoop(UUID) + case editPolicyChosen(NodSettings.EditPolicy) + } + } + + enum NodCommandOutcome: Equatable { + case sent(NodCommand) + case failed(String) + } + + private enum CancelID { case events } + + @Dependency(\.nodClient) var nodClient + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .task: + let directory = state.stateDirectory + return .run { send in + for await batch in nodClient.events(directory) { + await send(.eventsReceived(batch)) + } + } + .cancellable(id: CancelID.events, cancelInFlight: true) + + case .eventsReceived(let records): + for record in records { state.transcript.apply(record) } + return .none + + case .draftChanged(let text): + state.draft = text + return .none + + case .returnPressed: + return submit(&state, delivery: .queue) + + case .commandReturnPressed: + return submit(&state, delivery: .steer) + + case .escapePressed: + if state.forkMenuMessageID != nil { + state.forkMenuMessageID = nil + return .none + } + if state.commentingHunkID != nil { + return .send(.hunkCommentCancelled) + } + if state.trigger != nil { + state.draft = "" + return .none + } + guard state.transcript.isRunning else { return .none } + return command(.stop, state) + + case .stopTapped: + return command(.stop, state) + + case .slashCommandChosen(let slash): + state.draft = "/\(slash.name) " + return .none + + case .mentionChosen(let mention, let alternate): + replaceMentionQuery(&state, with: mention.title) + switch mention.kind { + case .loop(let id, let isRunning, _): + if isRunning != alternate { return .send(.delegate(.messageLoop(id))) } + state.attachments.append( + NodAttachment(kind: .loopTranscript, reference: id.uuidString, label: mention.title)) + case .file(let path): + state.attachments.append(NodAttachment(kind: .file, reference: path, label: nil)) + } + return .none + + case .attachmentAdded(let attachment): + guard !state.attachments.contains(attachment) else { return .none } + state.attachments.append(attachment) + return .none + + case .attachmentRemoved(let index): + guard state.attachments.indices.contains(index) else { return .none } + state.attachments.remove(at: index) + return .none + + case .modelChosen(let model): + return command(.setModel(.init(model: model)), state) + + case .toolToggled(let callID): + state.expandedTools.formSymmetricDifference([callID]) + return .none + + case .workToggled(let turn): + state.expandedWork.formSymmetricDifference([turn]) + return .none + + case .compactedToggled: + state.isCompactedExpanded.toggle() + return .none + + case .goalHeaderToggled: + state.isGoalExpanded.toggle() + return .none + + case .openInShellTabTapped(let command): + return .send(.delegate(.openInShellTab(command: command))) + + case .hunkDecided(let hunkID, let decision): + if decision == .comment { return .send(.hunkCommentStarted(hunkID: hunkID)) } + return command(.resolveHunk(.init(hunkID: hunkID, decision: decision)), state) + + case .hunkCommentStarted(let hunkID): + state.commentingHunkID = hunkID + state.hunkComment = "" + return .none + + case .hunkCommentChanged(let text): + state.hunkComment = text + return .none + + case .hunkCommentSubmitted: + let note = state.hunkComment.trimmingCharacters(in: .whitespacesAndNewlines) + guard let hunkID = state.commentingHunkID, !note.isEmpty else { return .none } + state.commentingHunkID = nil + state.hunkComment = "" + return command(.resolveHunk(.init(hunkID: hunkID, decision: .comment, note: note)), state) + + case .hunkCommentCancelled: + state.commentingHunkID = nil + state.hunkComment = "" + return .none + + case .permissionDecided(let askID, let decision): + return command(.resolvePermission(.init(askID: askID, decision: decision)), state) + + case .markGoalDoneTapped: + return command(.markGoalDone, state) + + case .editGoalTapped: + return .send(.delegate(.editGoal)) + + case .forkMenuToggled(let messageID): + state.forkMenuMessageID = state.forkMenuMessageID == messageID ? nil : messageID + return .none + + case .forkChosen(let messageID, let asSibling): + state.forkMenuMessageID = nil + if asSibling { return .send(.delegate(.forkAsSibling(messageID: messageID))) } + return command(.fork(.init(messageID: messageID)), state) + + case .runPlanTapped(let planID, let mode): + if mode == .composite { return .send(.delegate(.runPlanAsComposite(planID: planID))) } + guard let plan = state.transcript.plan(id: planID) else { return .none } + return command(.runPlan(.init(planID: planID, steps: plan.steps, mode: .here)), state) + + case .sendDraftTapped(let draftID, let text): + return command(.sendDraft(.init(draftID: draftID, text: text)), state) + + case .compactNowTapped: + return command(.compact, state) + + case .signInTapped: + return .send(.delegate(.signIn)) + + case .raiseCapTapped: + return .send(.delegate(.raiseSpendCap)) + + case .errorDismissed: + state.sendError = nil + return .none + + case .commandFinished(.sent(let command)): + state.sendError = nil + if case .setModel(let payload) = command { state.chosenModel = payload.model } + return .none + + case .commandFinished(.failed(let message)): + state.sendError = message + return .none + + case .delegate: + return .none + } + } + } + + private func submit(_ state: inout State, delivery: NodDelivery) -> Effect { + let text = state.draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty || !state.attachments.isEmpty else { return .none } + + if text.hasPrefix("/") { + let parts = text.dropFirst().split(separator: " ", maxSplits: 1) + let name = parts.first.map(String.init) ?? "" + let argument = parts.count > 1 ? String(parts[1]) : "" + switch name { + case "compact": + state.draft = "" + return command(.compact, state) + case "goal": + state.draft = "" + return .send(.delegate(.editGoal)) + case "fork": + guard let messageID = state.transcript.lastAssistantMessageID else { return .none } + state.draft = "" + return .send(.forkMenuToggled(messageID: messageID)) + case "handoff", "ask", "promote": + state.draft = "" + return .send(.delegate(.graphCommand(name: name, argument: argument))) + default: + break + } + } + + let send = NodCommand.Send(text: text, delivery: delivery, attachments: state.attachments) + state.draft = "" + state.attachments = [] + return command(.send(send), state) + } + + private func command(_ command: NodCommand, _ state: State) -> Effect { + let directory = state.stateDirectory + return .run { send in + do { + try await nodClient.send(directory, command) + await send(.commandFinished(.sent(command))) + } catch { + await send(.commandFinished(.failed(error.localizedDescription))) + } + } + } + + private func replaceMentionQuery(_ state: inout State, with title: String) { + guard case .mention = state.trigger, let at = state.draft.lastIndex(of: "@") else { return } + state.draft = String(state.draft[.. NodEvent.PlanProposed? { + for turn in turns.reversed() { + for item in turn.items.reversed() { + if case .plan(let plan) = item, plan.planID == id { return plan } + } + } + return nil + } + + var lastAssistantMessageID: String? { + for turn in turns.reversed() { + for item in turn.items.reversed() { + if case .text(let message) = item { return message.id } + } + } + return nil + } +} diff --git a/graphcode/Sources/Features/Nod/NodChatPaneView.swift b/graphcode/Sources/Features/Nod/NodChatPaneView.swift new file mode 100644 index 00000000..d60c7a59 --- /dev/null +++ b/graphcode/Sources/Features/Nod/NodChatPaneView.swift @@ -0,0 +1,431 @@ +import AppKit +import ComposableArchitecture +import GraphcodeKit +import SwiftUI + +/// Nod's chat, in the slot a CLI loop's terminal takes (design 1a, with 1b's folded Work +/// block and queued row): the goal pinned on top, one reading column, failure banners and +/// the composer at the bottom. The graph layer draws into `NodGraphSlots`. +struct NodChatPaneView: View { + @Bindable var store: StoreOf + /// For the permission card's "Always in ". + var projectName: String + + @Environment(\.nodGraphSlots) private var slots + + var body: some View { + VStack(spacing: 0) { + if store.goal != nil { + goalHeader + } + slots.contextStrip?() + transcript + bottom + } + .background(NodStyle.paneBackground) + .task { await store.send(.task).finish() } + } + + // MARK: Goal header + + private var goalTint: Color { store.loopType.accent } + + private var goalHeader: some View { + let verdict = NodChatPresentation.goalVerdict(for: store.transcript) + return VStack(alignment: .leading, spacing: 0) { + Button { + store.send(.goalHeaderToggled) + } label: { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2).fill(goalTint).frame(width: 8, height: 8) + Text(store.goal ?? "") + .font(.system(size: 12)) + .foregroundStyle(Color.white.opacity(0.85)) + .lineLimit(store.isGoalExpanded ? nil : 1) + .multilineTextAlignment(.leading) + Spacer(minLength: 8) + Text(NodChatPresentation.verdictLabel(verdict)) + .font(.system(size: 11)) + .foregroundStyle(Self.verdictColor(verdict)) + Text(store.isGoalExpanded ? "▴" : "▾") + .font(.system(size: 11)) + .foregroundStyle(NodStyle.muted) + } + .padding(.horizontal, 18) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if store.isGoalExpanded, let check = store.transcript.lastGoalCheck { + NodGoalClauseList(clauses: check.clauses) + .padding(.horizontal, 36) + .padding(.bottom, 10) + } + } + .background(goalTint.opacity(0.08)) + .overlay(alignment: .bottom) { Rectangle().fill(goalTint.opacity(0.25)).frame(height: 1) } + } + + static func verdictColor(_ verdict: NodChatPresentation.GoalVerdict) -> Color { + switch verdict { + case .unchecked: return NodStyle.muted + case .notYet: return NodStyle.attentionInk.opacity(0.9) + case .holds: return NodStyle.met + } + } + + // MARK: Transcript + + private var visibleTurns: [NodTranscript.Turn] { + store.isCompactedExpanded + ? store.transcript.turns : store.transcript.turns.filter { !$0.isCompacted } + } + + private var compactedCount: Int { store.transcript.turns.filter(\.isCompacted).count } + + private var transcript: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if compactedCount > 0 { + compactedDivider + } + ForEach(visibleTurns) { turn in + turnView(turn) + if let slot = slots.afterTurn?(turn.number) { + slot + } + } + ForEach(store.transcript.queued, id: \.id) { message in + queuedRow(message) + } + if store.transcript.turns.isEmpty && store.transcript.queued.isEmpty { + emptyState + } + } + .frame(maxWidth: NodStyle.columnWidth, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 22) + .padding(.bottom, 12) + .frame(maxWidth: .infinity) + } + .defaultScrollAnchor(.bottom) + } + + private var compactedDivider: some View { + Button { + store.send(.compactedToggled) + } label: { + HStack(spacing: 10) { + Rectangle().fill(NodStyle.hairline).frame(height: 1) + Text( + store.isCompactedExpanded + ? "Compacted \(compactedCount) turns · hide originals" + : "Compacted \(compactedCount) turns · show originals" + ) + .font(.system(size: 11)) + .foregroundStyle(NodStyle.muted) + .fixedSize() + Rectangle().fill(NodStyle.hairline).frame(height: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var emptyState: some View { + Text(store.transcript.session == nil ? "Waiting for Nod to start…" : "Nod is ready.") + .font(.system(size: 12.5)) + .foregroundStyle(NodStyle.muted) + .frame(maxWidth: .infinity) + .padding(.top, 40) + } + + @ViewBuilder + private func turnView(_ turn: NodTranscript.Turn) -> some View { + VStack(alignment: .leading, spacing: 10) { + if let prompt = turn.prompt { + promptBubble(prompt, origin: turn.origin) + } else if turn.origin == .timer || turn.origin == .goalCheck { + Text(turn.origin == .timer ? "Timed run · turn \(turn.number)" : "Goal check") + .font(.system(size: 11)) + .foregroundStyle(NodStyle.muted) + } + ForEach(NodChatPresentation.blocks(for: turn)) { block in + blockView(block) + } + if turn.isRunning, let activity = store.transcript.activity, + turn.number == store.transcript.currentTurn?.number + { + HStack(spacing: 8) { + NodSpinner() + Text(activity).font(.system(size: 12)).foregroundStyle(Color.white.opacity(0.7)) + } + .padding(.vertical, 4) + } + if let ended = turn.ended, ended.filesChanged > 0 { + Text( + "\(ended.filesChanged) file\(ended.filesChanged == 1 ? "" : "s") · +\(ended.added) −\(ended.removed)" + ) + .font(.system(size: 11)) + .foregroundStyle(NodStyle.muted) + .frame(maxWidth: .infinity, alignment: .trailing) + } else if turn.wasInterrupted { + Text("Stopped").font(.system(size: 11)).foregroundStyle(NodStyle.muted) + } + } + .opacity(turn.isCompacted ? 0.6 : 1) + } + + private func promptBubble(_ message: NodEvent.UserMessage, origin: NodTurnOrigin) -> some View { + VStack(alignment: .trailing, spacing: 4) { + if origin == .handoff || origin == .mail { + Text(origin == .handoff ? "Handoff" : "Mail") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(NodStyle.muted) + } + if !message.attachments.isEmpty { + HStack(spacing: 6) { + ForEach(Array(message.attachments.enumerated()), id: \.offset) { _, attachment in + NodChip { + Text( + "\(NodComposerView.glyph(attachment.kind)) \(NodComposerView.label(attachment))") + } + } + } + } + Text(message.text) + .font(.system(size: 13.5)) + .lineSpacing(3) + .foregroundStyle(NodStyle.ink) + .textSelection(.enabled) + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(RoundedRectangle(cornerRadius: 12).fill(NodStyle.bubble)) + .frame(maxWidth: 520, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + @ViewBuilder + private func blockView(_ block: NodTurnBlock) -> some View { + switch block { + case .work(let summary): + NodWorkBlockView( + summary: summary, + isExpanded: store.expandedWork.contains(summary.turn), + onToggle: { store.send(.workToggled(turn: summary.turn)) }, + toolCard: toolCard) + case .item(let item): + itemView(item) + } + } + + @ViewBuilder + private func itemView(_ item: NodTranscript.Item) -> some View { + switch item { + case .text(let message): + assistantText(message) + case .steer(let message): + steerRow(message) + case .tool(let card): + toolCard(card) + case .hunk(let card): + NodHunkCardView( + card: card, + isCommenting: store.commentingHunkID == card.staged.hunkID, + comment: $store.hunkComment.sending(\.hunkCommentChanged), + onDecide: { store.send(.hunkDecided(hunkID: card.staged.hunkID, $0)) }, + onSubmitComment: { store.send(.hunkCommentSubmitted) }, + onCancelComment: { store.send(.hunkCommentCancelled) }) + case .permission(let card): + NodPermissionCardView(card: card, projectName: projectName) { + store.send(.permissionDecided(askID: card.ask.askID, $0)) + } + case .goalCheck(let check): + NodGoalCheckCardView( + check: check, goalTint: goalTint, + onMarkDone: { store.send(.markGoalDoneTapped) }, + onEditGoal: { store.send(.editGoalTapped) }) + case .plan(let plan): + if let slot = slots.plan { + slot(plan) + } else { + NodPlanCardView(plan: plan) { store.send(.runPlanTapped(planID: plan.planID, mode: $0)) } + } + case .mailDraft(let draft): + if let slot = slots.mailDraft { + slot(draft) + } else { + NodMailDraftCardView(draft: draft) { + store.send(.sendDraftTapped(draftID: draft.draftID, text: draft.text)) + } + } + } + } + + private func toolCard(_ card: NodTranscript.ToolCard) -> NodToolCardView { + NodToolCardView( + card: card, + isExpanded: store.state.isToolExpanded(card), + onToggle: { store.send(.toolToggled(callID: card.call.callID)) }, + onOpenInShell: { store.send(.openInShellTabTapped(command: $0)) }) + } + + private func assistantText(_ message: NodTranscript.Message) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(Self.markdown(message.text)) + .font(.system(size: 13.5)) + .lineSpacing(4) + .foregroundStyle(NodStyle.body) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + if message.isFinal { + HStack(spacing: 14) { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(message.text, forType: .string) + } + Button("Fork from here") { store.send(.forkMenuToggled(messageID: message.id)) } + Spacer() + } + .buttonStyle(NodLinkButtonStyle()) + .font(.system(size: 11)) + .opacity(store.forkMenuMessageID == message.id ? 1 : 0.7) + if store.forkMenuMessageID == message.id { + NodForkMenuView( + onBranch: { store.send(.forkChosen(messageID: message.id, asSibling: false)) }, + onSibling: { store.send(.forkChosen(messageID: message.id, asSibling: true)) }) + } + } + } + } + + /// Inline code and emphasis from the model's markdown; block structure stays plain text + /// so a half-streamed fence never reflows the column. + static func markdown(_ text: String) -> AttributedString { + (try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) + ?? AttributedString(text) + } + + private func steerRow(_ message: NodEvent.UserMessage) -> some View { + HStack(spacing: 8) { + Text("Steered") + .font(.system(size: 10.5, weight: .bold)) + .textCase(.uppercase) + .foregroundStyle(NodStyle.actionInk) + Text("“\(message.text)”").font(.system(size: 12.5)).foregroundStyle(NodStyle.secondary) + Spacer(minLength: 0) + } + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background(RoundedRectangle(cornerRadius: 9).fill(NodStyle.action.opacity(0.08))) + } + + /// Design 1b's queued row: what will start the next turn, with the way to send it now. + private func queuedRow(_ message: NodEvent.UserMessage) -> some View { + HStack(spacing: 8) { + Text("Queued") + .font(.system(size: 10.5, weight: .bold)) + .textCase(.uppercase) + .foregroundStyle(Color.white.opacity(0.5)) + Text("“\(message.text)”") + .font(.system(size: 12.5)) + .foregroundStyle(NodStyle.secondary) + .lineLimit(2) + Spacer(minLength: 8) + if let turn = store.transcript.currentTurn?.number { + Text("sends after turn \(turn)").font(.system(size: 11)).foregroundStyle(NodStyle.muted) + } + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .overlay( + RoundedRectangle(cornerRadius: 9) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + .foregroundStyle(Color.white.opacity(0.18))) + } + + // MARK: Bottom + + private var bottom: some View { + VStack(spacing: 8) { + slots.aboveComposer?() + if let banner = NodChatPresentation.banner(for: store.transcript) { + NodBannerView( + banner: banner, + onSignIn: { store.send(.signInTapped) }, + onCompact: { store.send(.compactNowTapped) }, + onRaiseCap: { store.send(.raiseCapTapped) }) + } + if let error = store.sendError { + HStack(spacing: 8) { + Text(error).font(.system(size: 11.5)).foregroundStyle(NodStyle.failed) + Spacer() + Button("Dismiss") { store.send(.errorDismissed) }.buttonStyle(NodLinkButtonStyle()) + } + } + NodComposerView(store: store) + } + .frame(maxWidth: NodStyle.columnWidth) + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 16) + .frame(maxWidth: .infinity) + } +} + +/// Design section 8: what a CLI hides in its scrollback, as one line above the composer. +struct NodBannerView: View { + let banner: NodChatPresentation.Banner + let onSignIn: () -> Void + let onCompact: () -> Void + let onRaiseCap: () -> Void + + var body: some View { + HStack(spacing: 10) { + Circle().fill(tint).frame(width: 7, height: 7) + Text(message) + .font(.system(size: 12)) + .foregroundStyle(NodStyle.ink) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + action + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(RoundedRectangle(cornerRadius: 10).fill(tint.opacity(0.09))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(tint.opacity(0.4), lineWidth: 1)) + } + + private var tint: Color { + switch banner { + case .contextNearlyFull: return NodStyle.actionInk + case .signInExpired, .spendCap: return NodStyle.attention + case .other: return NodStyle.failed + } + } + + private var message: String { + switch banner { + case .signInExpired(let text), .spendCap(let text), .other(let text): return text + case .contextNearlyFull(let percent): return "\(percent)% of context used" + } + } + + @ViewBuilder + private var action: some View { + switch banner { + case .signInExpired: + Button("Sign in", action: onSignIn).buttonStyle(NodButtonStyle(weight: .primary)) + case .contextNearlyFull: + Button("Compact now", action: onCompact).buttonStyle(NodButtonStyle(weight: .secondary)) + case .spendCap: + Button("Raise cap", action: onRaiseCap).buttonStyle(NodButtonStyle(weight: .primary)) + case .other: + EmptyView() + } + } +} diff --git a/graphcode/Sources/Features/Nod/NodChatPresentation.swift b/graphcode/Sources/Features/Nod/NodChatPresentation.swift new file mode 100644 index 00000000..694c79b4 --- /dev/null +++ b/graphcode/Sources/Features/Nod/NodChatPresentation.swift @@ -0,0 +1,266 @@ +import Foundation +import GraphcodeKit + +/// What the pane draws for one turn: its cards in order, except that a turn past +/// `foldThreshold` tool calls gathers them into one Work block (design 1b) so a long turn +/// stays one screen. Failed calls stay out of the fold — failures open on their own. +enum NodTurnBlock: Equatable, Identifiable { + case item(NodTranscript.Item) + case work(NodWorkSummary) + + var id: String { + switch self { + case .item(let item): return item.id + case .work(let summary): return "work:\(summary.turn)" + } + } +} + +struct NodWorkSummary: Equatable { + var turn: Int + var tools: [NodTranscript.ToolCard] + + /// `read 3 · searched 1 · edited 2 · ran 1`, in the order the verbs first appeared. + var line: String { + var counts: [(verb: String, count: Int)] = [] + for tool in tools { + let verb = NodToolVerb(tool: tool.call.tool).pastTense + if let index = counts.firstIndex(where: { $0.verb == verb }) { + counts[index].count += 1 + } else { + counts.append((verb, 1)) + } + } + return counts.map { "\($0.verb) \($0.count)" }.joined(separator: " · ") + } + + var running: NodTranscript.ToolCard? { tools.last { $0.status == .running } } + + var durationMs: Int { tools.compactMap(\.result?.durationMs).reduce(0, +) } +} + +enum NodToolVerb: Equatable { + case read, search, edit, shell, other + + init(tool: String) { + switch tool.lowercased() { + case "read", "view", "ls", "notebookread": self = .read + case "grep", "glob", "search", "websearch", "webfetch": self = .search + case "edit", "write", "multiedit", "notebookedit", "apply_patch": self = .edit + case "bash", "shell", "run": self = .shell + default: self = .other + } + } + + var pastTense: String { + switch self { + case .read: return "read" + case .search: return "searched" + case .edit: return "edited" + case .shell: return "ran" + case .other: return "used" + } + } + + var label: String { + switch self { + case .read: return "Read" + case .search: return "Search" + case .edit: return "Edit" + case .shell: return "Shell" + case .other: return "Tool" + } + } +} + +enum NodChatPresentation { + static let foldThreshold = 5 + /// Design 8b: the warning shows from here; the runtime compacts on its own at 95%. + static let contextWarningThreshold = 0.8 + + static func blocks(for turn: NodTranscript.Turn) -> [NodTurnBlock] { + let toolCount = turn.items.reduce(0) { count, item in + if case .tool = item { return count + 1 } + return count + } + guard toolCount > foldThreshold else { return turn.items.map(NodTurnBlock.item) } + + var blocks: [NodTurnBlock] = [] + var folded: [NodTranscript.ToolCard] = [] + var workIndex: Int? + for item in turn.items { + if case .tool(let card) = item, card.status != .error { + folded.append(card) + if workIndex == nil { + workIndex = blocks.count + blocks.append(.work(NodWorkSummary(turn: turn.number, tools: []))) + } + } else { + blocks.append(.item(item)) + } + } + if let workIndex { + blocks[workIndex] = .work(NodWorkSummary(turn: turn.number, tools: folded)) + } + return blocks + } + + enum Banner: Equatable { + case signInExpired(String) + case contextNearlyFull(percent: Int) + case spendCap(String) + case other(String) + } + + /// One banner above the composer: a reported failure wins over the context warning, + /// which `usage` alone drives. + static func banner(for transcript: NodTranscript) -> Banner? { + if let failure = transcript.failure { + switch failure.kind { + case .signInExpired: return .signInExpired(failure.message) + case .spendCap: return .spendCap(failure.message) + case .contextFull: + return .contextNearlyFull(percent: percent(transcript.usage?.contextUsed ?? 0.95)) + case .permissionUnavailable, .engineError: return .other(failure.message) + } + } + if let used = transcript.usage?.contextUsed, used >= contextWarningThreshold { + return .contextNearlyFull(percent: percent(used)) + } + return nil + } + + private static func percent(_ fraction: Double) -> Int { Int((fraction * 100).rounded()) } + + enum GoalVerdict: Equatable { + case unchecked + case notYet(checks: Int) + case holds(metClauses: Int, of: Int) + } + + static func goalVerdict(for transcript: NodTranscript) -> GoalVerdict { + guard let check = transcript.lastGoalCheck else { return .unchecked } + if check.met { + return .holds(metClauses: check.clauses.filter(\.met).count, of: check.clauses.count) + } + return .notYet(checks: transcript.goalCheckCount) + } + + static func verdictLabel(_ verdict: GoalVerdict) -> String { + switch verdict { + case .unchecked: return "not checked yet" + case .notYet(let checks): return "not yet · checked \(checks)×" + case .holds(let met, let total): return "✓ holds · \(met) / \(total)" + } + } + + /// What sits beside the model chip: dollars on the Claude engine, premium requests on + /// Copilot, which bills those instead. + static func costLabel(for transcript: NodTranscript) -> String? { + if transcript.session?.engine == .copilotSDK || transcript.totalPremiumRequests > 0 { + let count = transcript.totalPremiumRequests + return count == 0 ? nil : "\(count) premium" + } + guard transcript.totalCostUSD > 0 else { return nil } + return String(format: "$%.2f", transcript.totalCostUSD) + } + + /// `Sonnet`, from `claude-sonnet-4-5` or `sonnet`; anything unrecognised is shown as is. + static func modelLabel(_ model: String) -> String { + let lower = model.lowercased() + for family in ["opus", "sonnet", "haiku", "fable"] where lower.contains(family) { + return family.prefix(1).uppercased() + family.dropFirst() + } + if lower.hasPrefix("gpt-") { return "GPT-" + model.dropFirst(4) } + return model + } + + static func duration(ms: Int) -> String { + ms < 1000 ? String(format: "%.1fs", Double(ms) / 1000) : "\(Int((Double(ms) / 1000).rounded()))s" + } +} + +// MARK: - Composer menus + +struct NodSlashCommand: Equatable, Identifiable { + enum Group: String, Equatable { + case thisLoop = "This loop" + case graph = "The graph" + } + + var name: String + var detail: String + var group: Group + + var id: String { name } + + /// Design 2b: the CLIs' verbs where they overlap, graph verbs grouped apart because they + /// act on other loops. + static let all: [NodSlashCommand] = [ + .init(name: "plan", detail: "Draft steps before touching anything", group: .thisLoop), + .init(name: "goal", detail: "Set or edit the done condition", group: .thisLoop), + .init(name: "fork", detail: "Branch the conversation from here", group: .thisLoop), + .init(name: "compact", detail: "Summarise older turns, keep the goal", group: .thisLoop), + .init(name: "handoff", detail: "Pass a brief to a downstream loop", group: .graph), + .init(name: "ask", detail: "Message a sibling loop", group: .graph), + .init(name: "promote", detail: "Turn this Main loop into a type", group: .graph), + ] + + static func matching(_ query: String) -> [NodSlashCommand] { + let query = query.lowercased() + guard !query.isEmpty else { return all } + return all.filter { $0.name.hasPrefix(query) } + } +} + +/// Something `@` can name: a loop (message it while it runs, attach its transcript once it +/// has finished — design 2c) or a file. +struct NodMention: Equatable, Identifiable { + enum Kind: Equatable { + case loop(id: UUID, isRunning: Bool, detail: String) + case file(path: String) + } + + var title: String + var kind: Kind + + var id: String { + switch kind { + case .loop(let id, _, _): return "loop:\(id)" + case .file(let path): return "file:\(path)" + } + } + + /// The action ⏎ takes; ⇥ switches to the other one. + var defaultActionLabel: String { + switch kind { + case .loop(_, let isRunning, _): return isRunning ? "message" : "attach" + case .file: return "attach" + } + } + + static func matching(_ query: String, in candidates: [NodMention]) -> [NodMention] { + let query = query.lowercased() + guard !query.isEmpty else { return candidates } + return candidates.filter { $0.title.lowercased().contains(query) } + } +} + +/// The trigger the caret sits in: `/com` at the start of the draft, or `@bi` anywhere +/// after whitespace. +enum NodComposerTrigger: Equatable { + case slash(String) + case mention(String) + + static func detect(in draft: String) -> NodComposerTrigger? { + if draft.hasPrefix("/"), !draft.contains(" "), !draft.contains("\n") { + return .slash(String(draft.dropFirst())) + } + guard let at = draft.lastIndex(of: "@") else { return nil } + let before = draft[.. AsyncStream<[NodEventRecord]> + /// Throws when the runtime cannot be reached or answers `{"ok":false}`. + var send: @Sendable (_ stateDirectory: URL, _ command: NodCommand) async throws -> Void +} + +enum NodStateDirectory { + /// `$NOD_STATE` — `/nod//`, which the runtime is launched with. + static func url(forNode id: UUID, supportDirectory: URL = SupportDirectory.url) -> URL { + supportDirectory.appendingPathComponent("nod", isDirectory: true) + .appendingPathComponent(id.uuidString, isDirectory: true) + } +} + +/// Splits appended bytes into records, holding a torn final line back until the rest of +/// it arrives. Pure, so the tail's edge cases are testable without a file. +struct NodEventTail: Equatable { + private(set) var offset: UInt64 = 0 + private var partial = Data() + + /// The file was replaced or truncated under the tail (a fresh run's log): start over. + mutating func reset() { + offset = 0 + partial = Data() + } + + mutating func consume(_ data: Data) -> [NodEventRecord] { + offset += UInt64(data.count) + partial.append(data) + guard let lastNewline = partial.lastIndex(of: UInt8(ascii: "\n")) else { return [] } + let complete = partial[partial.startIndex...lastNewline] + partial = Data(partial[partial.index(after: lastNewline)...]) + return NodProtocol.records(fromJSONLines: Data(complete)) + } +} + +enum NodControlError: Error, Equatable, LocalizedError { + case unreachable(String) + case rejected(String) + + var errorDescription: String? { + switch self { + case .unreachable(let detail): return "Nod isn't running (\(detail))." + case .rejected(let message): return message + } + } +} + +/// One command, one connection: connect, write the line, read the one-line reply. Bounded +/// both ways so a wedged runtime fails the send instead of hanging the pane. +enum NodControlSocket { + static let timeoutSeconds = 5 + + static func send(_ command: NodCommand, to socketPath: String) throws { + var line = try NodProtocol.makeEncoder().encode(command) + line.append(UInt8(ascii: "\n")) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw NodControlError.unreachable("socket: \(errno)") } + defer { close(fd) } + var timeout = timeval(tv_sec: timeoutSeconds, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var noSigPipe: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + guard pathBytes.count < MemoryLayout.size(ofValue: address.sun_path) else { + throw NodControlError.unreachable("socket path too long") + } + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + buffer.copyBytes(from: pathBytes) + } + let connected = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { throw NodControlError.unreachable("connect: \(errno)") } + + let written = line.withUnsafeBytes { write(fd, $0.baseAddress, $0.count) } + guard written == line.count else { throw NodControlError.unreachable("write: \(errno)") } + + var reply = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while !reply.contains(UInt8(ascii: "\n")) { + let count = read(fd, &buffer, buffer.count) + guard count > 0 else { break } + reply.append(contentsOf: buffer[0.. tail.offset, let handle = try? FileHandle(forReadingFrom: url) { + try? handle.seek(toOffset: tail.offset) + let data = (try? handle.readToEnd()) ?? Data() + try? handle.close() + let records = tail.consume(data) + if !records.isEmpty || isFirst { continuation.yield(records) } + } else if isFirst { + continuation.yield([]) + } + isFirst = false + try? await Task.sleep(for: pollInterval) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + }, + send: { directory, command in + let path = directory.appendingPathComponent("control.sock").path + try await Task.detached(priority: .userInitiated) { + try NodControlSocket.send(command, to: path) + }.value + }) + + static let testValue = NodClient( + events: { _ in AsyncStream { $0.finish() } }, send: { _, _ in }) + + /// A fixed log and a sink that accepts everything — for previews and headless renders. + static func replaying(_ records: [NodEventRecord]) -> NodClient { + NodClient( + events: { _ in + AsyncStream { continuation in + continuation.yield(records) + } + }, + send: { _, _ in }) + } +} + +extension DependencyValues { + var nodClient: NodClient { + get { self[NodClient.self] } + set { self[NodClient.self] = newValue } + } +} diff --git a/graphcode/Sources/Features/Nod/NodComposerView.swift b/graphcode/Sources/Features/Nod/NodComposerView.swift new file mode 100644 index 00000000..a544dd30 --- /dev/null +++ b/graphcode/Sources/Features/Nod/NodComposerView.swift @@ -0,0 +1,481 @@ +import AppKit +import ComposableArchitecture +import GraphcodeKit +import SwiftUI +import UniformTypeIdentifiers + +/// Design section 2: attachments over the draft, the draft, then the chips — model (with +/// cost beside it), edit policy — and the key hints. ⏎ queues, ⌘⏎ steers, esc stops. +struct NodComposerView: View { + @Bindable var store: StoreOf + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if !store.attachments.isEmpty { + attachmentRow + } + NodComposerTextView( + text: $store.draft.sending(\.draftChanged), + placeholder: placeholder, + onReturn: { store.send(.returnPressed) }, + onSteer: { store.send(.commandReturnPressed) }, + onEscape: { store.send(.escapePressed) }, + onTab: chooseFirstMentionAlternate + ) + .frame(minHeight: 20, maxHeight: 140) + .fixedSize(horizontal: false, vertical: true) + chipRow + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(RoundedRectangle(cornerRadius: 12).fill(NodStyle.composerBackground)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.12), lineWidth: 1)) + .overlay(alignment: .bottomLeading) { + menu.alignmentGuide(.bottom) { $0[.top] - 6 } + } + .onDrop(of: [.fileURL], isTargeted: nil, perform: dropFiles) + } + + private var placeholder: String { + store.transcript.isRunning ? "Steer Nod while it works…" : "Ask Nod…" + } + + private var attachmentRow: some View { + FlowRow { + ForEach(Array(store.attachments.enumerated()), id: \.offset) { index, attachment in + HStack(spacing: 5) { + Text(Self.glyph(attachment.kind)).foregroundStyle(NodStyle.muted) + Text(Self.label(attachment)).foregroundStyle(NodStyle.body).lineLimit(1) + Button { + store.send(.attachmentRemoved(index)) + } label: { + Text("×").foregroundStyle(NodStyle.muted) + } + .buttonStyle(.plain) + } + .font(.system(size: 11.5)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.07))) + } + } + } + + private var chipRow: some View { + HStack(spacing: 8) { + Menu { + Button("File…") { pickFiles(images: false) } + Button("Image…") { pickFiles(images: true) } + let finished = store.mentionCandidates.filter { + if case .loop(_, false, _) = $0.kind { return true } + return false + } + if !finished.isEmpty { + Menu("Loop transcript") { + ForEach(finished) { mention in + Button(mention.title) { store.send(.mentionChosen(mention, alternate: false)) } + } + } + } + } label: { + Text("+").font(.system(size: 11)).foregroundStyle(NodStyle.muted) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + + Menu { + ForEach(NodModelCatalog.models(for: store.transcript.session?.engine), id: \.self) { model in + Button(NodChatPresentation.modelLabel(model)) { store.send(.modelChosen(model)) } + } + } label: { + NodChip { Text("\(NodChatPresentation.modelLabel(store.model ?? "Model")) ▾") } + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + + if let cost = NodChatPresentation.costLabel(for: store.transcript) { + Text(cost).font(.system(size: 11, design: .monospaced)).foregroundStyle(NodStyle.muted) + } + + Menu { + Button("Ask before edits") { store.send(.delegate(.editPolicyChosen(.reviewHunks))) } + Button("Auto-accept edits") { store.send(.delegate(.editPolicyChosen(.auto))) } + } label: { + NodChip { Text("\(Self.editPolicyLabel(store.editPolicy)) ▾") } + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + + Spacer(minLength: 8) + Text( + store.transcript.isRunning ? "⏎ queue · ⌘⏎ steer · esc stop" : "⏎ send · @ loops · / commands" + ) + .font(.system(size: 11)) + .foregroundStyle(Color.white.opacity(0.5)) + } + } + + @ViewBuilder + private var menu: some View { + switch store.trigger { + case .slash(let query): + let commands = NodSlashCommand.matching(query) + if !commands.isEmpty { + NodSlashMenuView(commands: commands) { store.send(.slashCommandChosen($0)) } + } + case .mention(let query): + let mentions = NodMention.matching(query, in: store.mentionCandidates) + if !mentions.isEmpty { + NodMentionMenuView(query: query, mentions: mentions) { + store.send(.mentionChosen($0, alternate: false)) + } + } + case nil: + EmptyView() + } + } + + private func chooseFirstMentionAlternate() -> Bool { + guard case .mention(let query) = store.trigger, + let first = NodMention.matching(query, in: store.mentionCandidates).first + else { return false } + store.send(.mentionChosen(first, alternate: true)) + return true + } + + private func pickFiles(images: Bool) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + if images { panel.allowedContentTypes = [.image] } + guard panel.runModal() == .OK else { return } + for url in panel.urls { attach(url) } + } + + private func dropFiles(_ providers: [NSItemProvider]) -> Bool { + for provider in providers { + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url else { return } + DispatchQueue.main.async { attach(url) } + } + } + return true + } + + private func attach(_ url: URL) { + let isImage = UTType(filenameExtension: url.pathExtension)?.conforms(to: .image) ?? false + store.send( + .attachmentAdded( + NodAttachment(kind: isImage ? .image : .file, reference: url.path, label: nil))) + } + + static func glyph(_ kind: NodAttachment.Kind) -> String { + switch kind { + case .file: return "#" + case .image: return "▣" + case .loopTranscript: return "◇" + } + } + + static func label(_ attachment: NodAttachment) -> String { + if let label = attachment.label { + return attachment.kind == .loopTranscript ? "\(label) · transcript" : label + } + return (attachment.reference as NSString).lastPathComponent + } + + static func editPolicyLabel(_ policy: NodSettings.EditPolicy) -> String { + switch policy { + case .reviewHunks: return "Ask before edits" + case .auto: return "Auto" + } + } +} + +/// What the model chip offers. The runtime is the authority — `setModel` fails for a model +/// the engine does not have — so this is the menu, not a gate. +enum NodModelCatalog { + static func models(for engine: NodEngine?) -> [String] { + switch engine { + case .copilotSDK: return ["gpt-5", "claude-sonnet-4.5", "gemini-2.5-pro"] + case .claudeAgentSDK, nil: return ["opus", "sonnet", "haiku"] + } + } +} + +struct NodSlashMenuView: View { + let commands: [NodSlashCommand] + let onChoose: (NodSlashCommand) -> Void + + var body: some View { + NodPopover { + ForEach([NodSlashCommand.Group.thisLoop, .graph], id: \.self) { group in + let rows = commands.filter { $0.group == group } + if !rows.isEmpty { + NodPopoverHeader(title: group.rawValue) + ForEach(rows) { command in + Button { + onChoose(command) + } label: { + HStack(spacing: 10) { + Text("/\(command.name)").font(.system(size: 12, design: .monospaced)) + .foregroundStyle(NodStyle.ink) + .frame(width: 76, alignment: .leading) + Text(command.detail).font(.system(size: 12)).foregroundStyle(NodStyle.muted) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + } + } +} + +struct NodMentionMenuView: View { + let query: String + let mentions: [NodMention] + let onChoose: (NodMention) -> Void + + var body: some View { + NodPopover { + let loops = mentions.filter { if case .loop = $0.kind { return true } else { return false } } + let files = mentions.filter { if case .file = $0.kind { return true } else { return false } } + if !loops.isEmpty { + NodPopoverHeader(title: query.isEmpty ? "Loops" : "Loops · @\(query)") + ForEach(loops) { row($0) } + } + if !files.isEmpty { + NodPopoverHeader(title: "Files") + ForEach(files) { row($0) } + } + } + } + + private func row(_ mention: NodMention) -> some View { + Button { + onChoose(mention) + } label: { + HStack(spacing: 8) { + if case .file = mention.kind { + Text("#").foregroundStyle(NodStyle.muted) + } + Text(mention.title).foregroundStyle(NodStyle.ink) + if case .loop(_, _, let detail) = mention.kind { + Text(detail).foregroundStyle(NodStyle.muted) + } + Spacer(minLength: 8) + Text(mention.defaultActionLabel).foregroundStyle(NodStyle.actionInk) + } + .font(.system(size: 12)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +private struct NodPopover: View { + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { content } + .padding(.vertical, 6) + .frame(width: 380, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(white: 0.17))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.12), lineWidth: 1)) + .shadow(color: .black.opacity(0.4), radius: 12, y: 6) + } +} + +private struct NodPopoverHeader: View { + let title: String + + var body: some View { + Text(title) + .font(.system(size: 10.5, weight: .bold)) + .textCase(.uppercase) + .foregroundStyle(NodStyle.muted) + .padding(.horizontal, 10) + .padding(.top, 6) + .padding(.bottom, 2) + } +} + +/// Lays chips left to right, wrapping when the row is full. +private struct FlowRow: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + arrange(width: proposal.width ?? .infinity, subviews: subviews).size + } + + func placeSubviews( + in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () + ) { + let arrangement = arrange(width: bounds.width, subviews: subviews) + for (subview, origin) in zip(subviews, arrangement.origins) { + subview.place( + at: CGPoint(x: bounds.minX + origin.x, y: bounds.minY + origin.y), proposal: .unspecified) + } + } + + private func arrange(width: CGFloat, subviews: Subviews) -> (size: CGSize, origins: [CGPoint]) { + var origins: [CGPoint] = [] + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + var widest: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > 0, x + size.width > width { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + origins.append(CGPoint(x: x, y: y)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + widest = max(widest, x - spacing) + } + return (CGSize(width: widest, height: y + rowHeight), origins) + } +} + +/// An `NSTextView` rather than a SwiftUI field: SwiftUI's focus cannot take first responder +/// off a Ghostty surface in the same window (the zsh split beside the chat), and the keys +/// that matter here — ⏎, ⌘⏎, esc, ⇥ — arrive as AppKit commands before SwiftUI sees them. +struct NodComposerTextView: NSViewRepresentable { + @Binding var text: String + var placeholder: String + var onReturn: () -> Void + var onSteer: () -> Void + var onEscape: () -> Void + /// Returns whether it consumed the ⇥. + var onTab: () -> Bool + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let textView = ComposerTextView() + textView.delegate = context.coordinator + textView.coordinator = context.coordinator + textView.isRichText = false + textView.allowsUndo = true + textView.drawsBackground = false + textView.font = .systemFont(ofSize: 13) + textView.textColor = NSColor.white.withAlphaComponent(0.92) + textView.insertionPointColor = .white + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + textView.placeholder = placeholder + + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.hasVerticalScroller = false + scroll.documentView = textView + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + context.coordinator.parent = self + guard let textView = scroll.documentView as? ComposerTextView else { return } + if textView.string != text { + textView.string = text + textView.setSelectedRange(NSRange(location: (text as NSString).length, length: 0)) + } + if textView.placeholder != placeholder { + textView.placeholder = placeholder + textView.needsDisplay = true + } + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) + -> CGSize? + { + guard let textView = nsView.documentView as? NSTextView, + let container = textView.textContainer, let manager = textView.layoutManager + else { return nil } + let width = proposal.width ?? 400 + container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) + manager.ensureLayout(for: container) + let height = max(manager.usedRect(for: container).height, 17) + return CGSize(width: width, height: min(height, 140)) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: NodComposerTextView + + init(_ parent: NodComposerTextView) { self.parent = parent } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + } + + func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { + switch selector { + case #selector(NSResponder.insertNewline(_:)): + if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { return false } + if NSApp.currentEvent?.modifierFlags.contains(.command) == true { + parent.onSteer() + } else { + parent.onReturn() + } + return true + case #selector(NSResponder.cancelOperation(_:)): + parent.onEscape() + return true + case #selector(NSResponder.insertTab(_:)): + return parent.onTab() + default: + return false + } + } + } + + final class ComposerTextView: NSTextView { + weak var coordinator: Coordinator? + var placeholder = "" + + /// ⌘⏎ never reaches `insertNewline` — AppKit treats it as a key equivalent — so it is + /// caught here first. + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if window?.firstResponder === self, event.keyCode == 36, + event.modifierFlags.contains(.command) + { + coordinator?.parent.onSteer() + return true + } + return super.performKeyEquivalent(with: event) + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + super.mouseDown(with: event) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard string.isEmpty else { return } + (placeholder as NSString).draw( + at: .zero, + withAttributes: [ + .font: font ?? .systemFont(ofSize: 13), + .foregroundColor: NSColor.white.withAlphaComponent(0.55), + ]) + } + } +} diff --git a/graphcode/Sources/Features/Nod/NodGraphSlots.swift b/graphcode/Sources/Features/Nod/NodGraphSlots.swift new file mode 100644 index 00000000..a664fa81 --- /dev/null +++ b/graphcode/Sources/Features/Nod/NodGraphSlots.swift @@ -0,0 +1,46 @@ +import GraphcodeKit +import SwiftUI + +/// Where the graph layer (design 1c, owned by the NodGraphLayer stream) plugs into the +/// chat pane without the pane knowing what a sibling or a handoff is: a context strip under +/// the goal header, inline mail and handoff offers between turns, and offers above the +/// composer. Every slot defaults to nothing, which is also what a loop with no edges gets. +/// +/// Installed with `.environment(\.nodGraphSlots, …)` around `NodChatPaneView`. +struct NodGraphSlots { + /// "from Pricing → Monetization → Release notes · beside Billing UI". + var contextStrip: (() -> AnyView)? + /// Drawn after a turn: mail that arrived during it, a handoff it received. + var afterTurn: ((_ turn: Int) -> AnyView?)? + /// "Hand off to Release notes with a summary of what changed?" + var aboveComposer: (() -> AnyView)? + /// Replaces the pane's plain draft card for a reply Nod drafted to a sibling. + var mailDraft: ((NodEvent.MailDraft) -> AnyView)? + /// Replaces the pane's plain plan card (design section 4). + var plan: ((NodEvent.PlanProposed) -> AnyView)? + + init( + contextStrip: (() -> AnyView)? = nil, + afterTurn: ((Int) -> AnyView?)? = nil, + aboveComposer: (() -> AnyView)? = nil, + mailDraft: ((NodEvent.MailDraft) -> AnyView)? = nil, + plan: ((NodEvent.PlanProposed) -> AnyView)? = nil + ) { + self.contextStrip = contextStrip + self.afterTurn = afterTurn + self.aboveComposer = aboveComposer + self.mailDraft = mailDraft + self.plan = plan + } +} + +private struct NodGraphSlotsKey: EnvironmentKey { + static let defaultValue = NodGraphSlots() +} + +extension EnvironmentValues { + var nodGraphSlots: NodGraphSlots { + get { self[NodGraphSlotsKey.self] } + set { self[NodGraphSlotsKey.self] = newValue } + } +} diff --git a/graphcode/Sources/Features/Nod/NodStyle.swift b/graphcode/Sources/Features/Nod/NodStyle.swift new file mode 100644 index 00000000..4997a3cc --- /dev/null +++ b/graphcode/Sources/Features/Nod/NodStyle.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// The chat pane's palette, read off the design (`nod_designs/GraphCode Nod.dc.html`): +/// plain GraphCode chrome, action blue for actions, orange for "needs you", and muted text +/// no dimmer than 55% white (review round 2). +enum NodStyle { + static let paneBackground = Color(red: 0.118, green: 0.118, blue: 0.133) // #1e1e22 + static let cardBackground = Color(red: 0.098, green: 0.098, blue: 0.110) // #19191c + static let composerBackground = Color(red: 0.149, green: 0.149, blue: 0.165) // #26262a + static let bubble = Color.white.opacity(0.07) + static let hairline = Color.white.opacity(0.10) + + static let ink = Color.white.opacity(0.92) + static let body = Color.white.opacity(0.86) + static let secondary = Color.white.opacity(0.75) + static let muted = Color.white.opacity(0.55) + + static let action = Color(red: 0.039, green: 0.518, blue: 1.0) // #0a84ff + static let actionInk = Color(red: 0.475, green: 0.737, blue: 1.0) // #79bcff + static let attention = Color(red: 1.0, green: 0.624, blue: 0.039) // #ff9f0a + static let attentionInk = Color(red: 1.0, green: 0.745, blue: 0.361) // #ffbe5c + static let met = Color(red: 0.188, green: 0.820, blue: 0.345) // #30d158 + static let failed = Color(red: 1.0, green: 0.412, blue: 0.380) // #ff6961 + static let added = Color(red: 0.651, green: 0.941, blue: 0.722) // #a6f0b8 + static let removed = Color(red: 1.0, green: 0.702, blue: 0.682) // #ffb3ae + + static let columnWidth: CGFloat = 680 + static let mono = Font.system(size: 11.5, design: .monospaced) +} + +/// The design's three button weights: filled blue for the one thing to do, a quiet +/// outlined one beside it, and bare text for the way out. +struct NodButtonStyle: ButtonStyle { + enum Weight { case primary, secondary, plain } + var weight: Weight = .secondary + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 11.5, weight: weight == .primary ? .semibold : .regular)) + .foregroundStyle(foreground) + .padding(.horizontal, 10) + .frame(height: 24) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(background.opacity(configuration.isPressed ? 0.7 : 1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(weight == .secondary ? Color.white.opacity(0.12) : .clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + } + + private var foreground: Color { + switch weight { + case .primary: return .white + case .secondary: return Color.white.opacity(0.85) + case .plain: return Color.white.opacity(0.6) + } + } + + private var background: Color { + switch weight { + case .primary: return NodStyle.action + case .secondary: return Color.white.opacity(0.06) + case .plain: return .clear + } + } +} + +/// A link-weight action in blue ink, as the goal check's "Mark done anyway". +struct NodLinkButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 11.5)) + .foregroundStyle(NodStyle.actionInk.opacity(configuration.isPressed ? 0.6 : 1)) + .contentShape(Rectangle()) + } +} + +/// The small rounded chips under the composer and on attachments. +struct NodChip: View { + @ViewBuilder var label: Label + + var body: some View { + label + .font(.system(size: 11)) + .foregroundStyle(NodStyle.muted) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 5).fill(Color.white.opacity(0.06))) + } +} diff --git a/graphcode/Sources/Features/Nod/NodTranscript.swift b/graphcode/Sources/Features/Nod/NodTranscript.swift new file mode 100644 index 00000000..a8f4e00e --- /dev/null +++ b/graphcode/Sources/Features/Nod/NodTranscript.swift @@ -0,0 +1,311 @@ +import Foundation +import GraphcodeKit + +/// Nod's event log folded into what the chat pane draws: turns, each holding the prompt +/// that started it and the work cards it produced, plus the state that sits outside any +/// one turn — the latest goal check, usage, the queued messages, the live activity line +/// and whichever failure is showing. +/// +/// Pure and replayable: the pane rebuilds it from `events.jsonl` on open, and applies the +/// same records one at a time as the tail delivers them, so a reopened loop and a live one +/// draw the same thing. +struct NodTranscript: Equatable { + struct Session: Equatable { + var engine: NodEngine + var model: String + var conversationID: String + } + + struct Turn: Equatable, Identifiable { + var number: Int + var origin: NodTurnOrigin + var startedAt: Date + var prompt: NodEvent.UserMessage? + var items: [Item] = [] + var ended: NodEvent.TurnEnded? + var endedAt: Date? + /// Closed by the next turn starting without ever writing `turnEnded` — the runtime was + /// stopped or killed mid-turn. + var wasInterrupted = false + /// Folded into a "Compacted N turns" divider; the originals stay readable. + var isCompacted = false + + var id: Int { number } + var isRunning: Bool { ended == nil && !wasInterrupted } + } + + enum Item: Equatable, Identifiable { + case text(Message) + /// A note steered into the running turn, picked up at the next tool boundary. + case steer(NodEvent.UserMessage) + case tool(ToolCard) + case hunk(HunkCard) + case permission(PermissionCard) + case goalCheck(NodEvent.GoalCheck) + case plan(NodEvent.PlanProposed) + case mailDraft(NodEvent.MailDraft) + + var id: String { + switch self { + case .text(let message): return "text:\(message.id)" + case .steer(let message): return "steer:\(message.id)" + case .tool(let card): return "tool:\(card.call.callID)" + case .hunk(let card): return "hunk:\(card.staged.hunkID)" + case .permission(let card): return "ask:\(card.ask.askID)" + case .goalCheck(let check): return "goal:\(check.turn)" + case .plan(let plan): return "plan:\(plan.planID)" + case .mailDraft(let draft): return "draft:\(draft.draftID)" + } + } + } + + struct Message: Equatable { + var id: String + var text: String + var isFinal: Bool + } + + struct ToolCard: Equatable { + var call: NodEvent.ToolCall + var result: NodEvent.ToolResult? + + var status: NodToolStatus { result?.status ?? .running } + } + + struct HunkCard: Equatable { + var staged: NodEvent.HunkStaged + var resolution: NodEvent.HunkResolved? + + var decision: NodHunkDecision? { + resolution?.decision ?? (staged.autoAccepted ? .accept : nil) + } + } + + struct PermissionCard: Equatable { + var ask: NodEvent.PermissionAsked + var decision: NodPermissionDecision? + } + + private(set) var session: Session? + private(set) var turns: [Turn] = [] + /// Accepted with `delivery: queue` while a turn was running; each one starts a turn of + /// its own once the current one ends. + private(set) var queued: [NodEvent.UserMessage] = [] + private(set) var lastGoalCheck: NodEvent.GoalCheck? + /// How many goal checks have run, for the header's "checked 2×". + private(set) var goalCheckCount = 0 + private(set) var usage: NodEvent.Usage? + /// Dollars or premium requests summed across every `usage` record — each one reports + /// what a single model call cost, not a running total. + private(set) var totalCostUSD: Double = 0 + private(set) var totalPremiumRequests = 0 + private(set) var activity: String? + private(set) var failure: NodEvent.Failure? + private(set) var lastSeq = 0 + + var currentTurn: Turn? { turns.last.flatMap { $0.isRunning ? $0 : nil } } + var isRunning: Bool { currentTurn != nil } + + /// The permission asks still waiting on a human — the loop is in Needs you while this + /// is non-empty. + var openAsks: [NodEvent.PermissionAsked] { + turns.flatMap(\.items).compactMap { + if case .permission(let card) = $0, card.decision == nil { return card.ask } + return nil + } + } + + init() {} + + init(replaying records: [NodEventRecord]) { + for record in records { apply(record) } + } + + mutating func apply(_ record: NodEventRecord) { + // `seq` restarts at 1 when a resumed runtime opens a new run, which it announces with + // `sessionStarted`; anything else at or below the last seen seq is a replay. + if case .sessionStarted = record.event { + } else if record.seq <= lastSeq { + return + } + lastSeq = record.seq + + switch record.event { + case .sessionStarted(let started): + closeRunningTurn(at: record.at) + session = Session( + engine: started.engine, model: started.model, conversationID: started.conversationID) + if failure?.kind == .signInExpired || failure?.kind == .engineError { failure = nil } + + case .turnStarted(let started): + closeRunningTurn(at: record.at) + var turn = Turn(number: started.turn, origin: started.origin, startedAt: record.at) + if started.origin.carriesPrompt, !queued.isEmpty { + turn.prompt = queued.removeFirst() + } + turns.append(turn) + if failure?.kind == .spendCap || failure?.kind == .permissionUnavailable { failure = nil } + + case .userMessage(let message): + receive(message) + + case .assistantText(let text): + appendText(text) + + case .toolCall(let call): + append(.tool(ToolCard(call: call)), toTurn: call.turn) + + case .toolResult(let result): + updateItems { item in + guard case .tool(var card) = item, card.call.callID == result.callID else { return false } + card.result = result + item = .tool(card) + return true + } + + case .hunkStaged(let staged): + append(.hunk(HunkCard(staged: staged)), toTurn: staged.turn) + + case .hunkResolved(let resolved): + updateItems { item in + guard case .hunk(var card) = item, card.staged.hunkID == resolved.hunkID else { + return false + } + card.resolution = resolved + item = .hunk(card) + return true + } + + case .permissionAsked(let ask): + let turn = currentTurn?.number ?? turns.last?.number ?? 0 + append(.permission(PermissionCard(ask: ask)), toTurn: turn) + + case .permissionResolved(let resolved): + updateItems { item in + guard case .permission(var card) = item, card.ask.askID == resolved.askID else { + return false + } + card.decision = resolved.decision + item = .permission(card) + return true + } + + case .goalCheck(let check): + lastGoalCheck = check + goalCheckCount += 1 + append(.goalCheck(check), toTurn: check.turn) + + case .turnEnded(let ended): + guard let index = turns.lastIndex(where: { $0.number == ended.turn }) else { return } + turns[index].ended = ended + turns[index].endedAt = record.at + activity = nil + + case .usage(let usage): + self.usage = usage + totalCostUSD += usage.costUSD ?? 0 + totalPremiumRequests += usage.premiumRequests ?? 0 + + case .planProposed(let plan): + append(.plan(plan), toTurn: currentTurn?.number ?? turns.last?.number ?? 0) + + case .mailDraft(let draft): + append(.mailDraft(draft), toTurn: currentTurn?.number ?? turns.last?.number ?? 0) + + case .compacted(let compacted): + for index in turns.indices + where (compacted.fromTurn...compacted.throughTurn).contains(turns[index].number) { + turns[index].isCompacted = true + } + if failure?.kind == .contextFull { failure = nil } + + case .activity(let activity): + self.activity = activity.line + + case .failure(let failure): + self.failure = failure + + case .unknown: + break + } + } + + /// A queued message belongs to the turn it will start; a steer belongs to the turn it + /// lands in. A message accepted while idle is the prompt of the turn about to start — + /// whichever of the two records the runtime writes first. + private mutating func receive(_ message: NodEvent.UserMessage) { + if message.delivery == .steer, let index = runningTurnIndex { + turns[index].items.append(.steer(message)) + return + } + if let index = runningTurnIndex, turns[index].prompt == nil, turns[index].items.isEmpty, + turns[index].origin.carriesPrompt + { + turns[index].prompt = message + return + } + queued.append(message) + } + + private mutating func appendText(_ text: NodEvent.AssistantText) { + guard let turnIndex = index(ofTurn: text.turn) else { return } + if let itemIndex = turns[turnIndex].items.lastIndex(where: { + if case .text(let message) = $0 { return message.id == text.messageID } + return false + }), case .text(var message) = turns[turnIndex].items[itemIndex] { + message.text += text.delta + message.isFinal = message.isFinal || text.final + turns[turnIndex].items[itemIndex] = .text(message) + } else { + turns[turnIndex].items.append( + .text(Message(id: text.messageID, text: text.delta, isFinal: text.final))) + } + } + + private mutating func append(_ item: Item, toTurn number: Int) { + guard let index = index(ofTurn: number) else { return } + turns[index].items.append(item) + } + + /// The turn a record names, or — for one that names a turn this log never started, as a + /// runtime resumed mid-turn would — a stand-in so its cards are not dropped. + private mutating func index(ofTurn number: Int) -> Int? { + if let index = turns.lastIndex(where: { $0.number == number }) { return index } + turns.append(Turn(number: number, origin: .user, startedAt: .distantPast)) + return turns.count - 1 + } + + private var runningTurnIndex: Int? { + guard let last = turns.indices.last, turns[last].isRunning else { return nil } + return last + } + + /// A turn that never wrote `turnEnded` — the runtime was killed mid-turn — is closed by + /// the next one starting, so only one turn ever reads as running. + private mutating func closeRunningTurn(at date: Date) { + guard let index = runningTurnIndex else { return } + turns[index].wasInterrupted = true + turns[index].endedAt = date + } + + /// Newest first: a result or resolution is for a card near the end of the log. + private mutating func updateItems(_ update: (inout Item) -> Bool) { + for turnIndex in turns.indices.reversed() { + for itemIndex in turns[turnIndex].items.indices.reversed() + where update(&turns[turnIndex].items[itemIndex]) { + return + } + } + } +} + +extension NodTurnOrigin { + /// Whether a turn of this origin starts from a message the human (or a peer) sent, + /// rather than from the runtime itself. + var carriesPrompt: Bool { + switch self { + case .user, .queue, .handoff, .mail: return true + case .steer, .timer, .goalCheck: return false + } + } +} diff --git a/graphcode/Sources/Features/Nod/NodWorkCards.swift b/graphcode/Sources/Features/Nod/NodWorkCards.swift new file mode 100644 index 00000000..ecb54e1f --- /dev/null +++ b/graphcode/Sources/Features/Nod/NodWorkCards.swift @@ -0,0 +1,550 @@ +import AppKit +import GraphcodeKit +import SwiftUI + +/// Design 3a: one line when collapsed — verb, subject, summary — and the output with +/// "Open in zsh tab" and "Copy output" when open. +struct NodToolCardView: View { + let card: NodTranscript.ToolCard + let isExpanded: Bool + let onToggle: () -> Void + let onOpenInShell: (String) -> Void + + private var verb: NodToolVerb { NodToolVerb(tool: card.call.tool) } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button(action: onToggle) { + HStack(spacing: 8) { + if card.status == .running { + NodSpinner() + } else { + Text(isExpanded ? "▾" : "▸").font(.system(size: 9)) + } + Text(card.call.tool).foregroundStyle(NodStyle.secondary) + Text(subject).font(NodStyle.mono).lineLimit(1).truncationMode(.middle) + if let summary = card.result?.summary, !summary.isEmpty { + Text("· \(summary)") + .foregroundStyle(card.status == .error ? NodStyle.failed : NodStyle.muted) + .lineLimit(1) + } + Spacer(minLength: 8) + if let ms = card.result?.durationMs { + Text(NodChatPresentation.duration(ms: ms)) + } + } + .font(.system(size: 12)) + .foregroundStyle(NodStyle.muted) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + expanded + } + } + } + + /// The title minus a leading verb that repeats the tool's name — `Search "UsageGate"` + /// under a `Grep` tool reads as its subject, `Read UsageGate.swift` as `UsageGate.swift`. + private var subject: String { + let title = card.call.title + for prefix in [card.call.tool, verb.label] where title.hasPrefix(prefix + " ") { + return String(title.dropFirst(prefix.count + 1)) + } + return title + } + + private var expanded: some View { + VStack(alignment: .leading, spacing: 0) { + if let output = card.result?.output, !output.isEmpty { + ScrollView { + Text(output) + .font(NodStyle.mono) + .foregroundStyle(NodStyle.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + } + .frame(maxHeight: 220) + .fixedSize(horizontal: false, vertical: true) + } + HStack(spacing: 14) { + if verb == .shell { + Button("Open in zsh tab") { onOpenInShell(subject) } + } + if let output = card.result?.output, !output.isEmpty { + Button("Copy output") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(output, forType: .string) + } + } + Spacer() + } + .buttonStyle(NodLinkButtonStyle()) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .overlay(alignment: .top) { Rectangle().fill(NodStyle.hairline).frame(height: 1) } + } + .background(RoundedRectangle(cornerRadius: 8).fill(NodStyle.cardBackground)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(NodStyle.hairline, lineWidth: 1)) + .padding(.top, 2) + .padding(.bottom, 4) + } +} + +/// Design 1b's Work block: a turn's tool calls folded to one row of counts once there are +/// more than five, opening to one line per call. +struct NodWorkBlockView: View { + let summary: NodWorkSummary + let isExpanded: Bool + let onToggle: () -> Void + let toolCard: (NodTranscript.ToolCard) -> NodToolCardView + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button(action: onToggle) { + HStack(spacing: 8) { + Text(isExpanded ? "▾" : "▸").font(.system(size: 9)).foregroundStyle(NodStyle.muted) + Text("Work").font(.system(size: 12, weight: .semibold)).foregroundStyle(NodStyle.ink) + Text(summary.line).foregroundStyle(NodStyle.muted) + if let running = summary.running { + Text("· running \(running.call.title)") + .foregroundStyle(NodStyle.actionInk) + .lineLimit(1) + } + Spacer(minLength: 8) + if summary.durationMs > 0 { + Text(NodChatPresentation.duration(ms: summary.durationMs)) + .foregroundStyle(NodStyle.muted) + } + } + .font(.system(size: 12)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: 0) { + ForEach(summary.tools, id: \.call.callID) { tool in + toolCard(tool) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .overlay(alignment: .top) { Rectangle().fill(NodStyle.hairline).frame(height: 1) } + } + } + .background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.03))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(NodStyle.hairline, lineWidth: 1)) + } +} + +/// Design 3b: one staged hunk — written to the loop's worktree only on Accept. +struct NodHunkCardView: View { + let card: NodTranscript.HunkCard + let isCommenting: Bool + @Binding var comment: String + let onDecide: (NodHunkDecision) -> Void + let onSubmitComment: () -> Void + let onCancelComment: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text(card.staged.file).font(NodStyle.mono).foregroundStyle(NodStyle.ink).lineLimit(1) + .truncationMode(.head) + Text("+\(card.staged.added)").foregroundStyle(NodStyle.met) + if card.staged.removed > 0 { + Text("−\(card.staged.removed)").foregroundStyle(NodStyle.failed) + } + Spacer(minLength: 8) + trailing + } + .font(.system(size: 11)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .overlay(alignment: .bottom) { Rectangle().fill(NodStyle.hairline).frame(height: 1) } + + NodDiffView(header: card.staged.header, diff: card.staged.diff) + .opacity(card.decision == .reject ? 0.45 : 1) + + if isCommenting { + commentField + } else if let note = card.resolution?.note, card.decision == .comment { + Text("Sent back: \(note)") + .font(.system(size: 11.5)) + .foregroundStyle(NodStyle.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + } + } + .background(RoundedRectangle(cornerRadius: 10).fill(NodStyle.cardBackground)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(NodStyle.hairline, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + @ViewBuilder + private var trailing: some View { + switch card.decision { + case .accept: + Text(card.staged.autoAccepted && card.resolution == nil ? "✓ auto-accepted" : "✓ accepted") + .foregroundStyle(NodStyle.met) + case .reject: + Text("rejected").foregroundStyle(NodStyle.muted) + case .comment: + Text("sent back").foregroundStyle(NodStyle.attentionInk) + case nil: + HStack(spacing: 6) { + Button("Reject") { onDecide(.reject) }.buttonStyle(NodButtonStyle(weight: .plain)) + Button("Comment") { onDecide(.comment) }.buttonStyle(NodButtonStyle(weight: .secondary)) + Button("Accept") { onDecide(.accept) }.buttonStyle(NodButtonStyle(weight: .primary)) + } + } + } + + private var commentField: some View { + HStack(spacing: 8) { + TextField("Send it back with a note…", text: $comment) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .onSubmit(onSubmitComment) + Button("Cancel", action: onCancelComment).buttonStyle(NodButtonStyle(weight: .plain)) + Button("Send back", action: onSubmitComment).buttonStyle(NodButtonStyle(weight: .primary)) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .overlay(alignment: .top) { Rectangle().fill(NodStyle.hairline).frame(height: 1) } + } +} + +struct NodDiffView: View { + let header: String + let diff: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + line(header.hasPrefix("@@") ? header : "@@ \(header)", kind: .header) + ForEach(Array(lines.enumerated()), id: \.offset) { _, text in + line(text, kind: Kind(text)) + } + } + .font(NodStyle.mono) + .padding(.vertical, 4) + } + + /// The hunk's body without the diff's own file and range headers, which the card already + /// shows above it. + private var lines: [String] { + diff.split(separator: "\n", omittingEmptySubsequences: false).map(String.init).filter { + !$0.hasPrefix("@@") && !$0.hasPrefix("+++") && !$0.hasPrefix("---") && !$0.isEmpty + } + } + + private enum Kind { + case header, added, removed, context + + init(_ text: String) { + if text.hasPrefix("+") { + self = .added + } else if text.hasPrefix("-") { + self = .removed + } else { + self = .context + } + } + } + + private func line(_ text: String, kind: Kind) -> some View { + Text(text) + .foregroundStyle(foreground(kind)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 1) + .background(background(kind)) + .lineLimit(1) + } + + private func foreground(_ kind: Kind) -> Color { + switch kind { + case .header: return NodStyle.muted + case .added: return NodStyle.added + case .removed: return NodStyle.removed + case .context: return Color.white.opacity(0.6) + } + } + + private func background(_ kind: Kind) -> Color { + switch kind { + case .added: return NodStyle.met.opacity(0.12) + case .removed: return Color(red: 1, green: 0.271, blue: 0.227).opacity(0.12) + case .header, .context: return .clear + } + } +} + +/// Design 3c: a real "needs you" state, in the same orange as a CLI loop waiting on you. +struct NodPermissionCardView: View { + let card: NodTranscript.PermissionCard + /// The project's name, for "Always in ". + let projectName: String + let onDecide: (NodPermissionDecision) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 7) { + Circle().fill(card.decision == nil ? NodStyle.attention : NodStyle.muted) + .frame(width: 7, height: 7) + Text(Self.title(card.ask.kind)) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(NodStyle.ink) + } + Text(card.ask.subject) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Color.white.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 7).fill(Color.black.opacity(0.25))) + .textSelection(.enabled) + if !card.ask.reason.isEmpty { + Text(card.ask.reason) + .font(.system(size: 12)) + .foregroundStyle(Color.white.opacity(0.65)) + .fixedSize(horizontal: false, vertical: true) + } + if let decision = card.decision { + Text(Self.resolvedLabel(decision)) + .font(.system(size: 11.5)) + .foregroundStyle(decision == .deny ? NodStyle.muted : NodStyle.met) + } else { + HStack(spacing: 7) { + Button("Allow once") { onDecide(.allowOnce) } + .buttonStyle(NodButtonStyle(weight: .primary)) + Button("Always in \(projectName)") { onDecide(.alwaysAllow) } + .buttonStyle(NodButtonStyle(weight: .secondary)) + Spacer() + Button("Deny") { onDecide(.deny) }.buttonStyle(NodButtonStyle(weight: .plain)) + } + } + } + .padding(.horizontal, 13) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(card.decision == nil ? NodStyle.attention.opacity(0.07) : Color.white.opacity(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke( + card.decision == nil ? NodStyle.attention.opacity(0.4) : NodStyle.hairline, lineWidth: 1) + ) + } + + static func title(_ kind: NodPermissionKind) -> String { + switch kind { + case .shell: return "Nod wants to run a command" + case .network: return "Nod wants to reach the network" + case .editOutsideWorktree: return "Nod wants to edit outside its worktree" + case .messageLoop: return "Nod wants to message another loop" + case .mcpTool: return "Nod wants to use an MCP tool" + } + } + + static func resolvedLabel(_ decision: NodPermissionDecision) -> String { + switch decision { + case .allowOnce: return "✓ Allowed once" + case .alwaysAllow: return "✓ Always allowed" + case .deny: return "Denied" + } + } +} + +/// Design 3d: the goal split into clauses, each with its evidence, and the way out when +/// the evaluator is wrong. +struct NodGoalCheckCardView: View { + let check: NodEvent.GoalCheck + let goalTint: Color + let onMarkDone: () -> Void + let onEditGoal: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2).fill(goalTint).frame(width: 8, height: 8) + Text("Goal check · after turn \(check.turn)") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(NodStyle.ink) + Spacer() + Text("evaluator: \(NodChatPresentation.modelLabel(check.evaluatorModel))") + .font(.system(size: 11)) + .foregroundStyle(NodStyle.muted) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .overlay(alignment: .bottom) { Rectangle().fill(goalTint.opacity(0.2)).frame(height: 1) } + + NodGoalClauseList(clauses: check.clauses) + .padding(.horizontal, 12) + .padding(.vertical, 10) + + HStack(spacing: 10) { + Text(check.met ? "Goal holds. Nod stops here." : "Not yet. Nod carries on.") + .foregroundStyle(Color.white.opacity(0.65)) + Spacer() + if !check.met { + Button("Mark done anyway", action: onMarkDone) + } + Button("Edit goal", action: onEditGoal) + } + .font(.system(size: 11.5)) + .buttonStyle(NodLinkButtonStyle()) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .overlay(alignment: .top) { Rectangle().fill(goalTint.opacity(0.2)).frame(height: 1) } + } + .background(RoundedRectangle(cornerRadius: 10).fill(goalTint.opacity(0.06))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(goalTint.opacity(0.4), lineWidth: 1)) + } +} + +struct NodGoalClauseList: View { + let clauses: [NodGoalClause] + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(clauses.enumerated()), id: \.offset) { _, clause in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(clause.met ? "✓" : "○") + .foregroundStyle(clause.met ? NodStyle.met : NodStyle.attention) + Text(clause.text).foregroundStyle(Color.white.opacity(0.82)) + Spacer(minLength: 8) + if let evidence = clause.evidence { + Text(evidence) + .foregroundStyle(clause.met ? NodStyle.muted : NodStyle.attentionInk.opacity(0.9)) + .multilineTextAlignment(.trailing) + } + } + .font(.system(size: 12)) + } + } + } +} + +/// Design 3e: a branch stays inside the loop; a sibling gets its own worktree and card. +struct NodForkMenuView: View { + let onBranch: () -> Void + let onSibling: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Fork as") + .font(.system(size: 10.5, weight: .bold)) + .textCase(.uppercase) + .foregroundStyle(NodStyle.muted) + .padding(.horizontal, 10) + .padding(.top, 8) + .padding(.bottom, 2) + option( + "Branch in this loop", "Try the other approach. Switch with ‹ 1 / 2 › on the message.", + action: onBranch) + option( + "New sibling loop", "Its own worktree and its own card on the canvas, so both run at once.", + action: onSibling) + } + .padding(.bottom, 6) + .frame(width: 300, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(white: 0.17))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.12), lineWidth: 1)) + .shadow(color: .black.opacity(0.4), radius: 12, y: 6) + } + + private func option(_ title: String, _ detail: String, action: @escaping () -> Void) + -> some View + { + Button(action: action) { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.system(size: 12.5, weight: .medium)).foregroundStyle(NodStyle.ink) + Text(detail).font(.system(size: 11)).foregroundStyle(NodStyle.muted) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +/// The pane's own plan card, until the graph layer's plan mode replaces it through +/// `NodGraphSlots.plan`. +struct NodPlanCardView: View { + let plan: NodEvent.PlanProposed + let onRun: (NodCommand.RunPlan.Mode) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Plan").font(.system(size: 11, weight: .bold)).textCase(.uppercase) + .foregroundStyle(NodStyle.muted) + Text(plan.title).font(.system(size: 12.5, weight: .semibold)).foregroundStyle(NodStyle.ink) + } + ForEach(Array(plan.steps.enumerated()), id: \.element.id) { index, step in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(index + 1)").font(NodStyle.mono).foregroundStyle(NodStyle.muted) + Text(step.text).font(.system(size: 12)).foregroundStyle(NodStyle.body) + Spacer() + if let size = step.size { + Text(size.rawValue).font(.system(size: 10.5)).foregroundStyle(NodStyle.muted) + } + } + } + HStack { + Spacer() + Button("Run as Composite") { onRun(.composite) }.buttonStyle(NodButtonStyle()) + Button("Run here") { onRun(.here) }.buttonStyle(NodButtonStyle(weight: .primary)) + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(NodStyle.cardBackground)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(NodStyle.hairline, lineWidth: 1)) + } +} + +/// The pane's own draft card, until the graph layer's inline mail replaces it through +/// `NodGraphSlots.mailDraft`. +struct NodMailDraftCardView: View { + let draft: NodEvent.MailDraft + let onSend: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Nod's draft reply").font(.system(size: 11.5)).foregroundStyle(NodStyle.muted) + Text(draft.text).font(.system(size: 12.5)).foregroundStyle(NodStyle.body) + .textSelection(.enabled) + HStack { + Spacer() + Button("Send reply", action: onSend).buttonStyle(NodButtonStyle(weight: .primary)) + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.03))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(NodStyle.hairline, lineWidth: 1)) + } +} + +/// The design's running dot: a grey ring with a blue head. Static, so a headless render +/// and a reduced-motion screen draw the same thing. +struct NodSpinner: View { + var body: some View { + ZStack { + Circle().stroke(Color.white.opacity(0.25), lineWidth: 1.5) + Circle().trim(from: 0, to: 0.25).stroke(NodStyle.action, lineWidth: 1.5) + } + .frame(width: 10, height: 10) + } +} diff --git a/graphcode/Sources/Infrastructure/Ghostty/TerminalSurfaceStore.swift b/graphcode/Sources/Infrastructure/Ghostty/TerminalSurfaceStore.swift index fe37a98c..9fad60e6 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/TerminalSurfaceStore.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/TerminalSurfaceStore.swift @@ -30,6 +30,8 @@ final class TerminalSurfaceStore { private var surfaces: [UUID: GhosttyTerminalNSView] = [:] private var policy: SurfaceRetentionPolicy + /// Text typed at a surface before it was built — a tab opened to receive a command. + private var pendingText: [UUID: String] = [:] init(capacity: Int = SurfaceRetentionPolicy.defaultCapacity) { policy = SurfaceRetentionPolicy(capacity: capacity) @@ -48,6 +50,7 @@ final class TerminalSurfaceStore { } else { view = build() surfaces[id] = view + if let text = pendingText.removeValue(forKey: id) { type(text, into: view) } } // Only an unmounted surface may be aged out; see `SurfaceRetentionPolicy.touch`. if let evicted = policy.touch(id, isEvictable: { surfaces[$0]?.superview == nil }) { @@ -62,6 +65,7 @@ final class TerminalSurfaceStore { func retire(_ ids: [UUID]) { for id in ids { policy.forget(id) + pendingText[id] = nil release(id) } } @@ -72,6 +76,19 @@ final class TerminalSurfaceStore { retire(Array(surfaces.keys)) } + /// Types `text` at the surface's prompt without pressing ⏎, now or once it is built. + func typeText(_ text: String, into id: UUID) { + guard let view = surfaces[id] else { + pendingText[id] = text + return + } + type(text, into: view) + } + + private func type(_ text: String, into view: GhosttyTerminalNSView) { + view.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + /// Whether a surface for `id` is currently alive. For tests and for callers deciding /// whether a rebuild is about to happen. func isRetained(_ id: UUID) -> Bool { surfaces[id] != nil } @@ -115,6 +132,8 @@ struct TerminalSurfaceClient: Sendable { /// `projectPath` is the workspace's, so a remote project's shells are killed on their /// own host. var killSessions: @Sendable (_ ids: [UUID], _ projectPath: String?) -> Void + /// Puts text at a surface's prompt, unsent — Nod's "Open in zsh tab". + var typeText: @Sendable (_ id: UUID, _ text: String) -> Void = { _, _ in } } extension TerminalSurfaceClient: DependencyKey { @@ -149,6 +168,11 @@ extension TerminalSurfaceClient: DependencyKey { await ZmxSessionLauncher.killSession(id: id, projectPath: projectPath) } } + }, + typeText: { id, text in + DispatchQueue.main.async { + MainActor.assumeIsolated { TerminalSurfaceStore.shared.typeText(text, into: id) } + } }) /// Tests exercise the retention rules against `SurfaceRetentionPolicy` directly; a From dd033f4d535cc115a3320518adad866521c95b58 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:42:06 -0700 Subject: [PATCH 3/7] Test the Nod transcript, chat reducer and control socket; render the pane headless Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Sources/Features/Nod/NodChatFeature.swift | 1 + .../Features/Nod/NodChatPaneView.swift | 98 +++-- .../Features/Nod/NodComposerView.swift | 13 +- .../Sources/Features/Nod/NodTranscript.swift | 10 +- .../Sources/Features/Nod/NodWorkCards.swift | 2 +- graphcode/Tests/NodChatFeatureTests.swift | 402 ++++++++++++++++++ graphcode/Tests/NodChatRenderTests.swift | 184 ++++++++ graphcode/Tests/NodFixtures.swift | 180 ++++++++ graphcode/Tests/NodTranscriptTests.swift | 339 +++++++++++++++ 9 files changed, 1183 insertions(+), 46 deletions(-) create mode 100644 graphcode/Tests/NodChatFeatureTests.swift create mode 100644 graphcode/Tests/NodChatRenderTests.swift create mode 100644 graphcode/Tests/NodFixtures.swift create mode 100644 graphcode/Tests/NodTranscriptTests.swift diff --git a/graphcode/Sources/Features/Nod/NodChatFeature.swift b/graphcode/Sources/Features/Nod/NodChatFeature.swift index b7d33ad6..14d9fef0 100644 --- a/graphcode/Sources/Features/Nod/NodChatFeature.swift +++ b/graphcode/Sources/Features/Nod/NodChatFeature.swift @@ -105,6 +105,7 @@ struct NodChatFeature { case commandFinished(NodCommandOutcome) case delegate(Delegate) + @CasePathable enum Delegate: Equatable { /// "Open in zsh tab": type the command into a plain shell tab beside the chat. case openInShellTab(command: String) diff --git a/graphcode/Sources/Features/Nod/NodChatPaneView.swift b/graphcode/Sources/Features/Nod/NodChatPaneView.swift index d60c7a59..7d79bd6d 100644 --- a/graphcode/Sources/Features/Nod/NodChatPaneView.swift +++ b/graphcode/Sources/Features/Nod/NodChatPaneView.swift @@ -155,7 +155,8 @@ struct NodChatPaneView: View { blockView(block) } if turn.isRunning, let activity = store.transcript.activity, - turn.number == store.transcript.currentTurn?.number + turn.number == store.transcript.currentTurn?.number, + !turn.items.contains(where: Self.isRunningTool) { HStack(spacing: 8) { NodSpinner() @@ -177,6 +178,12 @@ struct NodChatPaneView: View { .opacity(turn.isCompacted ? 0.6 : 1) } + /// A running tool card already says what the activity line would. + static func isRunningTool(_ item: NodTranscript.Item) -> Bool { + if case .tool(let card) = item { return card.status == .running } + return false + } + private func promptBubble(_ message: NodEvent.UserMessage, origin: NodTurnOrigin) -> some View { VStack(alignment: .trailing, spacing: 4) { if origin == .handoff || origin == .mail { @@ -273,41 +280,11 @@ struct NodChatPaneView: View { } private func assistantText(_ message: NodTranscript.Message) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(Self.markdown(message.text)) - .font(.system(size: 13.5)) - .lineSpacing(4) - .foregroundStyle(NodStyle.body) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - if message.isFinal { - HStack(spacing: 14) { - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(message.text, forType: .string) - } - Button("Fork from here") { store.send(.forkMenuToggled(messageID: message.id)) } - Spacer() - } - .buttonStyle(NodLinkButtonStyle()) - .font(.system(size: 11)) - .opacity(store.forkMenuMessageID == message.id ? 1 : 0.7) - if store.forkMenuMessageID == message.id { - NodForkMenuView( - onBranch: { store.send(.forkChosen(messageID: message.id, asSibling: false)) }, - onSibling: { store.send(.forkChosen(messageID: message.id, asSibling: true)) }) - } - } - } - } - - /// Inline code and emphasis from the model's markdown; block structure stays plain text - /// so a half-streamed fence never reflows the column. - static func markdown(_ text: String) -> AttributedString { - (try? AttributedString( - markdown: text, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) - ?? AttributedString(text) + NodAssistantMessageView( + message: message, isForkMenuOpen: store.forkMenuMessageID == message.id, + onFork: { store.send(.forkMenuToggled(messageID: message.id)) }, + onBranch: { store.send(.forkChosen(messageID: message.id, asSibling: false)) }, + onSibling: { store.send(.forkChosen(messageID: message.id, asSibling: true)) }) } private func steerRow(_ message: NodEvent.UserMessage) -> some View { @@ -429,3 +406,52 @@ struct NodBannerView: View { } } } + +/// One message of Nod's prose. Copy and Fork from here show while the pointer is on it +/// (design 3e), and stay while the fork menu it opened is up. +struct NodAssistantMessageView: View { + let message: NodTranscript.Message + let isForkMenuOpen: Bool + let onFork: () -> Void + let onBranch: () -> Void + let onSibling: () -> Void + + @State private var isHovering = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(Self.markdown(message.text)) + .font(.system(size: 13.5)) + .lineSpacing(4) + .foregroundStyle(NodStyle.body) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + if message.isFinal { + HStack(spacing: 14) { + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(message.text, forType: .string) + } + Button("Fork from here", action: onFork) + Spacer() + } + .buttonStyle(NodLinkButtonStyle()) + .font(.system(size: 11)) + .opacity(isHovering || isForkMenuOpen ? 1 : 0) + if isForkMenuOpen { + NodForkMenuView(onBranch: onBranch, onSibling: onSibling) + } + } + } + .onHover { isHovering = $0 } + } + + /// Inline code and emphasis from the model's markdown; block structure stays plain text + /// so a half-streamed fence never reflows the column. + static func markdown(_ text: String) -> AttributedString { + (try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) + ?? AttributedString(text) + } +} diff --git a/graphcode/Sources/Features/Nod/NodComposerView.swift b/graphcode/Sources/Features/Nod/NodComposerView.swift index a544dd30..f9f78f90 100644 --- a/graphcode/Sources/Features/Nod/NodComposerView.swift +++ b/graphcode/Sources/Features/Nod/NodComposerView.swift @@ -30,8 +30,8 @@ struct NodComposerView: View { .padding(.vertical, 10) .background(RoundedRectangle(cornerRadius: 12).fill(NodStyle.composerBackground)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.12), lineWidth: 1)) - .overlay(alignment: .bottomLeading) { - menu.alignmentGuide(.bottom) { $0[.top] - 6 } + .overlay(alignment: .topLeading) { + menu.alignmentGuide(.top) { $0[.bottom] + 6 } } .onDrop(of: [.fileURL], isTargeted: nil, perform: dropFiles) } @@ -80,7 +80,8 @@ struct NodComposerView: View { } label: { Text("+").font(.system(size: 11)).foregroundStyle(NodStyle.muted) } - .menuStyle(.borderlessButton) + .menuStyle(.button) + .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() @@ -91,7 +92,8 @@ struct NodComposerView: View { } label: { NodChip { Text("\(NodChatPresentation.modelLabel(store.model ?? "Model")) ▾") } } - .menuStyle(.borderlessButton) + .menuStyle(.button) + .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() @@ -105,7 +107,8 @@ struct NodComposerView: View { } label: { NodChip { Text("\(Self.editPolicyLabel(store.editPolicy)) ▾") } } - .menuStyle(.borderlessButton) + .menuStyle(.button) + .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() diff --git a/graphcode/Sources/Features/Nod/NodTranscript.swift b/graphcode/Sources/Features/Nod/NodTranscript.swift index a8f4e00e..61e65237 100644 --- a/graphcode/Sources/Features/Nod/NodTranscript.swift +++ b/graphcode/Sources/Features/Nod/NodTranscript.swift @@ -102,6 +102,7 @@ struct NodTranscript: Equatable { private(set) var activity: String? private(set) var failure: NodEvent.Failure? private(set) var lastSeq = 0 + private var lastAt = Date.distantPast var currentTurn: Turn? { turns.last.flatMap { $0.isRunning ? $0 : nil } } var isRunning: Bool { currentTurn != nil } @@ -123,12 +124,13 @@ struct NodTranscript: Equatable { mutating func apply(_ record: NodEventRecord) { // `seq` restarts at 1 when a resumed runtime opens a new run, which it announces with - // `sessionStarted`; anything else at or below the last seen seq is a replay. - if case .sessionStarted = record.event { - } else if record.seq <= lastSeq { - return + // a `sessionStarted` newer than anything seen; anything else at or below the last + // seen seq is a replay. + if record.seq <= lastSeq { + guard case .sessionStarted = record.event, record.at > lastAt else { return } } lastSeq = record.seq + lastAt = max(lastAt, record.at) switch record.event { case .sessionStarted(let started): diff --git a/graphcode/Sources/Features/Nod/NodWorkCards.swift b/graphcode/Sources/Features/Nod/NodWorkCards.swift index ecb54e1f..7d2c4566 100644 --- a/graphcode/Sources/Features/Nod/NodWorkCards.swift +++ b/graphcode/Sources/Features/Nod/NodWorkCards.swift @@ -21,7 +21,7 @@ struct NodToolCardView: View { } else { Text(isExpanded ? "▾" : "▸").font(.system(size: 9)) } - Text(card.call.tool).foregroundStyle(NodStyle.secondary) + Text(verb == .other ? card.call.tool : verb.label).foregroundStyle(NodStyle.secondary) Text(subject).font(NodStyle.mono).lineLimit(1).truncationMode(.middle) if let summary = card.result?.summary, !summary.isEmpty { Text("· \(summary)") diff --git a/graphcode/Tests/NodChatFeatureTests.swift b/graphcode/Tests/NodChatFeatureTests.swift new file mode 100644 index 00000000..3bc5e49f --- /dev/null +++ b/graphcode/Tests/NodChatFeatureTests.swift @@ -0,0 +1,402 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +private actor CommandSink { + private(set) var commands: [NodCommand] = [] + func append(_ command: NodCommand) { commands.append(command) } +} + +@MainActor +@Suite +struct NodChatFeatureTests { + private static let directory = URL(fileURLWithPath: "/tmp/nod-test") + + private func makeStore( + log: NodLog = NodLog(), sink: CommandSink = CommandSink(), + send: (@Sendable (NodCommand) async throws -> Void)? = nil + ) -> TestStoreOf { + var state = NodChatFeature.State( + nodeID: UUID(), stateDirectory: Self.directory, loopTitle: "Monetization", + loopType: .goalBased, goal: "Done when every paid route enforces the cap") + state.transcript = log.transcript + let store = TestStore(initialState: state) { + NodChatFeature() + } withDependencies: { + $0.nodClient.send = { _, command in + if let send { try await send(command) } else { await sink.append(command) } + } + } + store.exhaustivity = .off + return store + } + + @Test + func theTailFeedsTheTranscript() async { + let log = NodLog.monetization + let store = TestStore( + initialState: NodChatFeature.State( + nodeID: UUID(), stateDirectory: Self.directory, loopTitle: "M", loopType: .goalBased) + ) { + NodChatFeature() + } withDependencies: { + $0.nodClient.events = { directory in + #expect(directory == Self.directory) + return AsyncStream { continuation in + continuation.yield(log.records) + continuation.finish() + } + } + } + store.exhaustivity = .off + await store.send(.task) + await store.receive(\.eventsReceived) + #expect(store.state.transcript == log.transcript) + } + + @Test + func returnQueuesAndCommandReturnSteersWithTheAttachments() async { + let sink = CommandSink() + let store = makeStore(log: .monetization, sink: sink) + let file = NodAttachment(kind: .file, reference: "/repo/UsageGate.swift") + + await store.send(.attachmentAdded(file)) + await store.send(.draftChanged(" also log blocks ")) + await store.send(.returnPressed) + await store.receive(\.commandFinished) + #expect(store.state.draft == "") + #expect(store.state.attachments.isEmpty) + + await store.send(.draftChanged("use the fixture clock")) + await store.send(.commandReturnPressed) + await store.receive(\.commandFinished) + + #expect( + await sink.commands == [ + .send(.init(text: "also log blocks", delivery: .queue, attachments: [file])), + .send(.init(text: "use the fixture clock", delivery: .steer)), + ]) + } + + @Test + func anEmptyDraftSendsNothing() async { + let sink = CommandSink() + let store = makeStore(sink: sink) + await store.send(.draftChanged(" ")) + await store.send(.returnPressed) + #expect(await sink.commands.isEmpty) + } + + @Test + func escapeClosesWhatIsOpenBeforeItStopsNod() async { + let sink = CommandSink() + let store = makeStore(log: .monetization, sink: sink) + + await store.send(.forkMenuToggled(messageID: "m1")) + await store.send(.escapePressed) + #expect(store.state.forkMenuMessageID == nil) + + await store.send(.draftChanged("/com")) + await store.send(.escapePressed) + #expect(store.state.draft == "") + #expect(await sink.commands.isEmpty) + + await store.send(.escapePressed) + await store.receive(\.commandFinished) + #expect(await sink.commands == [.stop]) + } + + @Test + func escapeWhileIdleStopsNothing() async { + let sink = CommandSink() + var log = NodLog() + log.turn(1) + log.endTurn(1) + let store = makeStore(log: log, sink: sink) + await store.send(.escapePressed) + #expect(await sink.commands.isEmpty) + } + + @Test + func cardDecisionsGoOutAsCommands() async { + let sink = CommandSink() + let store = makeStore(log: .monetization, sink: sink) + + await store.send(.hunkDecided(hunkID: "h1", .accept)) + await store.receive(\.commandFinished) + await store.send(.permissionDecided(askID: "a1", .alwaysAllow)) + await store.receive(\.commandFinished) + await store.send(.markGoalDoneTapped) + await store.receive(\.commandFinished) + await store.send(.compactNowTapped) + await store.receive(\.commandFinished) + await store.send(.forkChosen(messageID: "m1", asSibling: false)) + await store.receive(\.commandFinished) + + #expect( + await sink.commands == [ + .resolveHunk(.init(hunkID: "h1", decision: .accept)), + .resolvePermission(.init(askID: "a1", decision: .alwaysAllow)), + .markGoalDone, + .compact, + .fork(.init(messageID: "m1")), + ]) + } + + @Test + func aCommentSendsTheHunkBackWithItsNote() async { + let sink = CommandSink() + let store = makeStore(log: .monetization, sink: sink) + + await store.send(.hunkDecided(hunkID: "h1", .comment)) + await store.receive(\.hunkCommentStarted) + #expect(store.state.commentingHunkID == "h1") + await store.send(.hunkCommentChanged("use 51 so it's past the cap")) + await store.send(.hunkCommentSubmitted) + await store.receive(\.commandFinished) + + #expect(store.state.commentingHunkID == nil) + #expect( + await sink.commands == [ + .resolveHunk(.init(hunkID: "h1", decision: .comment, note: "use 51 so it's past the cap")) + ]) + } + + @Test + func graphVerbsAndTheGoalGoUpAsDelegates() async { + let sink = CommandSink() + let store = makeStore(log: .monetization, sink: sink) + + await store.send(.draftChanged("/handoff Release notes")) + await store.send(.returnPressed) + await store.receive(\.delegate.graphCommand) + + await store.send(.draftChanged("/goal")) + await store.send(.returnPressed) + await store.receive(\.delegate.editGoal) + + await store.send(.forkChosen(messageID: "m1", asSibling: true)) + await store.receive(\.delegate.forkAsSibling) + + await store.send(.runPlanTapped(planID: "p1", mode: .composite)) + await store.receive(\.delegate.runPlanAsComposite) + + await store.send(.openInShellTabTapped(command: "swift test --filter UsageCap")) + await store.receive(\.delegate.openInShellTab) + + #expect(await sink.commands.isEmpty) + } + + @Test + func slashCompactIsTheCompactCommand() async { + let sink = CommandSink() + let store = makeStore(sink: sink) + await store.send(.draftChanged("/compact")) + await store.send(.returnPressed) + await store.receive(\.commandFinished) + #expect(await sink.commands == [.compact]) + } + + @Test + func aRunningLoopIsMessagedAndAFinishedOneAttachedWithTabSwapping() async { + let running = UUID() + let done = UUID() + let store = makeStore() + + await store.send(.draftChanged("check that @bi")) + await store.send( + .mentionChosen( + NodMention(title: "Billing UI", kind: .loop(id: running, isRunning: true, detail: "")), + alternate: false)) + await store.receive(\.delegate.messageLoop) + #expect(store.state.draft == "check that @Billing UI ") + + await store.send(.draftChanged("@mig")) + await store.send( + .mentionChosen( + NodMention( + title: "Billing migration", kind: .loop(id: done, isRunning: false, detail: "")), + alternate: false)) + #expect( + store.state.attachments == [ + NodAttachment(kind: .loopTranscript, reference: done.uuidString, label: "Billing migration") + ]) + + await store.send(.draftChanged("@bi")) + await store.send( + .mentionChosen( + NodMention(title: "Billing UI", kind: .loop(id: running, isRunning: true, detail: "")), + alternate: true)) + #expect(store.state.attachments.count == 2) + } + + @Test + func aChosenModelShowsOnceTheRuntimeTakesIt() async { + let store = makeStore(log: .monetization) + #expect(store.state.model == "claude-sonnet-4-5") + await store.send(.modelChosen("opus")) + await store.receive(\.commandFinished) + #expect(store.state.model == "opus") + } + + @Test + func aRefusedCommandShowsItsError() async { + let store = makeStore(log: .monetization) { _ in + throw NodControlError.rejected("no such hunk") + } + await store.send(.hunkDecided(hunkID: "nope", .accept)) + await store.receive(\.commandFinished) + #expect(store.state.sendError == "no such hunk") + await store.send(.errorDismissed) + #expect(store.state.sendError == nil) + } + + @Test + func failedToolCallsOpenOnTheirOwn() { + var log = NodLog() + log.turn(1) + log.tool("ok", turn: 1, tool: "Bash", title: "ls") + log.tool("bad", turn: 1, tool: "Bash", title: "swift build", status: "error") + var state = NodChatFeature.State(nodeID: UUID(), loopTitle: "M", loopType: .sketch) + state.transcript = log.transcript + let cards = state.transcript.turns[0].items.toolCards + #expect(cards.map(state.isToolExpanded) == [false, true]) + } +} + +@MainActor +@Suite +struct NodWorkspaceWiringTests { + private final class TypedBox: @unchecked Sendable { + var typed: [(UUID, String)] = [] + } + + private func workspace(backend: CLISessionBackendKind) -> LoopWorkspaceFeature.State { + let node = LoopNode( + title: "Monetization", loopType: .goalBased, + goal: GoalSpec(summary: "every paid route enforces the cap"), backend: backend) + return LoopWorkspaceFeature.State( + node: node, layout: .defaultLayout(forNode: node.id), projectPath: "/tmp/project", + projectName: "project") + } + + @Test + func onlyAChatLoopGetsAChat() async { + let store = TestStore(initialState: workspace(backend: .claudeCode)) { + LoopWorkspaceFeature() + } + await store.send(.chatSurfaceAppeared) + + let nod = TestStore(initialState: workspace(backend: .nod)) { LoopWorkspaceFeature() } + nod.exhaustivity = .off + await nod.send(.chatSurfaceAppeared) + #expect(nod.state.nodChat?.goal == "every paid route enforces the cap") + #expect(nod.state.nodChat?.loopType == .goalBased) + #expect(nod.state.nodChat?.nodeID == nod.state.node.id) + } + + @Test + func openInShellTabTypesIntoAPlainShellTabMakingOneIfNeeded() async { + let box = TypedBox() + let store = TestStore(initialState: workspace(backend: .nod)) { + LoopWorkspaceFeature() + } withDependencies: { + $0.terminalLayoutStore = TerminalLayoutStore( + baseDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString)) + $0.terminalSurfaceClient.typeText = { id, text in box.typed.append((id, text)) } + } + store.exhaustivity = .off + await store.send(.chatSurfaceAppeared) + let chatTab = store.state.layout.selectedTabID + + await store.send(.nodChat(.delegate(.openInShellTab(command: "swift test")))) + #expect(store.state.layout.tabs.count == 2) + let shellTab = store.state.layout.tabs[1] + #expect(store.state.layout.selectedTabID == shellTab.id) + #expect(!shellTab.primary.launchesClaudeCode) + #expect(box.typed.map(\.0) == [shellTab.primary.id]) + #expect(box.typed.map(\.1) == ["swift test"]) + + await store.send(.tabSelected(chatTab)) + await store.send(.nodChat(.delegate(.openInShellTab(command: "make lint")))) + #expect(store.state.layout.tabs.count == 2) + #expect(store.state.layout.selectedTabID == shellTab.id) + #expect(box.typed.map(\.1) == ["swift test", "make lint"]) + } +} + +@Suite +struct NodControlSocketTests { + /// A one-shot runtime stand-in: accepts one connection, reads one line, answers `reply`. + private func serve(reply: String) throws -> (path: String, received: () -> String) { + let path = "/tmp/nod-\(UUID().uuidString.prefix(8)).sock" + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: Array(path.utf8)) } + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + #expect(bound == 0) + listen(fd, 1) + let lock = NSLock() + nonisolated(unsafe) var received = "" + let done = DispatchSemaphore(value: 0) + Thread.detachNewThread { + let client = accept(fd, nil, nil) + var buffer = [UInt8](repeating: 0, count: 4096) + let count = read(client, &buffer, buffer.count) + lock.lock() + received = String(decoding: buffer[0..(_ name: String, width: CGFloat, height: CGFloat, _ view: V) + throws + { + let host = NSHostingView( + rootView: view.frame(width: width, height: height).environment(\.colorScheme, .dark)) + host.frame = CGRect(x: 0, y: 0, width: width, height: height) + host.layoutSubtreeIfNeeded() + let rep = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: rep) + #expect(rep.pixelsWide >= Int(width)) + guard FileManager.default.fileExists(atPath: Self.outputDirectory.path) else { return } + let png = try #require(rep.representation(using: .png, properties: [:])) + try png.write(to: Self.outputDirectory.appendingPathComponent("\(name).png")) + } + + private func store(_ log: NodLog, configure: (inout NodChatFeature.State) -> Void = { _ in }) + -> StoreOf + { + var state = NodChatFeature.State( + nodeID: UUID(), loopTitle: "Monetization", loopType: .goalBased, + branch: "loop/monetization", + goal: "Done when every paid route enforces the cap and swift test passes") + state.transcript = log.transcript + configure(&state) + return Store(initialState: state) { + NodChatFeature() + } withDependencies: { + $0.nodClient = .replaying(log.records) + } + } + + @Test + func theReadingColumn() throws { + try render( + "1-pane-running", width: 900, height: 1100, + NodChatPaneView(store: store(.monetization), projectName: "graphcode")) + } + + @Test + func theGoalHeaderOpenWithAToolCardOpen() throws { + let pane = NodChatPaneView( + store: store(.monetization) { + $0.isGoalExpanded = true + $0.expandedTools = ["c3"] + }, projectName: "graphcode") + try render("2-goal-open-tool-open", width: 900, height: 1300, pane) + } + + @Test + func aLongTurnFoldsIntoAWorkBlock() throws { + var log = NodLog() + log.session() + log.user("Fix /export and add a test that hits the limit.", id: "u1") + log.turn(4) + log.say( + "`ExportRoute` is registered before `UsageGate`. Moving it into the paid group and adding `testExportBlocksPastCap`.", + turn: 4, id: "m1") + for (index, file) in ["UsageGate.swift", "Routes.swift", "ExportRoute.swift"].enumerated() { + log.tool("r\(index)", turn: 4, tool: "Read", title: "Read \(file)", summary: "84 lines", ms: 300) + } + log.tool("s1", turn: 4, tool: "Grep", title: "Search \"paid\"", summary: "9 hits", ms: 400) + log.tool("e1", turn: 4, tool: "Edit", title: "Edit Routes.swift", summary: "+3 −1", ms: 50) + log.tool( + "e2", turn: 4, tool: "Edit", title: "Edit UsageCapTests.swift", summary: "+22", ms: 50) + log.hunk( + "h2", turn: 4, file: "Tests/UsageCapTests.swift", header: "@@ 18,4 @@ testExportBlocksPastCap", + diff: """ + + for _ in 0..<50 { try await app.export(user) } + + let res = try await app.export(user) + + XCTAssertEqual(res.status, .paymentRequired) + """, added: 3, removed: 0) + log.tool("b1", turn: 4, tool: "Bash", title: "swift test --filter UsageCap", status: nil) + log.user("use the fixture clock, not Date()", id: "u2", delivery: "steer") + log.user("also log when a request is blocked", id: "u3") + log.usage(cost: 1.18, context: 0.44) + + try render( + "3-work-folded", width: 900, height: 900, + NodChatPaneView(store: store(log), projectName: "graphcode")) + try render( + "4-work-open-commenting", width: 900, height: 1100, + NodChatPaneView( + store: store(log) { + $0.expandedWork = [4] + $0.commentingHunkID = "h2" + $0.hunkComment = "use 51 so it's past the cap" + }, projectName: "graphcode")) + } + + @Test + func cardsMetResolvedAndForking() throws { + var log = NodLog.monetization + log.add("permissionResolved", ["askID": "a1", "decision": "allowOnce"]) + log.add("hunkResolved", ["hunkID": "h1", "decision": "accept"]) + log.add("toolResult", ["callID": "c4", "status": "ok", "summary": "exit 0", "durationMs": 9000]) + log.say( + "Two ways to gate /export: move it into the paid group, or check the cap inside the handler.", + turn: 2, id: "m3") + log.goalCheck( + turn: 2, met: true, + clauses: [ + ("Every paid route goes through UsageGate", true, "4 / 4 routes"), + ("swift test passes", true, "31 tests pass"), + ]) + log.endTurn(2, files: 2, added: 25, removed: 1) + try render( + "5-goal-holds-fork-menu", width: 900, height: 1500, + NodChatPaneView( + store: store(log) { $0.forkMenuMessageID = "m3" }, projectName: "graphcode")) + } + + @Test + func theThreeFailureBanners() throws { + var expired = NodLog.monetization + expired.failure("signInExpired", "Copilot sign-in expired. Nod paused after turn 6, and nothing was lost.") + var context = NodLog.monetization + context.usage(cost: 0.2, context: 0.82) + var cap = NodLog.monetization + cap.failure("spendCap", "Nightly deps hit its $2.00 cap this run. Stopped mid-turn 3.") + + let column = VStack(spacing: 10) { + ForEach(Array([expired, context, cap].enumerated()), id: \.offset) { _, log in + if let banner = NodChatPresentation.banner(for: log.transcript) { + NodBannerView(banner: banner, onSignIn: {}, onCompact: {}, onRaiseCap: {}) + } + } + } + .padding(20) + .background(NodStyle.paneBackground) + try render("6-banners", width: 720, height: 200, column) + } + + @Test + func theComposerWithAttachmentsAndItsMenus() throws { + let idle = store(NodLog()) { + $0.attachments = [ + NodAttachment(kind: .file, reference: "/repo/UsageGate.swift"), + NodAttachment(kind: .image, reference: "/tmp/banner.png"), + NodAttachment(kind: .loopTranscript, reference: UUID().uuidString, label: "Pricing"), + ] + $0.draft = "Make the upgrade banner match this screenshot, using the limits from Pricing." + } + let slash = store(.monetization) { $0.draft = "/" } + let mention = store(.monetization) { + $0.draft = "Check that @bi" + $0.mentionCandidates = [ + NodMention( + title: "Billing UI", + kind: .loop(id: UUID(), isRunning: true, detail: "Turn · running")), + NodMention( + title: "Billing migration", + kind: .loop(id: UUID(), isRunning: false, detail: "Composite · done 3d")), + NodMention(title: "BillingBanner.swift", kind: .file(path: "App/BillingBanner.swift")), + ] + } + let column = VStack(spacing: 24) { + NodComposerView(store: idle).padding(.top, 10) + NodComposerView(store: slash).padding(.top, 250) + NodComposerView(store: mention).padding(.top, 170) + } + .padding(20) + .frame(maxHeight: .infinity, alignment: .top) + .background(NodStyle.paneBackground) + try render("7-composer", width: 720, height: 700, column) + } +} diff --git a/graphcode/Tests/NodFixtures.swift b/graphcode/Tests/NodFixtures.swift new file mode 100644 index 00000000..a9cfa9fb --- /dev/null +++ b/graphcode/Tests/NodFixtures.swift @@ -0,0 +1,180 @@ +import Foundation +import GraphcodeKit + +@testable import graphcode + +/// Builds `events.jsonl` records the way the runtime writes them — JSON on the wire, so +/// the tests go through the same decoder the pane does and never need the payloads' +/// (internal) memberwise inits. +struct NodLog { + private(set) var records: [NodEventRecord] = [] + private var seq = 0 + private var clock: Date + + init(startingAt start: Date = Date(timeIntervalSince1970: 1_790_000_000)) { + clock = start + } + + /// The next run of the same runtime: seq starts again, the clock carries on. + init(after previous: NodLog) { + clock = previous.clock + 60 + } + + @discardableResult + mutating func add(_ type: String, _ fields: [String: Any] = [:]) -> NodEventRecord { + seq += 1 + clock += 1 + var object = fields + object["v"] = 1 + object["seq"] = seq + object["at"] = ISO8601DateFormatter().string(from: clock) + object["type"] = type + let data = try! JSONSerialization.data(withJSONObject: object) + let record = try! NodProtocol.makeDecoder().decode(NodEventRecord.self, from: data) + records.append(record) + return record + } + + var jsonLines: Data { + let encoder = NodProtocol.makeEncoder() + return records.reduce(into: Data()) { data, record in + data.append(try! encoder.encode(record)) + data.append(UInt8(ascii: "\n")) + } + } + + var transcript: NodTranscript { NodTranscript(replaying: records) } + + mutating func session(model: String = "claude-sonnet-4-5", engine: String = "claude") { + add( + "sessionStarted", + ["engine": engine, "model": model, "conversationID": "c-1", "resumed": false]) + } + + mutating func user(_ text: String, id: String, delivery: String = "queue") { + add("userMessage", ["id": id, "text": text, "delivery": delivery, "attachments": []]) + } + + mutating func turn(_ number: Int, origin: String = "user") { + add("turnStarted", ["turn": number, "origin": origin]) + } + + mutating func say(_ text: String, turn: Int, id: String, final: Bool = true) { + add("assistantText", ["turn": turn, "messageID": id, "delta": text, "final": final]) + } + + mutating func tool( + _ id: String, turn: Int, tool: String, title: String, status: String? = "ok", + summary: String = "", output: String? = nil, ms: Int? = nil + ) { + add("toolCall", ["turn": turn, "callID": id, "tool": tool, "title": title]) + guard let status else { return } + var result: [String: Any] = ["callID": id, "status": status, "summary": summary] + if let output { result["output"] = output } + if let ms { result["durationMs"] = ms } + add("toolResult", result) + } + + mutating func hunk( + _ id: String, turn: Int, file: String, header: String, diff: String, added: Int, + removed: Int, auto: Bool = false + ) { + add( + "hunkStaged", + [ + "turn": turn, "hunkID": id, "file": file, "header": header, "diff": diff, + "added": added, "removed": removed, "autoAccepted": auto, + ]) + } + + mutating func ask(_ id: String, kind: String = "shell", subject: String, reason: String) { + add( + "permissionAsked", + [ + "askID": id, "kind": kind, "subject": subject, "reason": reason, + "answerableFromCard": false, + ]) + } + + mutating func goalCheck(turn: Int, met: Bool, clauses: [(String, Bool, String?)]) { + add( + "goalCheck", + [ + "turn": turn, "evaluatorModel": "claude-haiku-4-5", "met": met, + "clauses": clauses.map { text, met, evidence -> [String: Any] in + var clause: [String: Any] = ["text": text, "met": met] + if let evidence { clause["evidence"] = evidence } + return clause + }, + ]) + } + + mutating func endTurn(_ number: Int, files: Int = 0, added: Int = 0, removed: Int = 0) { + add("turnEnded", ["turn": number, "filesChanged": files, "added": added, "removed": removed]) + } + + mutating func usage(cost: Double? = nil, premium: Int? = nil, context: Double) { + var fields: [String: Any] = ["inputTokens": 1000, "outputTokens": 200, "contextUsed": context] + if let cost { fields["costUSD"] = cost } + if let premium { fields["premiumRequests"] = premium } + add("usage", fields) + } + + mutating func failure(_ kind: String, _ message: String) { + add("failure", ["kind": kind, "message": message]) + } + + /// The design's running example (1a): a Goal loop fixing the /export usage cap, in turn + /// 2 with a staged hunk, a test run in flight and a note queued behind it. + static var monetization: NodLog { + var log = NodLog() + log.session() + log.user( + "The cap isn't enforced on /export. Fix it and add a test that hits the limit.", id: "u1") + log.turn(1) + log.say( + "Found it. `ExportRoute` is registered before `UsageGate` runs, so the middleware never sees it.", + turn: 1, id: "m1") + log.tool( + "c1", turn: 1, tool: "Read", title: "Read UsageGate.swift", summary: "84 lines", ms: 120) + log.tool( + "c2", turn: 1, tool: "Grep", title: "Search \"UsageGate\"", summary: "6 hits in 4 files", + ms: 400) + log.hunk( + "h1", turn: 1, file: "Sources/Server/Routes.swift", + header: "@@ 41,6 @@ func routes(_ app: Application)", + diff: """ + let paid = app.grouped(UsageGate()) + - app.post("export", use: ExportRoute.handle) + + paid.post("export", use: ExportRoute.handle) + + // export counts toward the cap like every paid route + """, added: 3, removed: 1) + log.tool( + "c3", turn: 1, tool: "Bash", title: "swift test --filter UsageCap", status: "ok", + summary: "exit 0", + output: """ + Test Suite 'UsageCapTests' started + ✓ testPaidRoutesAreGated (0.02s) + ✓ testExportBlocksPastCap (0.04s) + ✓ testCapResetsMonthly (0.01s) + Executed 3 tests, with 0 failures + """, ms: 14000) + log.goalCheck( + turn: 1, met: false, + clauses: [ + ("Every paid route goes through UsageGate", true, "4 / 4 routes"), + ("swift test passes", false, "1 failure · LegacyExportTests"), + ]) + log.endTurn(1, files: 1, added: 3, removed: 1) + log.usage(cost: 0.42, context: 0.31) + log.turn(2, origin: "goalCheck") + log.say("LegacyExportTests still posts to /export without a plan. Updating it.", turn: 2, id: "m2") + log.ask( + "a1", subject: "swift package resolve", + reason: "Fetches dependencies over the network. Not in this project's allowlist.") + log.tool("c4", turn: 2, tool: "Bash", title: "swift test --filter UsageCap", status: "running") + log.add("activity", ["line": "Running swift test --filter UsageCap · 14s"]) + log.user("also log when a request is blocked", id: "u2") + return log + } +} diff --git a/graphcode/Tests/NodTranscriptTests.swift b/graphcode/Tests/NodTranscriptTests.swift new file mode 100644 index 00000000..4e7eda87 --- /dev/null +++ b/graphcode/Tests/NodTranscriptTests.swift @@ -0,0 +1,339 @@ +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +@Suite +struct NodTranscriptTests { + @Test + func aMessageSentWhileIdleBecomesThePromptWhicheverRecordComesFirst() { + var before = NodLog() + before.session() + before.user("fix the cap", id: "u1") + before.turn(1) + #expect(before.transcript.turns.map(\.prompt?.text) == ["fix the cap"]) + #expect(before.transcript.queued.isEmpty) + + var after = NodLog() + after.session() + after.turn(1) + after.user("fix the cap", id: "u1") + #expect(after.transcript.turns.map(\.prompt?.text) == ["fix the cap"]) + #expect(after.transcript.queued.isEmpty) + } + + @Test + func deltasWithOneMessageIDConcatenate() { + var log = NodLog() + log.turn(1) + log.say("Found ", turn: 1, id: "m1", final: false) + log.say("it.", turn: 1, id: "m1", final: true) + log.say("Next.", turn: 1, id: "m2", final: false) + + let items = log.transcript.turns[0].items + #expect(items.count == 2) + guard case .text(let first) = items[0], case .text(let second) = items[1] else { + Issue.record("expected two text items, got \(items)") + return + } + #expect(first == .init(id: "m1", text: "Found it.", isFinal: true)) + #expect(second == .init(id: "m2", text: "Next.", isFinal: false)) + } + + @Test + func aToolResultLandsOnItsCall() { + var log = NodLog() + log.turn(1) + log.tool("c1", turn: 1, tool: "Bash", title: "swift test", status: nil) + #expect(log.transcript.turns[0].items.toolCards.map(\.status) == [.running]) + + log.add("toolResult", ["callID": "c1", "status": "error", "summary": "exit 1"]) + let card = log.transcript.turns[0].items.toolCards[0] + #expect(card.status == .error) + #expect(card.result?.summary == "exit 1") + } + + @Test + func aQueuedMessageWaitsForTheTurnItStarts() { + var log = NodLog() + log.user("first", id: "u1") + log.turn(1) + log.user("second", id: "u2") + #expect(log.transcript.queued.map(\.text) == ["second"]) + #expect(log.transcript.currentTurn?.number == 1) + + log.endTurn(1) + log.turn(2, origin: "queue") + let transcript = log.transcript + #expect(transcript.queued.isEmpty) + #expect(transcript.turns.map(\.prompt?.text) == ["first", "second"]) + } + + @Test + func aSteerLandsInTheRunningTurn() { + var log = NodLog() + log.user("first", id: "u1") + log.turn(1) + log.tool("c1", turn: 1, tool: "Edit", title: "Edit UsageCapTests.swift") + log.user("use the fixture clock", id: "u2", delivery: "steer") + + let transcript = log.transcript + #expect(transcript.queued.isEmpty) + guard case .steer(let steer) = transcript.turns[0].items.last else { + Issue.record("expected a steer item") + return + } + #expect(steer.text == "use the fixture clock") + } + + @Test + func hunksAreStagedUntilResolvedAndAutoAcceptedOnesReadAsAccepted() { + var log = NodLog() + log.turn(1) + log.hunk("h1", turn: 1, file: "A.swift", header: "@@ 1,3 @@", diff: "+a", added: 1, removed: 0) + log.hunk( + "h2", turn: 1, file: "B.swift", header: "@@ 1,3 @@", diff: "+b", added: 1, removed: 0, + auto: true) + #expect(log.transcript.turns[0].items.hunkCards.map(\.decision) == [nil, .accept]) + + log.add("hunkResolved", ["hunkID": "h1", "decision": "comment", "note": "use 51"]) + let cards = log.transcript.turns[0].items.hunkCards + #expect(cards[0].decision == .comment) + #expect(cards[0].resolution?.note == "use 51") + } + + @Test + func anOpenAskIsNeedsYouUntilResolved() { + var log = NodLog() + log.turn(1) + log.ask("a1", subject: "swift package resolve", reason: "network") + #expect(log.transcript.openAsks.map(\.askID) == ["a1"]) + + log.add("permissionResolved", ["askID": "a1", "decision": "allowOnce"]) + #expect(log.transcript.openAsks.isEmpty) + } + + @Test + func theGoalHeaderCountsChecksAndShowsTheLatest() { + var log = NodLog() + #expect(NodChatPresentation.goalVerdict(for: log.transcript) == .unchecked) + log.turn(1) + log.goalCheck(turn: 1, met: false, clauses: [("routes", true, nil), ("tests", false, nil)]) + log.turn(2, origin: "goalCheck") + log.goalCheck(turn: 2, met: false, clauses: [("routes", true, nil), ("tests", false, nil)]) + #expect(NodChatPresentation.goalVerdict(for: log.transcript) == .notYet(checks: 2)) + #expect( + NodChatPresentation.verdictLabel(.notYet(checks: 2)) == "not yet · checked 2×") + + log.goalCheck(turn: 2, met: true, clauses: [("routes", true, nil), ("tests", true, nil)]) + #expect( + NodChatPresentation.goalVerdict(for: log.transcript) == .holds(metClauses: 2, of: 2)) + } + + @Test + func costSumsPerCallAndCopilotShowsPremiumRequests() { + var claude = NodLog() + claude.session() + claude.usage(cost: 0.25, context: 0.1) + claude.usage(cost: 0.5, context: 0.2) + #expect(NodChatPresentation.costLabel(for: claude.transcript) == "$0.75") + + var copilot = NodLog() + copilot.session(model: "gpt-5", engine: "copilot") + copilot.usage(premium: 1, context: 0.1) + copilot.usage(premium: 2, context: 0.1) + #expect(NodChatPresentation.costLabel(for: copilot.transcript) == "3 premium") + } + + @Test + func bannersFollowFailuresAndClearWhenTheirCauseDoes() { + var log = NodLog() + log.session() + log.turn(1) + log.usage(cost: 0.1, context: 0.5) + #expect(NodChatPresentation.banner(for: log.transcript) == nil) + + log.usage(cost: 0.1, context: 0.82) + #expect(NodChatPresentation.banner(for: log.transcript) == .contextNearlyFull(percent: 82)) + + log.failure("spendCap", "Nightly deps hit its $2.00 cap this run.") + #expect( + NodChatPresentation.banner(for: log.transcript) + == .spendCap("Nightly deps hit its $2.00 cap this run.")) + log.turn(2) + #expect(NodChatPresentation.banner(for: log.transcript) == .contextNearlyFull(percent: 82)) + + log.failure("contextFull", "full") + log.add("compacted", ["fromTurn": 1, "throughTurn": 1]) + log.usage(cost: 0.1, context: 0.3) + #expect(NodChatPresentation.banner(for: log.transcript) == nil) + #expect(log.transcript.turns.map(\.isCompacted) == [true, false]) + + log.failure("signInExpired", "Copilot sign-in expired.") + #expect( + NodChatPresentation.banner(for: log.transcript) == .signInExpired("Copilot sign-in expired.")) + log.session() + #expect(NodChatPresentation.banner(for: log.transcript) == nil) + } + + @Test + func replayedRecordsAreDroppedButANewRunStartsItsOwnSequence() { + var log = NodLog() + log.session() + log.turn(1) + log.say("once", turn: 1, id: "m1", final: false) + + var transcript = log.transcript + for record in log.records { transcript.apply(record) } + guard case .text(let message) = transcript.turns[0].items[0] else { + Issue.record("expected text") + return + } + #expect(message.text == "once") + + // A resumed runtime opens a new run whose seq starts again at 1. + var resumed = NodLog(after: log) + resumed.session() + resumed.turn(2) + for record in resumed.records { transcript.apply(record) } + #expect(transcript.turns.map(\.number) == [1, 2]) + #expect(transcript.turns[0].wasInterrupted) + #expect(transcript.currentTurn?.number == 2) + } + + @Test + func unknownEventsAreSkipped() { + var log = NodLog() + log.turn(1) + log.add("somethingNewer", ["x": 1]) + log.say("still here", turn: 1, id: "m1") + #expect(log.transcript.turns[0].items.count == 1) + } + + @Test + func aTurnPastFiveToolCallsFoldsThemIntoOneWorkBlock() { + var log = NodLog() + log.turn(1) + log.say("Looking.", turn: 1, id: "m1") + for index in 1...3 { + log.tool("r\(index)", turn: 1, tool: "Read", title: "Read F\(index).swift", ms: 100) + } + log.tool("s1", turn: 1, tool: "Grep", title: "Search \"x\"", ms: 100) + log.tool("e1", turn: 1, tool: "Bash", title: "swift build", status: "error", summary: "exit 1") + log.hunk("h1", turn: 1, file: "A.swift", header: "@@", diff: "+a", added: 1, removed: 0) + log.tool("e2", turn: 1, tool: "Edit", title: "Edit A.swift", ms: 100) + log.tool("b1", turn: 1, tool: "Bash", title: "swift test", status: nil) + + let blocks = NodChatPresentation.blocks(for: log.transcript.turns[0]) + #expect(blocks.map(\.id) == ["text:m1", "work:1", "tool:e1", "hunk:h1"]) + guard case .work(let work) = blocks[1] else { + Issue.record("expected the work block second") + return + } + #expect(work.tools.map(\.call.callID) == ["r1", "r2", "r3", "s1", "e2", "b1"]) + #expect(work.line == "read 3 · searched 1 · edited 1 · ran 1") + #expect(work.running?.call.callID == "b1") + #expect(work.durationMs == 500) + } + + @Test + func fiveToolCallsStayUnfolded() { + var log = NodLog() + log.turn(1) + for index in 1...5 { + log.tool("r\(index)", turn: 1, tool: "Read", title: "Read F\(index).swift") + } + let blocks = NodChatPresentation.blocks(for: log.transcript.turns[0]) + #expect(blocks.count == 5) + #expect(!blocks.contains { if case .work = $0 { return true } else { return false } }) + } + + @Test + func theDesignScenarioFoldsToTwoTurnsWithAQueuedNote() { + let transcript = NodLog.monetization.transcript + #expect(transcript.session?.engine == .claudeAgentSDK) + #expect(transcript.turns.map(\.number) == [1, 2]) + #expect(transcript.turns[0].ended?.filesChanged == 1) + #expect(transcript.currentTurn?.number == 2) + #expect(transcript.queued.map(\.text) == ["also log when a request is blocked"]) + #expect(transcript.openAsks.map(\.askID) == ["a1"]) + #expect(transcript.activity == "Running swift test --filter UsageCap · 14s") + #expect(transcript.lastGoalCheck?.clauses.count == 2) + } +} + +extension [NodTranscript.Item] { + var toolCards: [NodTranscript.ToolCard] { + compactMap { if case .tool(let card) = $0 { return card } else { return nil } } + } + + var hunkCards: [NodTranscript.HunkCard] { + compactMap { if case .hunk(let card) = $0 { return card } else { return nil } } + } +} + +@Suite +struct NodEventTailTests { + @Test + func aTornLineWaitsForTheRestOfIt() throws { + let lines = NodLog.monetization.jsonLines + let cut = lines.count - 20 + var tail = NodEventTail() + let first = tail.consume(lines.prefix(cut)) + let second = tail.consume(lines.suffix(from: cut)) + #expect(first.count + second.count == NodLog.monetization.records.count) + #expect(second.count == 1) + #expect(tail.offset == UInt64(lines.count)) + } + + @Test + func resetStartsOverFromTheTop() { + var tail = NodEventTail() + _ = tail.consume(Data("{\"partial".utf8)) + tail.reset() + #expect(tail.offset == 0) + #expect(tail.consume(NodLog.monetization.jsonLines).count == NodLog.monetization.records.count) + } +} + +@Suite +struct NodComposerMenuTests { + @Test + func slashOpensOnlyAtTheStartAndAtSignAfterWhitespace() { + #expect(NodComposerTrigger.detect(in: "/com") == .slash("com")) + #expect(NodComposerTrigger.detect(in: "/compact now") == nil) + #expect(NodComposerTrigger.detect(in: "check that @bi") == .mention("bi")) + #expect(NodComposerTrigger.detect(in: "@") == .mention("")) + #expect(NodComposerTrigger.detect(in: "mail me@host") == nil) + #expect(NodComposerTrigger.detect(in: "@Billing done") == nil) + #expect(NodComposerTrigger.detect(in: "plain") == nil) + } + + @Test + func slashCommandsFilterByPrefixAndKeepTheirGroups() { + #expect(NodSlashCommand.matching("").count == 7) + #expect(NodSlashCommand.matching("p").map(\.name) == ["plan", "promote"]) + #expect(NodSlashCommand.matching("ha").map(\.group) == [.graph]) + } + + @Test + func aRunningLoopIsMessagedAndAFinishedOneAttached() { + let running = NodMention( + title: "Billing UI", kind: .loop(id: UUID(), isRunning: true, detail: "Turn · running")) + let done = NodMention( + title: "Billing migration", kind: .loop(id: UUID(), isRunning: false, detail: "done 3d")) + #expect(running.defaultActionLabel == "message") + #expect(done.defaultActionLabel == "attach") + #expect(NodMention.matching("bi", in: [running, done]).count == 2) + #expect(NodMention.matching("mig", in: [running, done]) == [done]) + } + + @Test + func modelLabelsAreFamilyNames() { + #expect(NodChatPresentation.modelLabel("claude-sonnet-4-5") == "Sonnet") + #expect(NodChatPresentation.modelLabel("haiku") == "Haiku") + #expect(NodChatPresentation.modelLabel("gpt-5") == "GPT-5") + #expect(NodChatPresentation.modelLabel("gemini-2.5-pro") == "gemini-2.5-pro") + } +} From fcc30438fab905c8e0f6f7e0558bd1c4974d31b9 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:53:57 -0700 Subject: [PATCH 4/7] Add graph-layer slots, edited plan steps, saved always-allow and gated actions to Nod's chat Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Sources/Features/Nod/NodChatFeature.swift | 51 ++++- .../Features/Nod/NodChatPaneView.swift | 19 +- .../Features/Nod/NodChatPresentation.swift | 3 +- .../Sources/Features/Nod/NodClient.swift | 25 +++ .../Features/Nod/NodComposerView.swift | 6 +- .../Sources/Features/Nod/NodGraphSlots.swift | 11 +- .../Sources/Features/Nod/NodTranscript.swift | 194 +++++++++-------- .../Sources/Features/Nod/NodWorkCards.swift | 19 +- graphcode/Tests/NodChatFeatureTests.swift | 200 ++++++------------ graphcode/Tests/NodChatRenderTests.swift | 42 +++- graphcode/Tests/NodChatWiringTests.swift | 140 ++++++++++++ graphcode/Tests/NodFixtures.swift | 10 +- 12 files changed, 476 insertions(+), 244 deletions(-) create mode 100644 graphcode/Tests/NodChatWiringTests.swift diff --git a/graphcode/Sources/Features/Nod/NodChatFeature.swift b/graphcode/Sources/Features/Nod/NodChatFeature.swift index 14d9fef0..932d11d2 100644 --- a/graphcode/Sources/Features/Nod/NodChatFeature.swift +++ b/graphcode/Sources/Features/Nod/NodChatFeature.swift @@ -38,6 +38,8 @@ struct NodChatFeature { var commentingHunkID: String? var hunkComment = "" var sendError: String? + /// `NodCommand.type`s this runtime has refused — their actions show disabled. + var unavailableCommands: Set = [] init( nodeID: UUID, stateDirectory: URL? = nil, loopTitle: String, loopType: LoopType, @@ -95,7 +97,8 @@ struct NodChatFeature { case editGoalTapped case forkMenuToggled(messageID: String?) case forkChosen(messageID: String, asSibling: Bool) - case runPlanTapped(planID: String, mode: NodCommand.RunPlan.Mode) + /// `steps` are the plan as the human left it; nil runs it as Nod proposed it. + case runPlanTapped(planID: String, mode: NodCommand.RunPlan.Mode, steps: [NodPlanStep]? = nil) case sendDraftTapped(draftID: String, text: String) case compactNowTapped case signInTapped @@ -123,12 +126,17 @@ struct NodChatFeature { enum NodCommandOutcome: Equatable { case sent(NodCommand) - case failed(String) + case failed(NodCommand, NodControlError) } + /// Commands the runtime refuses until the graph layer behind them ships. A refusal of + /// one of these greys its action out instead of showing an error. + static let gatedCommands: Set = ["fork", "sendDraft", "runPlan"] + private enum CancelID { case events } @Dependency(\.nodClient) var nodClient + @Dependency(\.nodAllowlist) var nodAllowlist var body: some ReducerOf { Reduce { state, action in @@ -265,10 +273,10 @@ struct NodChatFeature { if asSibling { return .send(.delegate(.forkAsSibling(messageID: messageID))) } return command(.fork(.init(messageID: messageID)), state) - case .runPlanTapped(let planID, let mode): + case .runPlanTapped(let planID, let mode, let edited): if mode == .composite { return .send(.delegate(.runPlanAsComposite(planID: planID))) } - guard let plan = state.transcript.plan(id: planID) else { return .none } - return command(.runPlan(.init(planID: planID, steps: plan.steps, mode: .here)), state) + guard let steps = edited ?? state.transcript.plan(id: planID)?.steps else { return .none } + return command(.runPlan(.init(planID: planID, steps: steps, mode: .here)), state) case .sendDraftTapped(let draftID, let text): return command(.sendDraft(.init(draftID: draftID, text: text)), state) @@ -289,10 +297,14 @@ struct NodChatFeature { case .commandFinished(.sent(let command)): state.sendError = nil if case .setModel(let payload) = command { state.chosenModel = payload.model } - return .none + return persistAlwaysAllow(command, state) - case .commandFinished(.failed(let message)): - state.sendError = message + case .commandFinished(.failed(let command, let error)): + if case .rejected = error, Self.gatedCommands.contains(command.type) { + state.unavailableCommands.insert(command.type) + return .none + } + state.sendError = error.localizedDescription return .none case .delegate: @@ -340,12 +352,24 @@ struct NodChatFeature { do { try await nodClient.send(directory, command) await send(.commandFinished(.sent(command))) + } catch let error as NodControlError { + await send(.commandFinished(.failed(command, error))) } catch { - await send(.commandFinished(.failed(error.localizedDescription))) + await send(.commandFinished(.failed(command, .unreachable(error.localizedDescription)))) } } } + /// The runtime keeps "Always" for the session only; the project's shell allowlist is + /// what makes it outlive the run. Other kinds have no allowlist to land in. + private func persistAlwaysAllow(_ command: NodCommand, _ state: State) -> Effect { + guard case .resolvePermission(let resolved) = command, resolved.decision == .alwaysAllow, + let ask = state.transcript.ask(id: resolved.askID), ask.kind == .shell + else { return .none } + let subject = ask.subject + return .run { _ in await nodAllowlist.allowShellCommand(subject) } + } + private func replaceMentionQuery(_ state: inout State, with title: String) { guard case .mention = state.trigger, let at = state.draft.lastIndex(of: "@") else { return } state.draft = String(state.draft[.. NodEvent.PermissionAsked? { + for turn in turns.reversed() { + for item in turn.items.reversed() { + if case .permission(let card) = item, card.ask.askID == id { return card.ask } + } + } + return nil + } + func plan(id: String) -> NodEvent.PlanProposed? { for turn in turns.reversed() { for item in turn.items.reversed() { diff --git a/graphcode/Sources/Features/Nod/NodChatPaneView.swift b/graphcode/Sources/Features/Nod/NodChatPaneView.swift index 7d79bd6d..cd83e2f8 100644 --- a/graphcode/Sources/Features/Nod/NodChatPaneView.swift +++ b/graphcode/Sources/Features/Nod/NodChatPaneView.swift @@ -144,7 +144,9 @@ struct NodChatPaneView: View { @ViewBuilder private func turnView(_ turn: NodTranscript.Turn) -> some View { VStack(alignment: .leading, spacing: 10) { - if let prompt = turn.prompt { + if let prompt = turn.prompt, let inbound = slots.inboundMessage?(prompt, turn.origin) { + inbound + } else if let prompt = turn.prompt { promptBubble(prompt, origin: turn.origin) } else if turn.origin == .timer || turn.origin == .goalCheck { Text(turn.origin == .timer ? "Timed run · turn \(turn.number)" : "Goal check") @@ -254,17 +256,24 @@ struct NodChatPaneView: View { check: check, goalTint: goalTint, onMarkDone: { store.send(.markGoalDoneTapped) }, onEditGoal: { store.send(.editGoalTapped) }) + if let offer = slots.afterGoalCheck?(check) { + offer + } case .plan(let plan): if let slot = slots.plan { slot(plan) } else { - NodPlanCardView(plan: plan) { store.send(.runPlanTapped(planID: plan.planID, mode: $0)) } + NodPlanCardView(plan: plan, canRun: !store.unavailableCommands.contains("runPlan")) { + store.send(.runPlanTapped(planID: plan.planID, mode: $0)) + } } case .mailDraft(let draft): if let slot = slots.mailDraft { slot(draft) } else { - NodMailDraftCardView(draft: draft) { + NodMailDraftCardView( + draft: draft, canSend: !store.unavailableCommands.contains("sendDraft") + ) { store.send(.sendDraftTapped(draftID: draft.draftID, text: draft.text)) } } @@ -282,6 +291,7 @@ struct NodChatPaneView: View { private func assistantText(_ message: NodTranscript.Message) -> some View { NodAssistantMessageView( message: message, isForkMenuOpen: store.forkMenuMessageID == message.id, + canBranch: !store.unavailableCommands.contains("fork"), onFork: { store.send(.forkMenuToggled(messageID: message.id)) }, onBranch: { store.send(.forkChosen(messageID: message.id, asSibling: false)) }, onSibling: { store.send(.forkChosen(messageID: message.id, asSibling: true)) }) @@ -412,6 +422,7 @@ struct NodBannerView: View { struct NodAssistantMessageView: View { let message: NodTranscript.Message let isForkMenuOpen: Bool + let canBranch: Bool let onFork: () -> Void let onBranch: () -> Void let onSibling: () -> Void @@ -439,7 +450,7 @@ struct NodAssistantMessageView: View { .font(.system(size: 11)) .opacity(isHovering || isForkMenuOpen ? 1 : 0) if isForkMenuOpen { - NodForkMenuView(onBranch: onBranch, onSibling: onSibling) + NodForkMenuView(canBranch: canBranch, onBranch: onBranch, onSibling: onSibling) } } } diff --git a/graphcode/Sources/Features/Nod/NodChatPresentation.swift b/graphcode/Sources/Features/Nod/NodChatPresentation.swift index 694c79b4..62af84ea 100644 --- a/graphcode/Sources/Features/Nod/NodChatPresentation.swift +++ b/graphcode/Sources/Features/Nod/NodChatPresentation.swift @@ -176,7 +176,8 @@ enum NodChatPresentation { } static func duration(ms: Int) -> String { - ms < 1000 ? String(format: "%.1fs", Double(ms) / 1000) : "\(Int((Double(ms) / 1000).rounded()))s" + ms < 1000 + ? String(format: "%.1fs", Double(ms) / 1000) : "\(Int((Double(ms) / 1000).rounded()))s" } } diff --git a/graphcode/Sources/Features/Nod/NodClient.swift b/graphcode/Sources/Features/Nod/NodClient.swift index 63a7a83a..8c5a0c14 100644 --- a/graphcode/Sources/Features/Nod/NodClient.swift +++ b/graphcode/Sources/Features/Nod/NodClient.swift @@ -172,3 +172,28 @@ extension DependencyValues { set { self[NodClient.self] = newValue } } } + +/// Where "Always in " outlives the run: `GraphcodeSettings.nod.shellAllowlist`, +/// the list Settings › Agents › Nod edits. +struct NodAllowlistClient: Sendable { + var allowShellCommand: @Sendable (String) async -> Void +} + +extension NodAllowlistClient: DependencyKey { + static let liveValue = NodAllowlistClient { command in + await MainActor.run { + let model = SettingsModel.shared + guard !model.settings.nod.shellAllowlist.contains(command) else { return } + model.settings.nod.shellAllowlist.append(command) + } + } + + static let testValue = NodAllowlistClient { _ in } +} + +extension DependencyValues { + var nodAllowlist: NodAllowlistClient { + get { self[NodAllowlistClient.self] } + set { self[NodAllowlistClient.self] = newValue } + } +} diff --git a/graphcode/Sources/Features/Nod/NodComposerView.swift b/graphcode/Sources/Features/Nod/NodComposerView.swift index f9f78f90..a3cc9597 100644 --- a/graphcode/Sources/Features/Nod/NodComposerView.swift +++ b/graphcode/Sources/Features/Nod/NodComposerView.swift @@ -86,7 +86,8 @@ struct NodComposerView: View { .fixedSize() Menu { - ForEach(NodModelCatalog.models(for: store.transcript.session?.engine), id: \.self) { model in + ForEach(NodModelCatalog.models(for: store.transcript.session?.engine), id: \.self) { + model in Button(NodChatPresentation.modelLabel(model)) { store.send(.modelChosen(model)) } } } label: { @@ -114,7 +115,8 @@ struct NodComposerView: View { Spacer(minLength: 8) Text( - store.transcript.isRunning ? "⏎ queue · ⌘⏎ steer · esc stop" : "⏎ send · @ loops · / commands" + store.transcript.isRunning + ? "⏎ queue · ⌘⏎ steer · esc stop" : "⏎ send · @ loops · / commands" ) .font(.system(size: 11)) .foregroundStyle(Color.white.opacity(0.5)) diff --git a/graphcode/Sources/Features/Nod/NodGraphSlots.swift b/graphcode/Sources/Features/Nod/NodGraphSlots.swift index a664fa81..5cc86107 100644 --- a/graphcode/Sources/Features/Nod/NodGraphSlots.swift +++ b/graphcode/Sources/Features/Nod/NodGraphSlots.swift @@ -18,19 +18,28 @@ struct NodGraphSlots { var mailDraft: ((NodEvent.MailDraft) -> AnyView)? /// Replaces the pane's plain plan card (design section 4). var plan: ((NodEvent.PlanProposed) -> AnyView)? + /// A turn's opening message from another loop — mail, a handoff — drawn in place of the + /// prompt bubble when this returns a view. + var inboundMessage: ((NodEvent.UserMessage, NodTurnOrigin) -> AnyView?)? + /// Drawn under a goal-check card: "Hand off to Release notes…?" once the goal holds. + var afterGoalCheck: ((NodEvent.GoalCheck) -> AnyView?)? init( contextStrip: (() -> AnyView)? = nil, afterTurn: ((Int) -> AnyView?)? = nil, aboveComposer: (() -> AnyView)? = nil, mailDraft: ((NodEvent.MailDraft) -> AnyView)? = nil, - plan: ((NodEvent.PlanProposed) -> AnyView)? = nil + plan: ((NodEvent.PlanProposed) -> AnyView)? = nil, + inboundMessage: ((NodEvent.UserMessage, NodTurnOrigin) -> AnyView?)? = nil, + afterGoalCheck: ((NodEvent.GoalCheck) -> AnyView?)? = nil ) { self.contextStrip = contextStrip self.afterTurn = afterTurn self.aboveComposer = aboveComposer self.mailDraft = mailDraft self.plan = plan + self.inboundMessage = inboundMessage + self.afterGoalCheck = afterGoalCheck } } diff --git a/graphcode/Sources/Features/Nod/NodTranscript.swift b/graphcode/Sources/Features/Nod/NodTranscript.swift index 61e65237..30652af9 100644 --- a/graphcode/Sources/Features/Nod/NodTranscript.swift +++ b/graphcode/Sources/Features/Nod/NodTranscript.swift @@ -132,103 +132,131 @@ struct NodTranscript: Equatable { lastSeq = record.seq lastAt = max(lastAt, record.at) - switch record.event { - case .sessionStarted(let started): - closeRunningTurn(at: record.at) - session = Session( - engine: started.engine, model: started.model, conversationID: started.conversationID) - if failure?.kind == .signInExpired || failure?.kind == .engineError { failure = nil } - - case .turnStarted(let started): - closeRunningTurn(at: record.at) - var turn = Turn(number: started.turn, origin: started.origin, startedAt: record.at) - if started.origin.carriesPrompt, !queued.isEmpty { - turn.prompt = queued.removeFirst() - } - turns.append(turn) - if failure?.kind == .spendCap || failure?.kind == .permissionUnavailable { failure = nil } + if !applyLifecycle(record) && !applyResolution(record.event) { + applyContent(record.event) + } + } - case .userMessage(let message): - receive(message) + /// Records about the run and its turns rather than any one card. + private mutating func applyLifecycle(_ record: NodEventRecord) -> Bool { + switch record.event { + case .sessionStarted(let started): startSession(started, at: record.at) + case .turnStarted(let started): startTurn(started, at: record.at) + case .turnEnded(let ended): endTurn(ended, at: record.at) + case .usage(let usage): add(usage) + case .compacted(let compacted): compact(compacted) + case .activity(let activity): self.activity = activity.line + case .failure(let failure): self.failure = failure + default: return false + } + return true + } - case .assistantText(let text): - appendText(text) + /// Records that settle a card already in the transcript. + private mutating func applyResolution(_ event: NodEvent) -> Bool { + switch event { + case .toolResult(let result): resolveTool(result) + case .hunkResolved(let resolved): resolveHunk(resolved) + case .permissionResolved(let resolved): resolvePermission(resolved) + default: return false + } + return true + } - case .toolCall(let call): - append(.tool(ToolCard(call: call)), toTurn: call.turn) + /// Records that add to the transcript. + private mutating func applyContent(_ event: NodEvent) { + let openTurn = currentTurn?.number ?? turns.last?.number ?? 0 + switch event { + case .userMessage(let message): receive(message) + case .assistantText(let text): appendText(text) + case .toolCall(let call): append(.tool(ToolCard(call: call)), toTurn: call.turn) + case .hunkStaged(let staged): append(.hunk(HunkCard(staged: staged)), toTurn: staged.turn) + case .permissionAsked(let ask): append(.permission(PermissionCard(ask: ask)), toTurn: openTurn) + case .goalCheck(let check): recordGoalCheck(check) + case .planProposed(let plan): append(.plan(plan), toTurn: openTurn) + case .mailDraft(let draft): append(.mailDraft(draft), toTurn: openTurn) + default: break + } + } - case .toolResult(let result): - updateItems { item in - guard case .tool(var card) = item, card.call.callID == result.callID else { return false } - card.result = result - item = .tool(card) - return true - } + private mutating func startSession(_ started: NodEvent.SessionStarted, at date: Date) { + closeRunningTurn(at: date) + session = Session( + engine: started.engine, model: started.model, conversationID: started.conversationID) + clearFailure([.signInExpired, .engineError]) + } - case .hunkStaged(let staged): - append(.hunk(HunkCard(staged: staged)), toTurn: staged.turn) - - case .hunkResolved(let resolved): - updateItems { item in - guard case .hunk(var card) = item, card.staged.hunkID == resolved.hunkID else { - return false - } - card.resolution = resolved - item = .hunk(card) - return true - } + private mutating func startTurn(_ started: NodEvent.TurnStarted, at date: Date) { + closeRunningTurn(at: date) + var turn = Turn(number: started.turn, origin: started.origin, startedAt: date) + if started.origin.carriesPrompt, !queued.isEmpty { + turn.prompt = queued.removeFirst() + } + turns.append(turn) + clearFailure([.spendCap, .permissionUnavailable]) + } - case .permissionAsked(let ask): - let turn = currentTurn?.number ?? turns.last?.number ?? 0 - append(.permission(PermissionCard(ask: ask)), toTurn: turn) - - case .permissionResolved(let resolved): - updateItems { item in - guard case .permission(var card) = item, card.ask.askID == resolved.askID else { - return false - } - card.decision = resolved.decision - item = .permission(card) - return true - } + private mutating func endTurn(_ ended: NodEvent.TurnEnded, at date: Date) { + guard let index = turns.lastIndex(where: { $0.number == ended.turn }) else { return } + turns[index].ended = ended + turns[index].endedAt = date + activity = nil + } - case .goalCheck(let check): - lastGoalCheck = check - goalCheckCount += 1 - append(.goalCheck(check), toTurn: check.turn) + private mutating func add(_ usage: NodEvent.Usage) { + self.usage = usage + totalCostUSD += usage.costUSD ?? 0 + totalPremiumRequests += usage.premiumRequests ?? 0 + } - case .turnEnded(let ended): - guard let index = turns.lastIndex(where: { $0.number == ended.turn }) else { return } - turns[index].ended = ended - turns[index].endedAt = record.at - activity = nil + private mutating func compact(_ compacted: NodEvent.Compacted) { + let range = compacted.fromTurn...compacted.throughTurn + for index in turns.indices where range.contains(turns[index].number) { + turns[index].isCompacted = true + } + clearFailure([.contextFull]) + } - case .usage(let usage): - self.usage = usage - totalCostUSD += usage.costUSD ?? 0 - totalPremiumRequests += usage.premiumRequests ?? 0 + private mutating func recordGoalCheck(_ check: NodEvent.GoalCheck) { + lastGoalCheck = check + goalCheckCount += 1 + append(.goalCheck(check), toTurn: check.turn) + } - case .planProposed(let plan): - append(.plan(plan), toTurn: currentTurn?.number ?? turns.last?.number ?? 0) + /// A failure stays up until what ends it happens: a new run for sign-in, the next turn + /// for a spend cap, a compaction for a full context. + private mutating func clearFailure(_ kinds: Set) { + if let kind = failure?.kind, kinds.contains(kind) { failure = nil } + } - case .mailDraft(let draft): - append(.mailDraft(draft), toTurn: currentTurn?.number ?? turns.last?.number ?? 0) + private mutating func resolveTool(_ result: NodEvent.ToolResult) { + updateItems { item in + guard case .tool(var card) = item, card.call.callID == result.callID else { return false } + card.result = result + item = .tool(card) + return true + } + } - case .compacted(let compacted): - for index in turns.indices - where (compacted.fromTurn...compacted.throughTurn).contains(turns[index].number) { - turns[index].isCompacted = true + private mutating func resolveHunk(_ resolved: NodEvent.HunkResolved) { + updateItems { item in + guard case .hunk(var card) = item, card.staged.hunkID == resolved.hunkID else { + return false } - if failure?.kind == .contextFull { failure = nil } - - case .activity(let activity): - self.activity = activity.line - - case .failure(let failure): - self.failure = failure + card.resolution = resolved + item = .hunk(card) + return true + } + } - case .unknown: - break + private mutating func resolvePermission(_ resolved: NodEvent.PermissionResolved) { + updateItems { item in + guard case .permission(var card) = item, card.ask.askID == resolved.askID else { + return false + } + card.decision = resolved.decision + item = .permission(card) + return true } } diff --git a/graphcode/Sources/Features/Nod/NodWorkCards.swift b/graphcode/Sources/Features/Nod/NodWorkCards.swift index 7d2c4566..721ab905 100644 --- a/graphcode/Sources/Features/Nod/NodWorkCards.swift +++ b/graphcode/Sources/Features/Nod/NodWorkCards.swift @@ -436,6 +436,7 @@ struct NodGoalClauseList: View { /// Design 3e: a branch stays inside the loop; a sibling gets its own worktree and card. struct NodForkMenuView: View { + var canBranch = true let onBranch: () -> Void let onSibling: () -> Void @@ -449,8 +450,14 @@ struct NodForkMenuView: View { .padding(.top, 8) .padding(.bottom, 2) option( - "Branch in this loop", "Try the other approach. Switch with ‹ 1 / 2 › on the message.", - action: onBranch) + "Branch in this loop", + canBranch + ? "Try the other approach. Switch with ‹ 1 / 2 › on the message." + : "Not available in this version of Nod yet.", + action: onBranch + ) + .disabled(!canBranch) + .opacity(canBranch ? 1 : 0.5) option( "New sibling loop", "Its own worktree and its own card on the canvas, so both run at once.", action: onSibling) @@ -484,6 +491,7 @@ struct NodForkMenuView: View { /// `NodGraphSlots.plan`. struct NodPlanCardView: View { let plan: NodEvent.PlanProposed + var canRun = true let onRun: (NodCommand.RunPlan.Mode) -> Void var body: some View { @@ -507,6 +515,9 @@ struct NodPlanCardView: View { Spacer() Button("Run as Composite") { onRun(.composite) }.buttonStyle(NodButtonStyle()) Button("Run here") { onRun(.here) }.buttonStyle(NodButtonStyle(weight: .primary)) + .disabled(!canRun) + .opacity(canRun ? 1 : 0.5) + .help(canRun ? "" : "Not available in this version of Nod yet.") } } .padding(12) @@ -519,6 +530,7 @@ struct NodPlanCardView: View { /// `NodGraphSlots.mailDraft`. struct NodMailDraftCardView: View { let draft: NodEvent.MailDraft + var canSend = true let onSend: () -> Void var body: some View { @@ -529,6 +541,9 @@ struct NodMailDraftCardView: View { HStack { Spacer() Button("Send reply", action: onSend).buttonStyle(NodButtonStyle(weight: .primary)) + .disabled(!canSend) + .opacity(canSend ? 1 : 0.5) + .help(canSend ? "" : "Not available in this version of Nod yet.") } } .padding(12) diff --git a/graphcode/Tests/NodChatFeatureTests.swift b/graphcode/Tests/NodChatFeatureTests.swift index 3bc5e49f..474cefd3 100644 --- a/graphcode/Tests/NodChatFeatureTests.swift +++ b/graphcode/Tests/NodChatFeatureTests.swift @@ -190,6 +190,32 @@ struct NodChatFeatureTests { #expect(await sink.commands.isEmpty) } + @Test + func runHereSendsThePlanAsTheHumanLeftIt() async { + let sink = CommandSink() + var log = NodLog.monetization + log.add( + "planProposed", + [ + "planID": "p1", "title": "Caps", + "steps": [["id": "s1", "text": "Move /export", "files": [], "editedByHuman": false]], + ]) + let store = makeStore(log: log, sink: sink) + let edited = [NodPlanStep(id: "s1", text: "Move /export and log it", editedByHuman: true)] + + await store.send(.runPlanTapped(planID: "p1", mode: .here)) + await store.receive(\.commandFinished) + await store.send(.runPlanTapped(planID: "p1", mode: .here, steps: edited)) + await store.receive(\.commandFinished) + + #expect( + await sink.commands == [ + .runPlan( + .init(planID: "p1", steps: [NodPlanStep(id: "s1", text: "Move /export")], mode: .here)), + .runPlan(.init(planID: "p1", steps: edited, mode: .here)), + ]) + } + @Test func slashCompactIsTheCompactCommand() async { let sink = CommandSink() @@ -242,6 +268,46 @@ struct NodChatFeatureTests { #expect(store.state.model == "opus") } + @Test + func aGatedCommandTheRuntimeRefusesIsDisabledNotAnError() async { + let store = makeStore(log: .monetization) { command in + if command.type == "fork" { throw NodControlError.rejected("fork is not available yet") } + } + await store.send(.forkChosen(messageID: "m1", asSibling: false)) + await store.receive(\.commandFinished) + #expect(store.state.unavailableCommands == ["fork"]) + #expect(store.state.sendError == nil) + } + + @Test + func alwaysAllowingAShellCommandSavesItToTheProjectAllowlist() async { + let saved = LockIsolated<[String]>([]) + var log = NodLog.monetization + log.ask("net", kind: "network", subject: "curl example.com", reason: "network") + var state = NodChatFeature.State( + nodeID: UUID(), stateDirectory: URL(fileURLWithPath: "/tmp/nod-test"), loopTitle: "M", + loopType: .goalBased) + state.transcript = log.transcript + let store = TestStore(initialState: state) { + NodChatFeature() + } withDependencies: { + $0.nodClient.send = { _, _ in } + $0.nodAllowlist.allowShellCommand = { command in saved.withValue { $0.append(command) } } + } + store.exhaustivity = .off + + await store.send(.permissionDecided(askID: "a1", .allowOnce)) + await store.receive(\.commandFinished) + await store.send(.permissionDecided(askID: "net", .alwaysAllow)) + await store.receive(\.commandFinished) + #expect(saved.value.isEmpty) + + await store.send(.permissionDecided(askID: "a1", .alwaysAllow)) + await store.receive(\.commandFinished) + await store.finish() + #expect(saved.value == ["swift package resolve"]) + } + @Test func aRefusedCommandShowsItsError() async { let store = makeStore(log: .monetization) { _ in @@ -266,137 +332,3 @@ struct NodChatFeatureTests { #expect(cards.map(state.isToolExpanded) == [false, true]) } } - -@MainActor -@Suite -struct NodWorkspaceWiringTests { - private final class TypedBox: @unchecked Sendable { - var typed: [(UUID, String)] = [] - } - - private func workspace(backend: CLISessionBackendKind) -> LoopWorkspaceFeature.State { - let node = LoopNode( - title: "Monetization", loopType: .goalBased, - goal: GoalSpec(summary: "every paid route enforces the cap"), backend: backend) - return LoopWorkspaceFeature.State( - node: node, layout: .defaultLayout(forNode: node.id), projectPath: "/tmp/project", - projectName: "project") - } - - @Test - func onlyAChatLoopGetsAChat() async { - let store = TestStore(initialState: workspace(backend: .claudeCode)) { - LoopWorkspaceFeature() - } - await store.send(.chatSurfaceAppeared) - - let nod = TestStore(initialState: workspace(backend: .nod)) { LoopWorkspaceFeature() } - nod.exhaustivity = .off - await nod.send(.chatSurfaceAppeared) - #expect(nod.state.nodChat?.goal == "every paid route enforces the cap") - #expect(nod.state.nodChat?.loopType == .goalBased) - #expect(nod.state.nodChat?.nodeID == nod.state.node.id) - } - - @Test - func openInShellTabTypesIntoAPlainShellTabMakingOneIfNeeded() async { - let box = TypedBox() - let store = TestStore(initialState: workspace(backend: .nod)) { - LoopWorkspaceFeature() - } withDependencies: { - $0.terminalLayoutStore = TerminalLayoutStore( - baseDirectory: FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString)) - $0.terminalSurfaceClient.typeText = { id, text in box.typed.append((id, text)) } - } - store.exhaustivity = .off - await store.send(.chatSurfaceAppeared) - let chatTab = store.state.layout.selectedTabID - - await store.send(.nodChat(.delegate(.openInShellTab(command: "swift test")))) - #expect(store.state.layout.tabs.count == 2) - let shellTab = store.state.layout.tabs[1] - #expect(store.state.layout.selectedTabID == shellTab.id) - #expect(!shellTab.primary.launchesClaudeCode) - #expect(box.typed.map(\.0) == [shellTab.primary.id]) - #expect(box.typed.map(\.1) == ["swift test"]) - - await store.send(.tabSelected(chatTab)) - await store.send(.nodChat(.delegate(.openInShellTab(command: "make lint")))) - #expect(store.state.layout.tabs.count == 2) - #expect(store.state.layout.selectedTabID == shellTab.id) - #expect(box.typed.map(\.1) == ["swift test", "make lint"]) - } -} - -@Suite -struct NodControlSocketTests { - /// A one-shot runtime stand-in: accepts one connection, reads one line, answers `reply`. - private func serve(reply: String) throws -> (path: String, received: () -> String) { - let path = "/tmp/nod-\(UUID().uuidString.prefix(8)).sock" - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: Array(path.utf8)) } - let bound = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - bind(fd, $0, socklen_t(MemoryLayout.size)) - } - } - #expect(bound == 0) - listen(fd, 1) - let lock = NSLock() - nonisolated(unsafe) var received = "" - let done = DispatchSemaphore(value: 0) - Thread.detachNewThread { - let client = accept(fd, nil, nil) - var buffer = [UInt8](repeating: 0, count: 4096) - let count = read(client, &buffer, buffer.count) - lock.lock() - received = String(decoding: buffer[0.. LoopWorkspaceFeature.State { + let node = LoopNode( + title: "Monetization", loopType: .goalBased, + goal: GoalSpec(summary: "every paid route enforces the cap"), backend: backend) + return LoopWorkspaceFeature.State( + node: node, layout: .defaultLayout(forNode: node.id), projectPath: "/tmp/project", + projectName: "project") + } + + @Test + func onlyAChatLoopGetsAChat() async { + let store = TestStore(initialState: workspace(backend: .claudeCode)) { + LoopWorkspaceFeature() + } + await store.send(.chatSurfaceAppeared) + + let nod = TestStore(initialState: workspace(backend: .nod)) { LoopWorkspaceFeature() } + nod.exhaustivity = .off + await nod.send(.chatSurfaceAppeared) + #expect(nod.state.nodChat?.goal == "every paid route enforces the cap") + #expect(nod.state.nodChat?.loopType == .goalBased) + #expect(nod.state.nodChat?.nodeID == nod.state.node.id) + } + + @Test + func openInShellTabTypesIntoAPlainShellTabMakingOneIfNeeded() async { + let box = TypedBox() + let store = TestStore(initialState: workspace(backend: .nod)) { + LoopWorkspaceFeature() + } withDependencies: { + $0.terminalLayoutStore = TerminalLayoutStore( + baseDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString)) + $0.terminalSurfaceClient.typeText = { id, text in box.typed.append((id, text)) } + } + store.exhaustivity = .off + await store.send(.chatSurfaceAppeared) + let chatTab = store.state.layout.selectedTabID + + await store.send(.nodChat(.delegate(.openInShellTab(command: "swift test")))) + #expect(store.state.layout.tabs.count == 2) + let shellTab = store.state.layout.tabs[1] + #expect(store.state.layout.selectedTabID == shellTab.id) + #expect(!shellTab.primary.launchesClaudeCode) + #expect(box.typed.map(\.0) == [shellTab.primary.id]) + #expect(box.typed.map(\.1) == ["swift test"]) + + await store.send(.tabSelected(chatTab)) + await store.send(.nodChat(.delegate(.openInShellTab(command: "make lint")))) + #expect(store.state.layout.tabs.count == 2) + #expect(store.state.layout.selectedTabID == shellTab.id) + #expect(box.typed.map(\.1) == ["swift test", "make lint"]) + } +} + +@Suite +struct NodControlSocketTests { + /// A one-shot runtime stand-in: accepts one connection, reads one line, answers `reply`. + private func serve(reply: String) throws -> (path: String, received: () -> String) { + let path = "/tmp/nod-\(UUID().uuidString.prefix(8)).sock" + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: Array(path.utf8)) } + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + #expect(bound == 0) + listen(fd, 1) + let lock = NSLock() + nonisolated(unsafe) var received = "" + let done = DispatchSemaphore(value: 0) + Thread.detachNewThread { + let client = accept(fd, nil, nil) + var buffer = [UInt8](repeating: 0, count: 4096) + let count = read(client, &buffer, buffer.count) + lock.lock() + received = String(decoding: buffer[0.. Date: Thu, 1 Oct 2026 21:56:48 -0700 Subject: [PATCH 5/7] Trim always-allowed shell commands like the Nod settings editor does Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- graphcode/Sources/Features/Nod/NodClient.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/graphcode/Sources/Features/Nod/NodClient.swift b/graphcode/Sources/Features/Nod/NodClient.swift index 8c5a0c14..6bd2f5d3 100644 --- a/graphcode/Sources/Features/Nod/NodClient.swift +++ b/graphcode/Sources/Features/Nod/NodClient.swift @@ -174,17 +174,20 @@ extension DependencyValues { } /// Where "Always in " outlives the run: `GraphcodeSettings.nod.shellAllowlist`, -/// the list Settings › Agents › Nod edits. +/// the list Settings › Agents › Nod edits. Trims, and skips blanks and duplicates, the way +/// that editor's own `NodSettings.addAllowlistPattern` does. struct NodAllowlistClient: Sendable { var allowShellCommand: @Sendable (String) async -> Void } extension NodAllowlistClient: DependencyKey { static let liveValue = NodAllowlistClient { command in + let pattern = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pattern.isEmpty else { return } await MainActor.run { let model = SettingsModel.shared - guard !model.settings.nod.shellAllowlist.contains(command) else { return } - model.settings.nod.shellAllowlist.append(command) + guard !model.settings.nod.shellAllowlist.contains(pattern) else { return } + model.settings.nod.shellAllowlist.append(pattern) } } From 6254836f3ee639d21a51dd66c281497ab64f4cf3 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 22:08:53 -0700 Subject: [PATCH 6/7] Read Nod's models and allowlist through NodSetup's NodSettings API The model chip lists NodModelCatalog, the chat starts on resolvedModel(for:tier:), and Always in project goes through addAllowlistPattern, so the pane and Settings never disagree. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../LoopWorkspace/LoopWorkspaceFeature.swift | 8 +++- .../Sources/Features/Nod/NodChatFeature.swift | 10 +++-- .../Features/Nod/NodChatPresentation.swift | 6 +++ .../Sources/Features/Nod/NodClient.swift | 39 ++++++++++--------- .../Features/Nod/NodComposerView.swift | 18 ++------- graphcode/Tests/NodChatFeatureTests.swift | 2 +- graphcode/Tests/NodChatWiringTests.swift | 26 +++++++++++++ 7 files changed, 70 insertions(+), 39 deletions(-) diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 4a409b35..c7973db9 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -152,6 +152,7 @@ struct LoopWorkspaceFeature { /// `TerminalSurfaceStore` — so without telling it here, a closed pane's terminal would /// linger until it aged out of the cache. @Dependency(\.terminalSurfaceClient) var terminalSurfaceClient + @Dependency(\.nodSettings) var nodSettings var body: some ReducerOf { Reduce { state, action in @@ -394,9 +395,14 @@ extension LoopWorkspaceFeature { guard state.node.backend.surface == .chat else { return .none } let node = state.node if state.nodChat == nil { - state.nodChat = NodChatFeature.State( + let settings = nodSettings.current() + var chat = NodChatFeature.State( nodeID: node.id, loopTitle: node.title, loopType: node.loopType, branch: node.worktreeBinding?.branch, goal: node.goal?.summary) + chat.defaultEngine = settings.engine + chat.defaultModel = settings.resolvedModel(for: node.loopType, tier: node.modelTier).id + chat.editPolicy = settings.editsInWorktree + state.nodChat = chat } else { state.nodChat?.loopTitle = node.title state.nodChat?.goal = node.goal?.summary diff --git a/graphcode/Sources/Features/Nod/NodChatFeature.swift b/graphcode/Sources/Features/Nod/NodChatFeature.swift index 932d11d2..2b29760b 100644 --- a/graphcode/Sources/Features/Nod/NodChatFeature.swift +++ b/graphcode/Sources/Features/Nod/NodChatFeature.swift @@ -24,6 +24,9 @@ struct NodChatFeature { /// The model picked from the chip since the session started; the log only names the /// model a run started on. var chosenModel: String? + /// What the loop will start on, from Settings, until `sessionStarted` says otherwise. + var defaultModel: String? + var defaultEngine: NodEngine = .claudeAgentSDK var draft = "" var attachments: [NodAttachment] = [] @@ -53,7 +56,8 @@ struct NodChatFeature { self.goal = goal } - var model: String? { chosenModel ?? transcript.session?.model } + var model: String? { chosenModel ?? transcript.session?.model ?? defaultModel } + var engine: NodEngine { transcript.session?.engine ?? defaultEngine } var trigger: NodComposerTrigger? { NodComposerTrigger.detect(in: draft) } @@ -136,7 +140,7 @@ struct NodChatFeature { private enum CancelID { case events } @Dependency(\.nodClient) var nodClient - @Dependency(\.nodAllowlist) var nodAllowlist + @Dependency(\.nodSettings) var nodSettings var body: some ReducerOf { Reduce { state, action in @@ -367,7 +371,7 @@ struct NodChatFeature { let ask = state.transcript.ask(id: resolved.askID), ask.kind == .shell else { return .none } let subject = ask.subject - return .run { _ in await nodAllowlist.allowShellCommand(subject) } + return .run { _ in await nodSettings.addAllowlistPattern(subject) } } private func replaceMentionQuery(_ state: inout State, with title: String) { diff --git a/graphcode/Sources/Features/Nod/NodChatPresentation.swift b/graphcode/Sources/Features/Nod/NodChatPresentation.swift index 62af84ea..86c4db6d 100644 --- a/graphcode/Sources/Features/Nod/NodChatPresentation.swift +++ b/graphcode/Sources/Features/Nod/NodChatPresentation.swift @@ -165,6 +165,12 @@ enum NodChatPresentation { return String(format: "$%.2f", transcript.totalCostUSD) } + /// The catalog's name for a model this engine offers, else the family read off the id. + static func modelLabel(_ model: String?, engine: NodEngine) -> String { + guard let model else { return "Model" } + return NodModelCatalog.model(id: model, engine: engine)?.displayName ?? modelLabel(model) + } + /// `Sonnet`, from `claude-sonnet-4-5` or `sonnet`; anything unrecognised is shown as is. static func modelLabel(_ model: String) -> String { let lower = model.lowercased() diff --git a/graphcode/Sources/Features/Nod/NodClient.swift b/graphcode/Sources/Features/Nod/NodClient.swift index 6bd2f5d3..090c106c 100644 --- a/graphcode/Sources/Features/Nod/NodClient.swift +++ b/graphcode/Sources/Features/Nod/NodClient.swift @@ -173,30 +173,31 @@ extension DependencyValues { } } -/// Where "Always in " outlives the run: `GraphcodeSettings.nod.shellAllowlist`, -/// the list Settings › Agents › Nod edits. Trims, and skips blanks and duplicates, the way -/// that editor's own `NodSettings.addAllowlistPattern` does. -struct NodAllowlistClient: Sendable { - var allowShellCommand: @Sendable (String) async -> Void +/// The slice of Settings › Agents › Nod the chat reads and writes, through NodSetup's own +/// `NodSettings` methods so the pane and the settings editor never disagree. +struct NodSettingsClient: Sendable { + var current: @Sendable () -> NodSettings + /// "Always in ": the runtime keeps it for the session only; the shell allowlist + /// is what makes it outlive the run. + var addAllowlistPattern: @Sendable (String) async -> Void } -extension NodAllowlistClient: DependencyKey { - static let liveValue = NodAllowlistClient { command in - let pattern = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !pattern.isEmpty else { return } - await MainActor.run { - let model = SettingsModel.shared - guard !model.settings.nod.shellAllowlist.contains(pattern) else { return } - model.settings.nod.shellAllowlist.append(pattern) - } - } +extension NodSettingsClient: DependencyKey { + static let liveValue = NodSettingsClient( + current: { GraphcodeSettingsStore.load().nod }, + addAllowlistPattern: { pattern in + await MainActor.run { + _ = SettingsModel.shared.settings.nod.addAllowlistPattern(pattern) + } + }) - static let testValue = NodAllowlistClient { _ in } + static let testValue = NodSettingsClient( + current: { NodSettings() }, addAllowlistPattern: { _ in }) } extension DependencyValues { - var nodAllowlist: NodAllowlistClient { - get { self[NodAllowlistClient.self] } - set { self[NodAllowlistClient.self] = newValue } + var nodSettings: NodSettingsClient { + get { self[NodSettingsClient.self] } + set { self[NodSettingsClient.self] = newValue } } } diff --git a/graphcode/Sources/Features/Nod/NodComposerView.swift b/graphcode/Sources/Features/Nod/NodComposerView.swift index a3cc9597..5e50132f 100644 --- a/graphcode/Sources/Features/Nod/NodComposerView.swift +++ b/graphcode/Sources/Features/Nod/NodComposerView.swift @@ -86,12 +86,11 @@ struct NodComposerView: View { .fixedSize() Menu { - ForEach(NodModelCatalog.models(for: store.transcript.session?.engine), id: \.self) { - model in - Button(NodChatPresentation.modelLabel(model)) { store.send(.modelChosen(model)) } + ForEach(NodModelCatalog.models(for: store.engine)) { model in + Button(model.displayName) { store.send(.modelChosen(model.id)) } } } label: { - NodChip { Text("\(NodChatPresentation.modelLabel(store.model ?? "Model")) ▾") } + NodChip { Text("\(NodChatPresentation.modelLabel(store.model, engine: store.engine)) ▾") } } .menuStyle(.button) .buttonStyle(.plain) @@ -200,17 +199,6 @@ struct NodComposerView: View { } } -/// What the model chip offers. The runtime is the authority — `setModel` fails for a model -/// the engine does not have — so this is the menu, not a gate. -enum NodModelCatalog { - static func models(for engine: NodEngine?) -> [String] { - switch engine { - case .copilotSDK: return ["gpt-5", "claude-sonnet-4.5", "gemini-2.5-pro"] - case .claudeAgentSDK, nil: return ["opus", "sonnet", "haiku"] - } - } -} - struct NodSlashMenuView: View { let commands: [NodSlashCommand] let onChoose: (NodSlashCommand) -> Void diff --git a/graphcode/Tests/NodChatFeatureTests.swift b/graphcode/Tests/NodChatFeatureTests.swift index 474cefd3..04c8c66c 100644 --- a/graphcode/Tests/NodChatFeatureTests.swift +++ b/graphcode/Tests/NodChatFeatureTests.swift @@ -292,7 +292,7 @@ struct NodChatFeatureTests { NodChatFeature() } withDependencies: { $0.nodClient.send = { _, _ in } - $0.nodAllowlist.allowShellCommand = { command in saved.withValue { $0.append(command) } } + $0.nodSettings.addAllowlistPattern = { command in saved.withValue { $0.append(command) } } } store.exhaustivity = .off diff --git a/graphcode/Tests/NodChatWiringTests.swift b/graphcode/Tests/NodChatWiringTests.swift index f70aa4f0..4284e0c1 100644 --- a/graphcode/Tests/NodChatWiringTests.swift +++ b/graphcode/Tests/NodChatWiringTests.swift @@ -36,6 +36,32 @@ struct NodWorkspaceWiringTests { #expect(nod.state.nodChat?.nodeID == nod.state.node.id) } + @Test + func theChatStartsOnTheModelSettingsWouldLaunch() async { + var settings = NodSettings(engine: .copilotSDK) + settings.editsInWorktree = .auto + var pinned = workspace(backend: .nod) + pinned.node.modelTier = .fast + for (state, expected) in [ + (workspace(backend: .nod), "claude-opus-5.5"), (pinned, "gpt-5.6-luna"), + ] { + let store = TestStore(initialState: state) { + LoopWorkspaceFeature() + } withDependencies: { + $0.nodSettings.current = { settings } + } + store.exhaustivity = .off + await store.send(.chatSurfaceAppeared) + #expect(store.state.nodChat?.model == expected) + #expect(store.state.nodChat?.engine == .copilotSDK) + #expect(store.state.nodChat?.editPolicy == .auto) + } + #expect(NodChatPresentation.modelLabel("gpt-6-sol", engine: .copilotSDK) == "GPT-6 Sol") + #expect( + NodChatPresentation.modelLabel("claude-sonnet-4-5", engine: .claudeAgentSDK) == "Sonnet") + #expect(NodChatPresentation.modelLabel(nil, engine: .claudeAgentSDK) == "Model") + } + @Test func openInShellTabTypesIntoAPlainShellTabMakingOneIfNeeded() async { let box = TypedBox() From c909504b4ec66184eb2f7f5e9eb7655b719508f0 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 22:10:24 -0700 Subject: [PATCH 7/7] Pin Nod's state directory to the node id as written, uppercase Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- graphcode/Tests/NodTranscriptTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/graphcode/Tests/NodTranscriptTests.swift b/graphcode/Tests/NodTranscriptTests.swift index 4e7eda87..90c1a066 100644 --- a/graphcode/Tests/NodTranscriptTests.swift +++ b/graphcode/Tests/NodTranscriptTests.swift @@ -275,6 +275,15 @@ extension [NodTranscript.Item] { @Suite struct NodEventTailTests { + /// The runtime's launcher names the directory with `uuidString` as is; a lowercased copy + /// would split the pane from the runtime on a case-sensitive volume. + @Test + func theStateDirectoryIsTheUppercaseNodeID() throws { + let id = try #require(UUID(uuidString: "9b3408f9-9b16-447f-a439-fc2aa8c02d06")) + let url = NodStateDirectory.url(forNode: id, supportDirectory: URL(fileURLWithPath: "/s")) + #expect(url.path == "/s/nod/9B3408F9-9B16-447F-A439-FC2AA8C02D06") + } + @Test func aTornLineWaitsForTheRestOfIt() throws { let lines = NodLog.monetization.jsonLines