Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions Packages/Sources/RxCodeChatKit/ChatBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ public final class ChatBridge {
public var claudeVersion: String?
public var codexVersion: String?

/// User decision summaries for `ExitPlanMode` tool calls in the current session,
/// keyed by `toolCallId`. Pushed by AppState from per-session state and persisted
/// SwiftData rows. Read by `PlanCardView` and `pendingPlans` so the chip surfaces
/// the user's decision even after the CLI's own follow-up tool_result has replaced
/// `ToolCall.result` (which happens on every reload of a CLI-backed session).
public var planDecisionSummaries: [String: String] = [:]

// MARK: - Action Handlers (set up by the app target)

public var sendHandler: (() async -> Void)?
Expand Down Expand Up @@ -120,15 +127,14 @@ public final class ChatBridge {
guard let toolCall = message.blocks[blockIdx].toolCall,
PlanCardView.isExitPlanMode(toolCall) else { continue }

if planDecisionSummaries[toolCall.id] != nil { return false }
if PlanCardView.isPlanDecided(toolCall) { return false }

// If anything came after this ExitPlanMode — later blocks in the
// same message, or any later message — the chat continued past
// it, so the user must have already acted on the plan. This
// covers CLI-backed sessions reloaded from disk: the CLI's
// recorded tool_result for ExitPlanMode does not match the UI's
// in-memory decision summary, so the result string alone is not
// a reliable signal once the session has been persisted.
// it, so the user must have already acted on the plan. Covers
// edge cases where neither the persisted decision nor the
// in-memory result has caught up yet (e.g. mid-reconcile).
let hasLaterBlock = blockIdx < message.blocks.count - 1
let hasLaterMessage = messageIdx < messages.count - 1
if hasLaterBlock || hasLaterMessage { return false }
Expand All @@ -138,4 +144,44 @@ public final class ChatBridge {
}
return false
}

/// All `ExitPlanMode` tool calls in the current session, mapped to a
/// presentation-ready `PendingPlan`. Includes both undecided plans (banner
/// surfaces these) and decided plans (inline chip renders the outcome).
/// Superseded plans are excluded so a re-emitted plan replaces the prior one.
public var pendingPlans: [PendingPlan] {
var collected: [PendingPlan] = []
for message in messages {
for block in message.blocks {
guard let toolCall = block.toolCall,
PlanCardView.isExitPlanMode(toolCall) else { continue }
if PlanCardView.isSupersededExitPlanMode(
toolCall: toolCall,
in: message,
allMessages: messages
) { continue }

let inlineMd = toolCall.input["plan"]?.stringValue ?? ""
let markdown: String = inlineMd.isEmpty
? (PlanCardView.fallbackPlanMarkdown(in: message)
?? PlanCardView.latestPriorPlanMarkdown(before: message, in: messages)
?? "")
: inlineMd

let persistedSummary = planDecisionSummaries[toolCall.id]
let decided = persistedSummary != nil || PlanCardView.isPlanDecided(toolCall)
let summary: String? = persistedSummary
?? (PlanCardView.isPlanDecided(toolCall) ? toolCall.result : nil)
let isStreaming = message.isStreaming && markdown.isEmpty
collected.append(PendingPlan(
toolCallId: toolCall.id,
markdown: markdown,
isStreaming: isStreaming,
isDecided: decided,
decisionSummary: summary
))
}
}
return collected
}
}
41 changes: 38 additions & 3 deletions Packages/Sources/RxCodeChatKit/InputBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import UniformTypeIdentifiers
import RxCodeCore

struct InputBarView<Accessory: View, TopAccessory: View>: View {
@Environment(ChatBridge.self) private var chatBridge
@Environment(WindowState.self) private var windowState
@Environment(ChatBridge.self) private var environmentChatBridge: ChatBridge?
@Environment(WindowState.self) private var environmentWindowState: WindowState?
@State private var isInputFocused: Bool = false
@State private var inputFocusTrigger: UUID? = nil

private let accessory: Accessory
private let topAccessory: TopAccessory
private let injectedChatBridge: ChatBridge?
private let injectedWindowState: WindowState?

@State private var showFilePicker = false
@State private var showSlashPopup = false
Expand All @@ -28,6 +30,28 @@ struct InputBarView<Accessory: View, TopAccessory: View>: View {
init(accessory: Accessory, @ViewBuilder topAccessory: () -> TopAccessory) {
self.accessory = accessory
self.topAccessory = topAccessory()
self.injectedChatBridge = nil
self.injectedWindowState = nil
}

init(
windowState: WindowState,
chatBridge: ChatBridge,
accessory: Accessory,
@ViewBuilder topAccessory: () -> TopAccessory
) {
self.accessory = accessory
self.topAccessory = topAccessory()
self.injectedChatBridge = chatBridge
self.injectedWindowState = windowState
}

private var chatBridge: ChatBridge {
injectedChatBridge ?? environmentChatBridge!
}

private var windowState: WindowState {
injectedWindowState ?? environmentWindowState!
}

var body: some View {
Expand Down Expand Up @@ -805,9 +829,20 @@ struct InputBarView<Accessory: View, TopAccessory: View>: View {

private func processNextQueued() {
guard let next = chatBridge.dequeueNextForFlush() else { return }
let draftText = windowState.inputText
let draftAttachments = windowState.attachments
let shouldRestoreDraft = !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !draftAttachments.isEmpty

windowState.inputText = next.text
windowState.attachments = next.attachments
Task { await chatBridge.send() }
Task {
await chatBridge.send()
if shouldRestoreDraft {
windowState.inputText = draftText
windowState.attachments = draftAttachments
}
}
}

private func handleReturnKey() {
Expand Down
40 changes: 38 additions & 2 deletions Packages/Sources/RxCodeChatKit/MessageBubble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ struct MessageBubble: View {
} else {
// Assistant message: render blocks in order
let renderBlocks = assistantRenderBlocks()
// While the model is paused on an undecided ExitPlanMode in this
// same message, sibling tools without results are effectively
// suspended — not running. Drop the streaming flag for those so
// their spinner doesn't keep ticking until the user approves.
let siblingsArePaused = messageHasPendingExitPlanMode
let siblingStreaming = message.isStreaming && !siblingsArePaused

ForEach(renderBlocks) { block in
Group {
Expand All @@ -90,7 +96,7 @@ struct MessageBubble: View {
externalPlanMarkdown: external
)
} else {
ToolResultView(toolCall: toolCall, isMessageStreaming: message.isStreaming)
ToolResultView(toolCall: toolCall, isMessageStreaming: siblingStreaming)
}
case .transientTools(let id, let tools):
transientToolSummary(groupId: id, tools: tools)
Expand Down Expand Up @@ -353,6 +359,24 @@ struct MessageBubble: View {
return .asymmetric(insertion: insertion, removal: .identity)
}

// MARK: - Plan Pause Helpers

/// True when this assistant message contains an `ExitPlanMode` tool call that
/// the user has not yet decided on. While true, the CLI is suspended on the
/// PreToolUse hook — sibling tool calls in the same message will never get
/// their `tool_result` delivered until the plan is approved/rejected, so the
/// "running" spinner on those siblings would otherwise tick forever.
private var messageHasPendingExitPlanMode: Bool {
for block in message.blocks {
guard let toolCall = block.toolCall,
PlanCardView.isExitPlanMode(toolCall) else { continue }
if chatBridge.planDecisionSummaries[toolCall.id] != nil { continue }
if PlanCardView.isPlanDecided(toolCall) { continue }
return true
}
return false
}

// MARK: - Transient Tool Helpers

/// Read, Grep, Glob, Bash etc. are collapsed into a summary after streaming completes
Expand Down Expand Up @@ -393,7 +417,19 @@ struct MessageBubble: View {
pendingTransientGroupStartId = nil
}

for block in message.blocks {
// When the model emits an ExitPlanMode tool call together with a trailing
// narration text ("Plan written with 'hi'. Awaiting approval."), the API
// stream orders them tool-then-text. Lift sibling text blocks ahead of the
// plan chip so the user sees the description with the plan rather than
// dangling after a chip that's already been decided.
let orderedBlocks: [MessageBlock] = {
guard PlanCardView.containsExitPlanMode(message) else { return message.blocks }
let textBlocks = message.blocks.filter { $0.isText }
let nonTextBlocks = message.blocks.filter { !$0.isText }
return textBlocks + nonTextBlocks
}()

for block in orderedBlocks {
if PlanCardView.shouldHideBlock(block, in: message, allMessages: chatBridge.messages) { continue }
if block.isText {
flushTransientTools()
Expand Down
5 changes: 4 additions & 1 deletion Packages/Sources/RxCodeChatKit/MessageListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ struct MessageListView: View {
}
}

if chatBridge.isStreaming {
if chatBridge.isStreaming && !chatBridge.hasPendingPlanDecision {
// Hide the spinner/dots while the CLI is paused waiting on the
// user's plan decision — the model isn't actually generating
// tokens, so showing "in progress" is misleading.
HStack(alignment: .top, spacing: 0) {
StreamingIndicatorView(
isThinking: chatBridge.isThinking,
Expand Down
Loading