diff --git a/Packages/Sources/RxCodeChatKit/BubbleStyle.swift b/Packages/Sources/RxCodeChatKit/BubbleStyle.swift index 3289fa47..38187a7c 100644 --- a/Packages/Sources/RxCodeChatKit/BubbleStyle.swift +++ b/Packages/Sources/RxCodeChatKit/BubbleStyle.swift @@ -33,6 +33,8 @@ struct BubbleStyle: ViewModifier { private var padding: EdgeInsets { switch variant { + case .user: + return EdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20) case .tool, .toolError: return Self.toolPadding default: diff --git a/Packages/Sources/RxCodeChatKit/ChatBridge.swift b/Packages/Sources/RxCodeChatKit/ChatBridge.swift index 7bad2239..1c0e70df 100644 --- a/Packages/Sources/RxCodeChatKit/ChatBridge.swift +++ b/Packages/Sources/RxCodeChatKit/ChatBridge.swift @@ -23,11 +23,13 @@ public final class ChatBridge { /// updates as the CLI emits cumulative usage. Surfaced beside the streaming indicator. public var liveOutputTokens: Int = 0 public var lastTurnContextUsedPercentage: Double? + public var agentProvider: AgentProvider = .claudeCode public var modelDisplayName: String = "" public var sessionStats: ChatSessionStats = ChatSessionStats() public var autoPreviewSettings: AttachmentAutoPreviewSettings = AttachmentAutoPreviewSettings() public var appVersion: String = "" public var claudeVersion: String? + public var codexVersion: String? // MARK: - Action Handlers (set up by the app target) @@ -36,7 +38,8 @@ public final class ChatBridge { public var sendSlashCommandHandler: ((String) async -> Void)? public var runTerminalCommandHandler: ((String) async -> Void)? public var editAndResendHandler: ((UUID, String) async -> Void)? - public var fetchRateLimitHandler: (() async -> RateLimitUsage?)? + public var fetchRateLimitHandler: ((AgentProvider) async -> RateLimitUsage?)? + public var setSessionProviderHandler: ((AgentProvider) -> Void)? public var togglePlanModeHandler: (() async -> Void)? public var enqueueMessageHandler: ((String, [Attachment]) -> Void)? public var removeQueuedMessageHandler: ((UUID) -> Void)? @@ -71,7 +74,11 @@ public final class ChatBridge { } public func fetchRateLimit() async -> RateLimitUsage? { - await fetchRateLimitHandler?() + await fetchRateLimitHandler?(agentProvider) + } + + public func setSessionProvider(_ provider: AgentProvider) { + setSessionProviderHandler?(provider) } public func togglePlanMode() async { diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index d6738f3f..3618cdae 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import RxCodeCore // MARK: - Render Group Cache @@ -35,11 +36,15 @@ private final class RenderGroupCache: @unchecked Sendable { /// Renders markdown text with styled code blocks, headers, lists, and rich text. struct MarkdownContentView: View { let text: String + let showsTrailingCursor: Bool + let isCursorVisible: Bool @State private var cachedGroups: [RenderGroup] @State private var cachedText: String - init(text: String) { + init(text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true) { self.text = text + self.showsTrailingCursor = showsTrailingCursor + self.isCursorVisible = isCursorVisible let groups: [RenderGroup] if let cached = RenderGroupCache.shared.get(text) { groups = cached @@ -53,12 +58,14 @@ struct MarkdownContentView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - ForEach(Array(cachedGroups.enumerated()), id: \.offset) { _, group in + ForEach(Array(cachedGroups.enumerated()), id: \.offset) { index, group in switch group { case .attributedText(let attrStr): - Text(attrStr) - .textSelection(.enabled) - .lineSpacing(6) + MarkdownAttributedTextView( + content: attrStr, + showsTrailingCursor: showsTrailingCursor && index == cachedGroups.count - 1, + isCursorVisible: isCursorVisible + ) .frame(maxWidth: .infinity, alignment: .leading) case .blockquote(let attrStr): BlockquoteView(content: attrStr) @@ -533,6 +540,191 @@ private func parseInlineMarkdown(_ content: String) -> AttributedString { return result } +// MARK: - Markdown Attributed Text + +nonisolated(unsafe) private let inlineCodeBackgroundAttribute = NSAttributedString.Key("rxcode.inlineCodeBackground") + +private struct MarkdownAttributedTextView: NSViewRepresentable { + let content: AttributedString + var showsTrailingCursor: Bool = false + var isCursorVisible: Bool = true + + func makeNSView(context: Context) -> InlineCodeTextView { + let textView = InlineCodeTextView() + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + return textView + } + + func updateNSView(_ textView: InlineCodeTextView, context: Context) { + let attributed = Self.nsAttributedString( + from: content, + showsTrailingCursor: showsTrailingCursor, + isCursorVisible: isCursorVisible + ) + textView.linkTextAttributes = [ + .foregroundColor: NSColor(ClaudeTheme.accent), + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + textView.textStorage?.setAttributedString(attributed) + textView.invalidateIntrinsicContentSize() + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: InlineCodeTextView, context: Context) -> CGSize? { + let width = proposal.width ?? 500 + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + return CGSize(width: width, height: 0) + } + textContainer.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + return CGSize(width: width, height: ceil(usedRect.height)) + } + + private static func nsAttributedString( + from content: AttributedString, + showsTrailingCursor: Bool, + isCursorVisible: Bool + ) -> NSAttributedString { + let result = NSMutableAttributedString(content) + let fullRange = NSRange(location: 0, length: result.length) + guard fullRange.length > 0 else { + if showsTrailingCursor { + appendTrailingCursor(to: result, isVisible: isCursorVisible) + } + return result + } + + let defaultTextColor = NSColor(ClaudeTheme.textPrimary) + let defaultFont = NSFont.systemFont(ofSize: ClaudeTheme.messageSize(15)) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = 6 + result.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) + result.enumerateAttribute(.font, in: fullRange) { value, range, _ in + guard value == nil else { return } + result.addAttribute(.font, value: defaultFont, range: range) + } + result.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in + guard value == nil else { return } + result.addAttribute(.foregroundColor, value: defaultTextColor, range: range) + } + + var location = 0 + for run in content.runs { + let runText = String(content[run.range].characters) + let length = (runText as NSString).length + defer { location += length } + guard length > 0, + let intent = run.inlinePresentationIntent, + intent.contains(.code) else { continue } + + let range = NSRange(location: location, length: length) + result.addAttributes([ + .font: NSFont.monospacedSystemFont(ofSize: ClaudeTheme.messageSize(14), weight: .regular), + .foregroundColor: defaultTextColor, + inlineCodeBackgroundAttribute: true + ], range: range) + result.removeAttribute(.backgroundColor, range: range) + } + if showsTrailingCursor { + appendTrailingCursor(to: result, isVisible: isCursorVisible) + } + return result + } + + private static func appendTrailingCursor(to result: NSMutableAttributedString, isVisible: Bool) { + result.append(NSAttributedString(string: " ●", attributes: [ + .font: NSFont.systemFont(ofSize: ClaudeTheme.messageSize(8), weight: .regular), + .foregroundColor: isVisible ? NSColor(ClaudeTheme.accent) : NSColor.clear + ])) + } +} + +private final class InlineCodeLayoutManager: NSLayoutManager, @unchecked Sendable { + nonisolated override init() { + super.init() + } + + nonisolated required init?(coder: NSCoder) { + super.init(coder: coder) + } + + nonisolated override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) { + guard let storage = textStorage, + let container = textContainers.first else { + super.drawBackground(forGlyphRange: glyphsToShow, at: origin) + return + } + + let fullRange = NSRange(location: 0, length: storage.length) + storage.enumerateAttribute(inlineCodeBackgroundAttribute, in: fullRange) { value, charRange, _ in + guard value != nil else { return } + let glyphRange = self.glyphRange(forCharacterRange: charRange, actualCharacterRange: nil) + let visibleRange = NSIntersectionRange(glyphRange, glyphsToShow) + guard visibleRange.length > 0 else { return } + + self.enumerateEnclosingRects( + forGlyphRange: glyphRange, + withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), + in: container + ) { rect, _ in + var roundedRect = rect.offsetBy(dx: origin.x, dy: origin.y) + roundedRect = roundedRect.insetBy(dx: -5, dy: -1.5) + let path = NSBezierPath(roundedRect: roundedRect, xRadius: 6, yRadius: 6) + MainActor.assumeIsolated { + NSColor(ClaudeTheme.codeBackground).setFill() + } + path.fill() + } + } + + super.drawBackground(forGlyphRange: glyphsToShow, at: origin) + } +} + +private final class InlineCodeTextView: NSTextView { + init() { + let storage = NSTextStorage() + let layoutManager = InlineCodeLayoutManager() + let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.widthTracksTextView = true + container.heightTracksTextView = false + storage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(container) + super.init(frame: .zero, textContainer: container) + } + + required init?(coder: NSCoder) { + nil + } + + override var intrinsicContentSize: NSSize { + guard let layoutManager, let textContainer else { + return NSSize(width: NSView.noIntrinsicMetric, height: 0) + } + layoutManager.ensureLayout(for: textContainer) + let usedRect = layoutManager.usedRect(for: textContainer) + return NSSize(width: NSView.noIntrinsicMetric, height: ceil(usedRect.height)) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + textContainer?.containerSize = NSSize(width: newSize.width, height: CGFloat.greatestFiniteMagnitude) + invalidateIntrinsicContentSize() + } +} + /// Removes incorrectly included characters (such as backticks) from URLs inside markdown links `[text](url)` func sanitizeMarkdownLinkURLs(_ text: String) -> String { let pattern = #"\[([^\]]*)\]\(([^)]*`[^)]*)\)"# @@ -586,9 +778,7 @@ private struct BlockquoteView: View { let content: AttributedString var body: some View { - Text(content) - .textSelection(.enabled) - .lineSpacing(6) + MarkdownAttributedTextView(content: content) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 13) .overlay(alignment: .leading) { diff --git a/Packages/Sources/RxCodeChatKit/MessageBubble.swift b/Packages/Sources/RxCodeChatKit/MessageBubble.swift index fd835bf4..c13fb758 100644 --- a/Packages/Sources/RxCodeChatKit/MessageBubble.swift +++ b/Packages/Sources/RxCodeChatKit/MessageBubble.swift @@ -18,6 +18,23 @@ struct MessageBubble: View { /// Threshold (character count) for collapsing long text private static let longTextThreshold = 500 + private enum AssistantRenderBlock: Identifiable { + case text(MessageBlock) + case tool(ToolCall) + case transientTools(id: String, tools: [ToolCall]) + + var id: String { + switch self { + case .text(let block): + return block.id + case .tool(let toolCall): + return toolCall.id + case .transientTools(let id, _): + return id + } + } + } + var body: some View { HStack(alignment: .top, spacing: 0) { if message.role == .user { @@ -50,44 +67,16 @@ struct MessageBubble: View { errorBubble } else { // Assistant message: render blocks in order - let hidden = message.isStreaming ? [] : message.blocks.compactMap(\.toolCall).filter { isTransientTool($0) && $0.hasNonEmptyResult } - // Filter to only renderable blocks — exclude hidden transient tool blocks from ForEach - // to prevent zero-height TupleViews from introducing VStack spacing. - // Adjacent text blocks made contiguous by hidden tools are merged into a single bubble - // (so continuous text Claude sent across turns due to tool_use appears as one bubble) - let visibleBlocks = Self.mergeAdjacentTextBlocks( - in: message.blocks.filter { block in - if PlanCardView.shouldHideBlock(block, in: message, allMessages: chatBridge.messages) { return false } - if let text = block.text { return !text.isEmpty } - if let toolCall = block.toolCall { - if PlanCardView.isSupersededExitPlanMode( - toolCall: toolCall, - in: message, - allMessages: chatBridge.messages - ) { return false } - if message.isStreaming { return true } - if isTransientTool(toolCall) { return false } - // Agent/Edit/Write tools are always shown even without a result - // Agent/Edit/Write/AskUserQuestion are always shown even without a result - if toolCall.isKeepAlways { return true } - // Other non-transient tools: only show when there is a result or error (prevents empty tool bubbles) - return toolCall.result != nil || toolCall.isError - } - return false - } - ) + let renderBlocks = assistantRenderBlocks() - // Hidden tool summary — shown before text (reflects tool execution → text response order) - if !hidden.isEmpty { - transientToolSummary(hidden: hidden) - } - - ForEach(visibleBlocks) { block in + ForEach(renderBlocks) { block in Group { - if let text = block.text, !text.isEmpty { - assistantTextBubble(text: text, blockId: block.id, hasHiddenTools: !hidden.isEmpty) - } - if let toolCall = block.toolCall { + switch block { + case .text(let textBlock): + if let text = textBlock.text, !text.isEmpty { + assistantTextBubble(text: text, blockId: textBlock.id) + } + case .tool(let toolCall): if toolCall.name == "AskUserQuestion" { AskUserQuestionView(toolCall: toolCall) } else if let planMd = PlanCardView.renderMarkdown(for: toolCall, in: message) { @@ -103,11 +92,13 @@ struct MessageBubble: View { } else { ToolResultView(toolCall: toolCall, isMessageStreaming: message.isStreaming) } + case .transientTools(let id, let tools): + transientToolSummary(groupId: id, tools: tools) } } .transition(blockFadeTransition) } - .animation(.easeOut(duration: 0.28), value: visibleBlocks.map(\.id)) + .animation(.easeOut(duration: 0.28), value: renderBlocks.map(\.id)) } // Response complete indicator + elapsed time @@ -260,9 +251,9 @@ struct MessageBubble: View { .buttonStyle(.plain) } } - .frame(maxWidth: 500, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) .bubbleStyle(.user) - .fixedSize(horizontal: true, vertical: false) + .frame(maxWidth: 500, alignment: .trailing) .overlay(alignment: .bottomTrailing) { if isHoveringUserBubble { HStack(spacing: 3) { @@ -287,24 +278,27 @@ struct MessageBubble: View { // MARK: - Assistant Text Bubble - private func assistantTextBubble(text: String, blockId: String, hasHiddenTools: Bool = false) -> some View { + private func assistantTextBubble(text: String, blockId: String) -> some View { let isLastBlock = message.blocks.last?.isText == true && message.blocks.last?.text == text + let showsCursor = message.isStreaming && isLastBlock - return HStack(alignment: .bottom, spacing: 0) { - MarkdownContentView(text: text) - .frame(maxWidth: .infinity, alignment: .leading) - if message.isStreaming && isLastBlock { - Text("|") - .font(.system(size: ClaudeTheme.messageSize(15), weight: .light)) - .foregroundStyle(ClaudeTheme.accent) - .opacity(cursorVisible ? 1 : 0) - .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: cursorVisible) - .onAppear { cursorVisible = false } - } - } + return MarkdownContentView( + text: text, + showsTrailingCursor: showsCursor, + isCursorVisible: cursorVisible + ) + .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(ClaudeTheme.textPrimary) .padding(.vertical, 2) + .task(id: showsCursor) { + guard showsCursor else { return } + cursorVisible = true + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(500)) + cursorVisible.toggle() + } + } .overlay(alignment: .bottomTrailing) { if hoveredBlockId == blockId && !message.isStreaming { copyButton(for: text) @@ -312,13 +306,6 @@ struct MessageBubble: View { } } .onHover { hoveredBlockId = $0 ? blockId : nil } - .onTapGesture { - if hasHiddenTools { - withAnimation(.easeInOut(duration: 0.2)) { - showTransientTools.toggle() - } - } - } .accessibilityLabel("Assistant: \(text)") } @@ -374,48 +361,99 @@ struct MessageBubble: View { return cat == .readOnly || cat == .execution } - /// Merges adjacent text blocks made contiguous by hidden transient tools. - /// Displays continuous text Claude split across turns due to tool_use as a single bubble. - /// - /// Join rule: respects original trailing/leading whitespace; adds a single space only when - /// neither side has whitespace. Forced paragraph breaks would split bullets mid-list, - /// so they are avoided — even text following a complete sentence joins naturally with a single space. - private static func mergeAdjacentTextBlocks(in blocks: [MessageBlock]) -> [MessageBlock] { - var result: [MessageBlock] = [] - for block in blocks { - if block.isText, - let lastIdx = result.indices.last, - result[lastIdx].isText { - let prev = result[lastIdx].text ?? "" - let curr = block.text ?? "" - let needsSpace = !(prev.last?.isWhitespace ?? true) && !(curr.first?.isWhitespace ?? true) - let joined = needsSpace ? prev + " " + curr : prev + curr - // Preserve original block id to ensure ForEach diff stability - result[lastIdx] = .text(joined, id: result[lastIdx].id) + /// Builds the assistant render list in source order. Completed transient + /// tools are folded into contiguous collapsed groups, while edit/write/plan + /// cards remain inline between those groups. + private func assistantRenderBlocks() -> [AssistantRenderBlock] { + var result: [AssistantRenderBlock] = [] + var pendingTransientTools: [ToolCall] = [] + var pendingTransientGroupStartId: String? + + func appendText(_ block: MessageBlock) { + guard let text = block.text, !text.isEmpty else { return } + if let lastIndex = result.indices.last, + case .text(let previousBlock) = result[lastIndex] { + let previous = previousBlock.text ?? "" + let needsSpace = !(previous.last?.isWhitespace ?? true) && !(text.first?.isWhitespace ?? true) + let joined = needsSpace ? previous + " " + text : previous + text + result[lastIndex] = .text(.text(joined, id: previousBlock.id)) } else { - result.append(block) + result.append(.text(block)) + } + } + + func flushTransientTools() { + guard !pendingTransientTools.isEmpty else { return } + let startId = pendingTransientGroupStartId ?? pendingTransientTools[0].id + result.append(.transientTools( + id: "transient-tools-\(startId)-\(pendingTransientTools.count)", + tools: pendingTransientTools + )) + pendingTransientTools = [] + pendingTransientGroupStartId = nil + } + + for block in message.blocks { + if PlanCardView.shouldHideBlock(block, in: message, allMessages: chatBridge.messages) { continue } + if block.isText { + flushTransientTools() + appendText(block) + continue + } + guard let toolCall = block.toolCall else { continue } + if PlanCardView.isSupersededExitPlanMode( + toolCall: toolCall, + in: message, + allMessages: chatBridge.messages + ) { continue } + + if message.isStreaming { + flushTransientTools() + result.append(.tool(toolCall)) + continue + } + + if isTransientTool(toolCall) { + guard toolCall.result != nil || toolCall.isError else { continue } + if pendingTransientGroupStartId == nil { + pendingTransientGroupStartId = toolCall.id + } + pendingTransientTools.append(toolCall) + continue + } + + if toolCall.isKeepAlways || toolCall.result != nil || toolCall.isError { + flushTransientTools() + result.append(.tool(toolCall)) } } + + flushTransientTools() return result } - @State private var showTransientTools = false + @State private var expandedTransientGroupIds: Set = [] - private func transientToolSummary(hidden: [ToolCall]) -> some View { + private func transientToolSummary(groupId: String, tools: [ToolCall]) -> some View { VStack(alignment: .leading, spacing: 6) { Button { withAnimation(.easeInOut(duration: 0.2)) { - showTransientTools.toggle() + if expandedTransientGroupIds.contains(groupId) { + expandedTransientGroupIds.remove(groupId) + } else { + expandedTransientGroupIds.insert(groupId) + } } } label: { + let isExpanded = expandedTransientGroupIds.contains(groupId) HStack(spacing: 6) { Image(systemName: "eye.slash") .font(.system(size: ClaudeTheme.messageSize(11))) .foregroundStyle(ClaudeTheme.textTertiary) - Text(String(format: String(localized: "%lld tools executed", bundle: .module), hidden.count)) + Text(String(format: String(localized: "%lld tools executed", bundle: .module), tools.count)) .font(.system(size: ClaudeTheme.messageSize(12))) .foregroundStyle(ClaudeTheme.textTertiary) - Image(systemName: showTransientTools ? "chevron.up" : "chevron.down") + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.system(size: ClaudeTheme.messageSize(9))) .foregroundStyle(ClaudeTheme.textTertiary) } @@ -424,8 +462,8 @@ struct MessageBubble: View { } .buttonStyle(.plain) - if showTransientTools { - ForEach(hidden, id: \.id) { toolCall in + if expandedTransientGroupIds.contains(groupId) { + ForEach(tools, id: \.id) { toolCall in ToolResultView(toolCall: toolCall, isMessageStreaming: false) } } diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index a529634d..6f136adf 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -83,6 +83,7 @@ struct MessageListView: View { StreamingIndicatorView( isThinking: chatBridge.isThinking, startDate: chatBridge.streamingStartDate, + agentProvider: chatBridge.agentProvider, outputTokens: chatBridge.liveOutputTokens ) Spacer(minLength: 40) @@ -546,37 +547,63 @@ struct EmptySessionView: View { struct StreamingIndicatorView: View { let isThinking: Bool var startDate: Date? + var agentProvider: AgentProvider = .claudeCode var outputTokens: Int = 0 var body: some View { - HStack(spacing: 10) { - AnimatedDotsView() - - HStack(spacing: 6) { - if let startDate { - InlineElapsedTimeView(startDate: startDate) + VStack(alignment: .leading, spacing: 8) { + if isThinking { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.system(size: ClaudeTheme.size(14), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text(thinkingLabel) + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + ProgressView() + .controlSize(.small) + .scaleEffect(0.65) + .tint(ClaudeTheme.textTertiary) } + } - if outputTokens > 0 { - if startDate != nil { - separator - } - HStack(spacing: 3) { - Image(systemName: "arrow.up") - .font(.system(size: ClaudeTheme.size(9), weight: .semibold)) - Text(formatTokenCount(outputTokens)) - .monospacedDigit() - .contentTransition(.numericText(value: Double(outputTokens))) - Text("tok", bundle: .module) - .foregroundStyle(ClaudeTheme.textTertiary) - } - .animation(.easeInOut(duration: 0.25), value: outputTokens) - } + HStack(spacing: 10) { + AnimatedDotsView() + metadataView } - .font(.system(size: ClaudeTheme.size(11), weight: .medium, design: .monospaced)) - .foregroundStyle(ClaudeTheme.textSecondary) } .padding(.vertical, 8) + .accessibilityLabel(isThinking ? "\(thinkingLabel) in progress" : "Response in progress") + } + + private var thinkingLabel: String { + agentProvider == .codex ? "reasoning" : "thinking" + } + + private var metadataView: some View { + HStack(spacing: 6) { + if let startDate { + InlineElapsedTimeView(startDate: startDate) + } + + if outputTokens > 0 { + if startDate != nil { + separator + } + HStack(spacing: 3) { + Image(systemName: "arrow.up.arrow.down") + .font(.system(size: ClaudeTheme.size(9), weight: .semibold)) + Text(formatTokenCount(outputTokens)) + .monospacedDigit() + .contentTransition(.numericText(value: Double(outputTokens))) + Text("tok", bundle: .module) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .animation(.easeInOut(duration: 0.25), value: outputTokens) + } + } + .font(.system(size: ClaudeTheme.size(11), weight: .medium, design: .monospaced)) + .foregroundStyle(ClaudeTheme.textSecondary) } private var separator: some View { diff --git a/Packages/Sources/RxCodeChatKit/StatusLineView.swift b/Packages/Sources/RxCodeChatKit/StatusLineView.swift index 0faab4a7..99bee5f1 100644 --- a/Packages/Sources/RxCodeChatKit/StatusLineView.swift +++ b/Packages/Sources/RxCodeChatKit/StatusLineView.swift @@ -8,10 +8,6 @@ struct StatusLineView: View { @State private var showSevenDayPopover = false @State private var showContextPopover = false - private var modelDisplayName: String { - chatBridge.modelDisplayName - } - private var totalResponseDuration: Double { chatBridge.messages .filter { $0.role == .assistant } @@ -26,28 +22,11 @@ struct StatusLineView: View { var body: some View { HStack(spacing: 10) { - segment(icon: "cpu", text: modelDisplayName, color: ClaudeTheme.statusSuccess) + providerMenuSegment() Divider().frame(height: 12) - rateLimitSegment( - label: String(localized: "5h", bundle: .module), - icon: "clock", - percent: rateLimit?.fiveHourPercent, - resetsAt: rateLimit?.fiveHourResetsAt, - isPresented: $showFiveHourPopover, - title: "5-hour rate limit", - body: "Tracks usage against Anthropic's rolling 5-hour limit. Resets gradually as older requests age out." - ) - rateLimitSegment( - label: String(localized: "7d", bundle: .module), - icon: "calendar", - percent: rateLimit?.sevenDayPercent, - resetsAt: rateLimit?.sevenDayResetsAt, - isPresented: $showSevenDayPopover, - title: "7-day rate limit", - body: "Tracks usage against Anthropic's rolling 7-day limit. Resets gradually as older requests age out." - ) + usageSegments() Divider().frame(height: 12) @@ -88,31 +67,116 @@ struct StatusLineView: View { Task { await refreshRateLimit() } } } + .onChange(of: chatBridge.agentProvider) { + rateLimit = nil + Task { await refreshRateLimit() } + } } // MARK: - Version Segment @ViewBuilder private func versionSegment() -> some View { - if let cli = chatBridge.claudeVersion { + if let cli = currentRuntimeVersion { HStack(spacing: 4) { Image(systemName: "sparkle") .font(.system(size: ClaudeTheme.size(10))) - Text("CC \(cli)") + Text("\(currentRuntimeShortName) \(cli)") } .foregroundStyle(ClaudeTheme.textTertiary) } } - // MARK: - Segment + private var currentRuntimeVersion: String? { + switch chatBridge.agentProvider { + case .claudeCode: return chatBridge.claudeVersion + case .codex: return chatBridge.codexVersion + } + } - private func segment(icon: String, text: String, color: Color) -> some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: ClaudeTheme.size(10))) - Text(text) + private var currentRuntimeShortName: String { + switch chatBridge.agentProvider { + case .claudeCode: return "CC" + case .codex: return "Codex" + } + } + + // MARK: - Provider Menu + + private func providerMenuSegment() -> some View { + Menu { + ForEach(AgentProvider.allCases, id: \.self) { provider in + Button { + chatBridge.setSessionProvider(provider) + } label: { + Text(provider.displayName) + if chatBridge.agentProvider == provider { + Image(systemName: "checkmark") + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: chatBridge.agentProvider == .codex ? "chevron.left.forwardslash.chevron.right" : "cpu") + .font(.system(size: ClaudeTheme.size(10))) + Text(chatBridge.agentProvider.displayName) + Image(systemName: "chevron.down") + .font(.system(size: ClaudeTheme.size(8), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .foregroundStyle(ClaudeTheme.statusSuccess) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Client: \(chatBridge.agentProvider.displayName)") + } + + // MARK: - Usage Segments + + @ViewBuilder + private func usageSegments() -> some View { + switch chatBridge.agentProvider { + case .claudeCode: + rateLimitSegment( + label: String(localized: "5h", bundle: .module), + icon: "clock", + percent: rateLimit?.fiveHourPercent, + resetsAt: rateLimit?.fiveHourResetsAt, + isPresented: $showFiveHourPopover, + title: "5-hour rate limit", + body: "Tracks usage against Anthropic's rolling 5-hour limit. Resets gradually as older requests age out." + ) + rateLimitSegment( + label: String(localized: "7d", bundle: .module), + icon: "calendar", + percent: rateLimit?.sevenDayPercent, + resetsAt: rateLimit?.sevenDayResetsAt, + isPresented: $showSevenDayPopover, + title: "7-day rate limit", + body: "Tracks usage against Anthropic's rolling 7-day limit. Resets gradually as older requests age out." + ) + case .codex: + rateLimitSegment( + label: String(localized: "5h", bundle: .module), + icon: "clock", + percent: rateLimit?.fiveHourPercent, + resetsAt: rateLimit?.fiveHourResetsAt, + isPresented: $showFiveHourPopover, + title: "5-hour rate limit", + body: "Tracks usage against Codex's rolling 5-hour limit. Resets gradually as older requests age out." + ) + rateLimitSegment( + label: String(localized: "24h", bundle: .module), + icon: "calendar", + percent: rateLimit?.twentyFourHourPercent, + resetsAt: rateLimit?.twentyFourHourResetsAt, + isPresented: $showSevenDayPopover, + title: "24-hour rate limit", + body: "Tracks usage against Codex's rolling 24-hour limit. Resets gradually as older requests age out." + ) } - .foregroundStyle(color) } // MARK: - Rate Limit Segment diff --git a/Packages/Sources/RxCodeChatKit/ToolResultView.swift b/Packages/Sources/RxCodeChatKit/ToolResultView.swift index 2f2d7515..0a7f2163 100644 --- a/Packages/Sources/RxCodeChatKit/ToolResultView.swift +++ b/Packages/Sources/RxCodeChatKit/ToolResultView.swift @@ -151,6 +151,11 @@ struct ToolResultView: View { ClaudeThemeDivider() editDiffView(oldString: oldStr, newString: newStr) } + } else if isEditTool, !toolCall.fileChangeDiffs.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ClaudeThemeDivider() + fileChangeDiffsView(toolCall.fileChangeDiffs) + } } else if let result = toolCall.result, !result.isEmpty { VStack(alignment: .leading, spacing: 6) { ClaudeThemeDivider() @@ -258,8 +263,11 @@ struct ToolResultView: View { } private var hasExpandableContent: Bool { - isEditTool && toolCall.input["old_string"]?.stringValue != nil - && toolCall.input["new_string"]?.stringValue != nil + isEditTool && ( + (toolCall.input["old_string"]?.stringValue != nil + && toolCall.input["new_string"]?.stringValue != nil) + || !toolCall.fileChangeDiffs.isEmpty + ) } private func editHunksFromToolInput() -> [PreviewFile.EditHunk] { @@ -333,6 +341,79 @@ struct ToolResultView: View { .textSelection(.enabled) } + private func fileChangeDiffsView(_ changes: [ToolCall.FileChangeDiff]) -> some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(changes.enumerated()), id: \.offset) { _, change in + VStack(alignment: .leading, spacing: 4) { + if changes.count > 1 { + Text(URL(fileURLWithPath: change.path).lastPathComponent) + .font(.system(size: ClaudeTheme.messageSize(12), weight: .medium)) + .foregroundStyle(ClaudeTheme.textSecondary) + .lineLimit(1) + } + rawUnifiedDiffView(change.diff) + } + } + } + .textSelection(.enabled) + } + + private func rawUnifiedDiffView(_ diff: String) -> some View { + let lines = diff.components(separatedBy: .newlines) + let collapseThreshold = 18 + let needsToggle = lines.count > collapseThreshold + let visibleLines = needsToggle && !isDiffExpanded + ? Array(lines.prefix(collapseThreshold)) + : lines + + return VStack(alignment: .leading, spacing: 0) { + ForEach(Array(visibleLines.enumerated()), id: \.offset) { _, line in + Text(line.isEmpty ? " " : line) + .font(.system(size: ClaudeTheme.messageSize(12), design: .monospaced)) + .foregroundStyle(diffLineColor(line)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(diffLineBackground(line)) + } + + if needsToggle { + Button { + withAnimation(.easeInOut(duration: 0.2)) { isDiffExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Text(isDiffExpanded ? "Show less" : "Show more", bundle: .module) + .font(.system(size: ClaudeTheme.messageSize(12), weight: .medium)) + Image(systemName: isDiffExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: ClaudeTheme.messageSize(10), weight: .medium)) + } + .foregroundStyle(ClaudeTheme.textTertiary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(ClaudeTheme.surfacePrimary.opacity(0.6)) + ) + } + .buttonStyle(.plain) + } + } + } + + private func diffLineColor(_ line: String) -> Color { + if line.hasPrefix("+"), !line.hasPrefix("+++") { return ClaudeTheme.statusSuccess } + if line.hasPrefix("-"), !line.hasPrefix("---") { return ClaudeTheme.statusError } + if line.hasPrefix("@@") { return ClaudeTheme.accent } + return ClaudeTheme.textPrimary + } + + private func diffLineBackground(_ line: String) -> Color { + if line.hasPrefix("+"), !line.hasPrefix("+++") { return ClaudeTheme.statusSuccess.opacity(0.06) } + if line.hasPrefix("-"), !line.hasPrefix("---") { return ClaudeTheme.statusError.opacity(0.06) } + if line.hasPrefix("@@") { return ClaudeTheme.accent.opacity(0.08) } + return Color.clear + } + // MARK: - Helpers private var sfSymbol: String { @@ -383,7 +464,7 @@ struct ToolResultView: View { @ViewBuilder private var inputSummaryView: some View { if isEditTool || toolNameLower == "write", - let filePath = toolCall.input["file_path"]?.stringValue { + let filePath = toolCall.editedFilePath { let fileName = URL(fileURLWithPath: filePath).lastPathComponent HStack(spacing: 0) { Text("\(toolDescriptionPrefix) — ") @@ -442,6 +523,10 @@ struct ToolResultView: View { let fileName = URL(fileURLWithPath: filePath).lastPathComponent return "\(toolDescriptionPrefix) — \(fileName)" } + if let filePath = toolCall.editedFilePath { + let fileName = URL(fileURLWithPath: filePath).lastPathComponent + return "\(toolDescriptionPrefix) — \(fileName)" + } if let command = toolCall.input["command"]?.stringValue { return "\(toolDescriptionPrefix) — \(command.count > 50 ? String(command.prefix(50)) + "..." : command)" } diff --git a/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift b/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift index ecb0f11e..9497854d 100644 --- a/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift +++ b/Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift @@ -206,6 +206,7 @@ public actor CLISessionStore { createdAt: snippet.firstTimestamp ?? mtimeDate, updatedAt: snippet.lastTimestamp ?? mtimeDate, isPinned: meta.isPinned, + agentProvider: meta.agentProvider ?? .claudeCode, model: meta.model, effort: meta.effort, permissionMode: meta.permissionMode, @@ -423,6 +424,7 @@ public actor CLISessionStore { createdAt: firstTimestamp ?? mtimeDate, updatedAt: mtimeDate, isPinned: meta.isPinned, + agentProvider: meta.agentProvider ?? .claudeCode, model: meta.model, effort: meta.effort, permissionMode: meta.permissionMode, diff --git a/Packages/Sources/RxCodeCore/CLISession/SessionMetaStore.swift b/Packages/Sources/RxCodeCore/CLISession/SessionMetaStore.swift index 32850628..423ec6cb 100644 --- a/Packages/Sources/RxCodeCore/CLISession/SessionMetaStore.swift +++ b/Packages/Sources/RxCodeCore/CLISession/SessionMetaStore.swift @@ -9,6 +9,7 @@ public actor SessionMetaStore { public struct Meta: Codable, Sendable { public var title: String? public var isPinned: Bool + public var agentProvider: AgentProvider? public var model: String? public var effort: String? public var permissionMode: PermissionMode? @@ -19,6 +20,7 @@ public actor SessionMetaStore { public init( title: String? = nil, isPinned: Bool = false, + agentProvider: AgentProvider? = nil, model: String? = nil, effort: String? = nil, permissionMode: PermissionMode? = nil, @@ -28,6 +30,7 @@ public actor SessionMetaStore { ) { self.title = title self.isPinned = isPinned + self.agentProvider = agentProvider self.model = model self.effort = effort self.permissionMode = permissionMode @@ -37,13 +40,14 @@ public actor SessionMetaStore { } private enum CodingKeys: String, CodingKey { - case title, isPinned, model, effort, permissionMode, updatedAt, worktreePath, worktreeBranch + case title, isPinned, agentProvider, model, effort, permissionMode, updatedAt, worktreePath, worktreeBranch } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) title = try container.decodeIfPresent(String.self, forKey: .title) isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + agentProvider = try container.decodeIfPresent(AgentProvider.self, forKey: .agentProvider) model = try container.decodeIfPresent(String.self, forKey: .model) effort = try container.decodeIfPresent(String.self, forKey: .effort) permissionMode = try container.decodeIfPresent(PermissionMode.self, forKey: .permissionMode) diff --git a/Packages/Sources/RxCodeCore/Models/AgentModel.swift b/Packages/Sources/RxCodeCore/Models/AgentModel.swift new file mode 100644 index 00000000..cc646550 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/AgentModel.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum AgentProvider: String, Codable, CaseIterable, Sendable, Hashable { + case claudeCode + case codex + + public var displayName: String { + switch self { + case .claudeCode: return "Claude Code" + case .codex: return "Codex" + } + } +} + +public struct AgentModel: Identifiable, Codable, Sendable, Hashable { + public let provider: AgentProvider + public let id: String + public let displayName: String + public let description: String + + public var key: String { "\(provider.rawValue):\(id)" } + + public init(provider: AgentProvider, id: String, displayName: String, description: String = "") { + self.provider = provider + self.id = id + self.displayName = displayName + self.description = description + } +} diff --git a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift index f37e9ab3..8e28912f 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatMessage.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatMessage.swift @@ -153,7 +153,7 @@ public struct ChatMessage: Identifiable, Codable, Sendable, Equatable { public mutating func setToolResult(id: String, result: String, isError: Bool) { guard let index = toolCallIndex(id: id), let toolCall = blocks[index].toolCall else { return } - if result.isEmpty && !isError && !toolCall.isKeepAlways { + if result.isEmpty && !isError && !toolCall.keepsEmptyResult { blocks.remove(at: index) } else { blocks[index].toolCall?.result = result @@ -164,8 +164,9 @@ public struct ChatMessage: Identifiable, Codable, Sendable, Equatable { public mutating func finalizeToolCalls() { blocks.removeAll { block in guard let toolCall = block.toolCall else { return false } - if toolCall.isKeepAlways { return false } - return toolCall.result == nil || (toolCall.result?.isEmpty == true && !toolCall.isError) + if toolCall.result == nil { return !toolCall.isKeepAlways } + if toolCall.keepsEmptyResult { return false } + return toolCall.result?.isEmpty == true && !toolCall.isError } } } @@ -217,6 +218,12 @@ public struct ToolCall: Identifiable, Codable, Sendable, Equatable { Self.keepAlwaysNames.contains(name.lowercased()) } + /// Completed read/execution tools may have an empty result, but the chat UI + /// still needs the block so it can count them in the collapsed tool summary. + public var keepsEmptyResult: Bool { + isKeepAlways || ToolCategory(toolName: name).isTransient + } + public init( id: String, name: String, @@ -235,6 +242,37 @@ public struct ToolCall: Identifiable, Codable, Sendable, Equatable { // MARK: - File Edit Extraction public extension ToolCall { + struct FileChangeDiff: Sendable, Equatable { + public let path: String + public let diff: String + + public init(path: String, diff: String) { + self.path = path + self.diff = diff + } + + /// Synthesize a single `(oldString, newString)` hunk from a unified diff + /// body so Codex `fileChange` calls can flow through the same + /// `ThreadFileEdit` storage as Claude Edit/MultiEdit/Write. + public var hunk: PreviewFile.EditHunk { + var removed: [String] = [] + var added: [String] = [] + for rawLine in diff.components(separatedBy: "\n") { + if rawLine.hasPrefix("---") || rawLine.hasPrefix("+++") { continue } + if rawLine.hasPrefix("@@") { continue } + if rawLine.hasPrefix("-") { + removed.append(String(rawLine.dropFirst())) + } else if rawLine.hasPrefix("+") { + added.append(String(rawLine.dropFirst())) + } + } + return PreviewFile.EditHunk( + oldString: removed.joined(separator: "\n"), + newString: added.joined(separator: "\n") + ) + } + } + /// Edit/MultiEdit/Write input → list of (old, new) hunks. Returns `[]` for /// non-edit tools or malformed input. Write is represented as a single /// hunk with `oldString == ""` and `newString == content`, so the existing @@ -261,9 +299,22 @@ public extension ToolCall { } } + var fileChangeDiffs: [FileChangeDiff] { + guard name.lowercased() == "edit", + let changes = input["changes"]?.arrayValue else { return [] } + + return changes.compactMap { entry in + guard let obj = entry.objectValue, + let path = obj["path"]?.stringValue, + let diff = obj["diff"]?.stringValue, + !diff.isEmpty else { return nil } + return FileChangeDiff(path: path, diff: diff) + } + } + var editedFilePath: String? { guard ["edit", "multiedit", "multi_edit", "write"].contains(name.lowercased()) else { return nil } - return input["file_path"]?.stringValue + return input["file_path"]?.stringValue ?? fileChangeDiffs.first?.path } } diff --git a/Packages/Sources/RxCodeCore/Models/ChatSession.swift b/Packages/Sources/RxCodeCore/Models/ChatSession.swift index 0986b3dd..fd46c7d6 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatSession.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatSession.swift @@ -10,6 +10,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { public let createdAt: Date public var updatedAt: Date public var isPinned: Bool + public var agentProvider: AgentProvider public var model: String? public var effort: String? public var permissionMode: PermissionMode? @@ -27,6 +28,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { createdAt: Date = Date(), updatedAt: Date = Date(), isPinned: Bool = false, + agentProvider: AgentProvider = .claudeCode, model: String? = nil, effort: String? = nil, permissionMode: PermissionMode? = nil, @@ -43,6 +45,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { self.createdAt = createdAt self.updatedAt = updatedAt self.isPinned = isPinned + self.agentProvider = agentProvider self.model = model self.effort = effort self.permissionMode = permissionMode @@ -54,7 +57,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { } private enum CodingKeys: String, CodingKey { - case id, projectId, title, messages, createdAt, updatedAt, isPinned, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt + case id, projectId, title, messages, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt } public init(from decoder: Decoder) throws { @@ -66,6 +69,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + agentProvider = try container.decodeIfPresent(AgentProvider.self, forKey: .agentProvider) ?? .claudeCode model = try container.decodeIfPresent(String.self, forKey: .model) effort = try container.decodeIfPresent(String.self, forKey: .effort) permissionMode = try container.decodeIfPresent(PermissionMode.self, forKey: .permissionMode) @@ -83,6 +87,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { public let createdAt: Date public var updatedAt: Date public var isPinned: Bool + public var agentProvider: AgentProvider public var model: String? public var effort: String? public var permissionMode: PermissionMode? @@ -99,6 +104,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { createdAt: Date, updatedAt: Date, isPinned: Bool, + agentProvider: AgentProvider = .claudeCode, model: String? = nil, effort: String? = nil, permissionMode: PermissionMode? = nil, @@ -114,6 +120,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { self.createdAt = createdAt self.updatedAt = updatedAt self.isPinned = isPinned + self.agentProvider = agentProvider self.model = model self.effort = effort self.permissionMode = permissionMode @@ -125,7 +132,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { } private enum CodingKeys: String, CodingKey { - case id, projectId, title, createdAt, updatedAt, isPinned, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt + case id, projectId, title, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt } public init(from decoder: Decoder) throws { @@ -136,6 +143,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + agentProvider = try container.decodeIfPresent(AgentProvider.self, forKey: .agentProvider) ?? .claudeCode model = try container.decodeIfPresent(String.self, forKey: .model) effort = try container.decodeIfPresent(String.self, forKey: .effort) permissionMode = try container.decodeIfPresent(PermissionMode.self, forKey: .permissionMode) @@ -155,6 +163,7 @@ public struct ChatSession: Identifiable, Codable, Sendable { createdAt: createdAt, updatedAt: updatedAt, isPinned: isPinned, + agentProvider: agentProvider, model: model, effort: effort, permissionMode: permissionMode, @@ -237,6 +246,7 @@ extension ChatSession.Summary { ChatSession(id: id, projectId: projectId, title: title, messages: [], createdAt: createdAt, updatedAt: updatedAt, isPinned: isPinned, + agentProvider: agentProvider, model: model, effort: effort, permissionMode: permissionMode, origin: origin, worktreePath: worktreePath, worktreeBranch: worktreeBranch, diff --git a/Packages/Sources/RxCodeCore/Models/ChatThread.swift b/Packages/Sources/RxCodeCore/Models/ChatThread.swift index f5868959..6bf47f56 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatThread.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatThread.swift @@ -12,6 +12,8 @@ public final class ChatThread { public var cliSessionId: String? + public var agentProviderRaw: String? + public var originRaw: String? public var model: String? public var effort: String? public var permissionModeRaw: String? @@ -29,6 +31,8 @@ public final class ChatThread { updatedAt: Date = .now, isPinned: Bool = false, cliSessionId: String? = nil, + agentProviderRaw: String? = AgentProvider.claudeCode.rawValue, + originRaw: String? = SessionOrigin.cliBacked.rawValue, model: String? = nil, effort: String? = nil, permissionModeRaw: String? = nil, @@ -44,6 +48,8 @@ public final class ChatThread { self.updatedAt = updatedAt self.isPinned = isPinned self.cliSessionId = cliSessionId + self.agentProviderRaw = agentProviderRaw + self.originRaw = originRaw self.model = model self.effort = effort self.permissionModeRaw = permissionModeRaw @@ -56,17 +62,22 @@ public final class ChatThread { extension ChatThread { public func toSummary() -> ChatSession.Summary { - ChatSession.Summary( + let agentProvider = agentProviderRaw.flatMap(AgentProvider.init(rawValue:)) ?? .claudeCode + let origin = originRaw.flatMap(SessionOrigin.init(rawValue:)) + ?? (agentProvider == .codex ? .codexAppServer : .cliBacked) + + return ChatSession.Summary( id: id, projectId: projectId, title: title, createdAt: createdAt, updatedAt: updatedAt, isPinned: isPinned, + agentProvider: agentProvider, model: model, effort: effort, permissionMode: permissionModeRaw.flatMap(PermissionMode.init(rawValue:)), - origin: .cliBacked, + origin: origin, worktreePath: worktreePath, worktreeBranch: worktreeBranch, isArchived: isArchived, @@ -78,6 +89,8 @@ extension ChatThread { title = summary.title updatedAt = summary.updatedAt isPinned = summary.isPinned + agentProviderRaw = summary.agentProvider.rawValue + originRaw = summary.origin.rawValue model = summary.model effort = summary.effort permissionModeRaw = summary.permissionMode?.rawValue @@ -96,6 +109,8 @@ extension ChatThread { updatedAt: summary.updatedAt, isPinned: summary.isPinned, cliSessionId: cliSessionId, + agentProviderRaw: summary.agentProvider.rawValue, + originRaw: summary.origin.rawValue, model: summary.model, effort: summary.effort, permissionModeRaw: summary.permissionMode?.rawValue, diff --git a/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift b/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift index ff093da9..5a90acce 100644 --- a/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift +++ b/Packages/Sources/RxCodeCore/Models/RateLimitUsage.swift @@ -4,18 +4,24 @@ import Foundation public struct RateLimitUsage: Sendable { public let fiveHourPercent: Double public let sevenDayPercent: Double + public let twentyFourHourPercent: Double? public let fiveHourResetsAt: Date? public let sevenDayResetsAt: Date? + public let twentyFourHourResetsAt: Date? public init( fiveHourPercent: Double, sevenDayPercent: Double, + twentyFourHourPercent: Double? = nil, fiveHourResetsAt: Date?, - sevenDayResetsAt: Date? + sevenDayResetsAt: Date?, + twentyFourHourResetsAt: Date? = nil ) { self.fiveHourPercent = fiveHourPercent self.sevenDayPercent = sevenDayPercent + self.twentyFourHourPercent = twentyFourHourPercent self.fiveHourResetsAt = fiveHourResetsAt self.sevenDayResetsAt = sevenDayResetsAt + self.twentyFourHourResetsAt = twentyFourHourResetsAt } } diff --git a/Packages/Sources/RxCodeCore/Models/SessionOrigin.swift b/Packages/Sources/RxCodeCore/Models/SessionOrigin.swift index 8c54a683..32250deb 100644 --- a/Packages/Sources/RxCodeCore/Models/SessionOrigin.swift +++ b/Packages/Sources/RxCodeCore/Models/SessionOrigin.swift @@ -9,4 +9,8 @@ public enum SessionOrigin: String, Codable, Sendable { /// Backed by Claude Code CLI's `~/.claude/projects/{enc(cwd)}/{sid}.jsonl`. /// Source of truth is the CLI; RxCode keeps RxCode-only metadata in a sidecar. case cliBacked + + /// Backed by Codex app-server for execution. RxCode persists a local replay + /// copy of the rendered messages while using the Codex thread id for turns. + case codexAppServer } diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index cd36ff20..ba61a7d3 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -128,6 +128,9 @@ public final class WindowState { /// Per-session model override. When set, this model is used instead of the global default. /// Cleared when a new chat is started or a different session is selected. public var sessionModel: String? + /// Per-session agent provider override. Kept with `sessionModel` so new + /// sessions and resumes route to the correct runtime. + public var sessionAgentProvider: AgentProvider? /// Per-session effort override. When set, passed as --effort to the CLI. /// Cleared when a new chat is started or a different session is selected. public var sessionEffort: String? diff --git a/Packages/Tests/RxCodeCoreTests/AgentModelTests.swift b/Packages/Tests/RxCodeCoreTests/AgentModelTests.swift new file mode 100644 index 00000000..17d17551 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/AgentModelTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import RxCodeCore + +@Suite("Agent model persistence") +struct AgentModelTests { + @Test("AgentModel round trips provider and model id") + func agentModelRoundTrip() throws { + let model = AgentModel(provider: .codex, id: "gpt-5.4", displayName: "GPT-5.4", description: "Codex model") + let data = try JSONEncoder().encode(model) + let decoded = try JSONDecoder().decode(AgentModel.self, from: data) + + #expect(decoded.provider == .codex) + #expect(decoded.id == "gpt-5.4") + #expect(decoded.key == "codex:gpt-5.4") + } + + @Test("ChatSession defaults old payloads to Claude Code") + func oldChatSessionPayloadDefaultsToClaudeCode() throws { + let id = UUID().uuidString + let projectId = UUID() + let json = """ + { + "id": "\(id)", + "projectId": "\(projectId.uuidString)", + "title": "Old Session", + "messages": [], + "createdAt": "2026-05-14T00:00:00Z", + "updatedAt": "2026-05-14T00:00:00Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let session = try decoder.decode(ChatSession.self, from: Data(json.utf8)) + + #expect(session.agentProvider == .claudeCode) + #expect(session.summary.agentProvider == .claudeCode) + } + + @Test("ChatThread summary preserves Codex provider") + func chatThreadSummaryPreservesProvider() { + let summary = ChatSession.Summary( + id: "thread-1", + projectId: UUID(), + title: "Codex Thread", + createdAt: Date(), + updatedAt: Date(), + isPinned: false, + agentProvider: .codex, + model: "gpt-5.4", + origin: .codexAppServer + ) + + let row = ChatThread.from(summary, cliSessionId: "thread-1") + let roundTrip = row.toSummary() + + #expect(roundTrip.agentProvider == .codex) + #expect(roundTrip.model == "gpt-5.4") + #expect(roundTrip.origin == .codexAppServer) + } +} diff --git a/Packages/Tests/RxCodeCoreTests/ChatMessageToolResultTests.swift b/Packages/Tests/RxCodeCoreTests/ChatMessageToolResultTests.swift new file mode 100644 index 00000000..cc216773 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/ChatMessageToolResultTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import RxCodeCore + +@Suite("ChatMessage tool-result retention") +struct ChatMessageToolResultTests { + + @Test("Completed Bash with empty output is retained for collapsed summaries") + func emptyBashResultIsRetained() { + var message = ChatMessage(role: .assistant) + message.appendToolCall(ToolCall( + id: "bash-1", + name: "Bash", + input: ["command": .string("true")] + )) + + message.setToolResult(id: "bash-1", result: "", isError: false) + message.finalizeToolCalls() + + #expect(message.toolCalls.count == 1) + #expect(message.toolCalls.first?.result == "") + } + + @Test("Unresolved Bash is removed on final cleanup") + func unresolvedBashIsRemoved() { + var message = ChatMessage(role: .assistant) + message.appendToolCall(ToolCall( + id: "bash-1", + name: "Bash", + input: ["command": .string("sleep 1")] + )) + + message.finalizeToolCalls() + + #expect(message.toolCalls.isEmpty) + } + + @Test("Unknown tool with empty success is still removed") + func emptyUnknownToolResultIsRemoved() { + var message = ChatMessage(role: .assistant) + message.appendToolCall(ToolCall(id: "custom-1", name: "CustomTool")) + + message.setToolResult(id: "custom-1", result: "", isError: false) + + #expect(message.toolCalls.isEmpty) + } +} diff --git a/Packages/Tests/RxCodeCoreTests/ToolCallFileChangeTests.swift b/Packages/Tests/RxCodeCoreTests/ToolCallFileChangeTests.swift new file mode 100644 index 00000000..56da7c08 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/ToolCallFileChangeTests.swift @@ -0,0 +1,67 @@ +import Testing +@testable import RxCodeCore + +@Suite("ToolCall file-change extraction") +struct ToolCallFileChangeTests { + + @Test("Codex app-server fileChange exposes edited path and diff") + func codexFileChangeDiff() { + let diff = """ + @@ -1,2 +1,2 @@ + -old + +new + """ + let call = ToolCall( + id: "call-1", + name: "Edit", + input: [ + "type": "fileChange", + "changes": [ + [ + "path": "/tmp/example.swift", + "diff": .string(diff) + ] + ] + ] + ) + + #expect(call.editedFilePath == "/tmp/example.swift") + #expect(call.fileChangeDiffs == [ + ToolCall.FileChangeDiff(path: "/tmp/example.swift", diff: diff) + ]) + } + + @Test("Codex fileChange diff synthesizes a single old/new hunk") + func codexFileChangeHunk() { + let diff = """ + --- a/example.swift + +++ b/example.swift + @@ -1,3 +1,3 @@ + keep + -old line 1 + -old line 2 + +new line 1 + +new line 2 + keep + """ + let call = ToolCall( + id: "call-1", + name: "Edit", + input: [ + "type": "fileChange", + "changes": [ + [ + "path": "/tmp/example.swift", + "diff": .string(diff) + ] + ] + ] + ) + + let diffs = call.fileChangeDiffs + #expect(diffs.count == 1) + let hunk = diffs[0].hunk + #expect(hunk.oldString == "old line 1\nold line 2") + #expect(hunk.newString == "new line 1\nnew line 2") + } +} diff --git a/README.md b/README.md index c296f0ff..c0f7d1f4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **The terminal was for the few. RxCode is for everyone.** -RxCode is a lightweight native macOS desktop client for Claude Code. It brings the CLI agent workflow into a project-centric GUI with streaming chat, repository switching, file browsing, Git status, permissions, terminal access, and per-project notes. +RxCode is a lightweight native macOS desktop client for Claude Code and Codex. It brings the CLI agent workflow into a project-centric GUI with streaming chat, repository switching, file browsing, Git status, permissions, terminal access, and per-project notes. ![Platform](https://img.shields.io/badge/platform-macOS%2015.0%2B-blue) ![Swift](https://img.shields.io/badge/Swift-6.x-orange) @@ -21,7 +21,7 @@ RxCode is a lightweight native macOS desktop client for Claude Code. It brings t The terminal is a wall. For most people who aren't developers, it's a closed door — install a CLI, generate SSH keys, approve every tool call without a real preview of what it's about to do. None of that is hard for engineers; all of it is hard for everyone else. The terminal was for the few, and it still is. -RxCode was built so my non-developer coworkers could use Claude Code without learning a shell first. It doesn't reinvent the agent. It spawns the real `claude` CLI underneath, so your `CLAUDE.md`, skills, MCP servers, and slash commands keep working as-is. What sits on top is a native Mac app: +RxCode was built so my non-developer coworkers could use Claude Code and Codex without learning a shell first. It doesn't reinvent the agent. It talks to the real runtimes underneath: `claude` for Claude Code, and the Codex app-server through the `codex` CLI for Codex sessions. Your existing CLI setup stays the source of truth. What sits on top is a native Mac app: - Approval modals that surface the actual diff before any tool runs, with risk-aware Allow / Allow Session / Deny options. - Per-project windows you can run in parallel — switch tabs, double-click to spin off a window, keep streams alive in the background. @@ -29,15 +29,16 @@ RxCode was built so my non-developer coworkers could use Claude Code without lea - GitHub OAuth that handles SSH key setup for you, so `git clone` just works. - An inspector with a file tree, Git status, embedded terminal, and a per-project memo pad. -Same engine, no terminal required. +Same agents, no terminal required. --- -## Key Features vs. Claude Desktop +## What RxCode Adds | RxCode feature | Why it matters | |---------------|----------------| | **Native macOS app** | Built with SwiftUI, not Electron. The current v1.2.0 release is about 5.6 MB to download and about 13 MB unpacked, without bundling a browser runtime. | +| **Claude Code and Codex support** | Choose the agent runtime per session. RxCode detects installed `claude` and `codex` binaries, surfaces available models, and keeps the same project-centric workflow for both. | | **Project-centric workspace** | Register multiple local repositories, switch between them from project tabs, or open a project in its own window for parallel sessions. In-progress streams keep running in the background while you switch. | | **Custom slash commands** | Add, edit, disable, import, and export custom slash commands. Built-in commands can be edited locally, while JSON import/export stays custom-only. | | **Shortcut buttons** | Create quick buttons for prompts or terminal commands you run repeatedly. Terminal-command shortcuts can launch directly into RxCode's interactive terminal popup. | @@ -51,14 +52,14 @@ Same engine, no terminal required. | Feature | Description | |---------|-------------| -| **Streaming Chat** | Real-time Claude Code conversations with Markdown rendering, tool call visualization, diff views, and error bubbles for failed empty responses. | +| **Streaming Chat** | Real-time Claude Code and Codex conversations with Markdown rendering, tool call visualization, diff views, and error bubbles for failed empty responses. | | **Multi-Project Workspace** | Register local folders or GitHub repositories, switch freely, and keep per-project session history. | | **Dedicated Project Windows** | Double-click a project tab to open it in an independent window and work across multiple repositories at once. | | **Per-Session Controls** | Choose model, permission mode, and effort level per session from the chat toolbar. Defaults are configurable in Settings. | | **Permission Modes** | Ask, Accept Edits, Plan, Auto, and Bypass modes mirror Claude Code's permission model and can be changed from the toolbar. | | **Permission Management** | Risk-based approve/deny UI with Allow, Allow Session, Deny, and 5-minute auto-deny handling. | | **Effort Levels** | Auto, Low, Medium, High, XHigh, and Max reasoning controls for each session. | -| **Model Selection** | Claude Code aliases with localized descriptions, including Opus, Sonnet, Haiku, 1M context, and plan variants. | +| **Model Selection** | Claude Code aliases and Codex models with localized descriptions, including Opus, Sonnet, Haiku, 1M context, plan variants, and GPT coding models. | | **File Attachments** | Drag-and-drop files and images. Smart paste detects images, file paths, URLs, and long text. | | **Attachment Auto-Preview Settings** | Toggle automatic preview chips separately for URLs, file paths, images, and long text. | | **Slash Commands** | Built-in and custom command system with built-in command edits/toggles and custom-command JSON import/export. | @@ -83,7 +84,9 @@ Same engine, no terminal required. ## Requirements - **macOS 15.0** or later -- **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)** installed and authenticated +- At least one supported agent runtime installed and authenticated: + - **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)** + - **Codex CLI** - **Xcode with Swift 6.2+ toolchain** for building the current source tree --- diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 353fd274..2985acdf 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -36,6 +36,7 @@ struct SessionStreamState { var activeToolInputBuffer: String = "" // accumulator for input_json_delta // Per-session overrides (persisted in memory across session switches) + var agentProvider: AgentProvider? var model: String? var effort: String? var permissionMode: PermissionMode? @@ -76,6 +77,24 @@ struct SessionStreamState { var titleGenerationTriggered: Bool = false } +enum SummarizationProvider: String, CaseIterable, Identifiable { + case selectedClient + case claudeCode + case codex + case openAI + + var id: String { rawValue } + + var displayName: String { + switch self { + case .selectedClient: return "Selected Client" + case .claudeCode: return "Claude Code" + case .codex: return "Codex" + case .openAI: return "OpenAI" + } + } +} + @Observable @MainActor @@ -163,8 +182,45 @@ final class AppState { // MARK: - Model static let availableModels = ["default", "best", "opus", "opus[1m]", "opusplan", "sonnet", "sonnet[1m]", "haiku"] + static let fallbackCodexModels = ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"] + nonisolated static let defaultOpenAISummarizationEndpoint = "https://api.openai.com/v1" + private static let openAISummarizationKeychainService = "com.idealapp.RxCode.openai-summarization" + private static let openAISummarizationKeychainAccount = "apiKey" + + static var availableClaudeModels: [AgentModel] { + availableModels.map { + AgentModel(provider: .claudeCode, id: $0, displayName: modelDisplayName($0), description: modelDescription($0)) + } + } + + static func availableCodexModels(_ discovered: [AgentModel]) -> [AgentModel] { + if !discovered.isEmpty { return discovered } + return fallbackCodexModels.map { + AgentModel(provider: .codex, id: $0, displayName: modelDisplayName($0, provider: .codex), description: modelDescription($0, provider: .codex)) + } + } + + func availableAgentModelSections() -> [(provider: AgentProvider, models: [AgentModel])] { + [ + (.claudeCode, Self.availableClaudeModels), + (.codex, Self.availableCodexModels(codexModels)) + ] + } static func modelDisplayName(_ model: String) -> String { + modelDisplayName(model, provider: .claudeCode) + } + + static func modelDisplayName(_ model: String, provider: AgentProvider) -> String { + if provider == .codex { + return model + .replacingOccurrences(of: "-", with: " ") + .split(separator: " ") + .map { part in + part.uppercased().hasPrefix("GPT") ? part.uppercased() : part.capitalized + } + .joined(separator: " ") + } switch model { case "default": return "Default" case "best": return "Best" @@ -179,6 +235,18 @@ final class AppState { } static func modelDescription(_ model: String) -> String { + modelDescription(model, provider: .claudeCode) + } + + static func modelDescription(_ model: String, provider: AgentProvider) -> String { + if provider == .codex { + switch model { + case "gpt-5.4": return "Balanced Codex model for everyday coding." + case "gpt-5.4-mini": return "Fast Codex model for lighter coding tasks." + case "gpt-5.3-codex": return "Codex-optimized coding model." + default: return "Codex model served by the Codex app-server." + } + } let key: String switch model { case "default": key = "model.desc.default" @@ -225,10 +293,59 @@ final class AppState { didSet { UserDefaults.standard.set(selectedModel, forKey: "selectedModel") } } + var selectedAgentProvider: AgentProvider = AgentProvider(rawValue: UserDefaults.standard.string(forKey: "selectedAgentProvider") ?? "") ?? .claudeCode { + didSet { UserDefaults.standard.set(selectedAgentProvider.rawValue, forKey: "selectedAgentProvider") } + } + + var codexModels: [AgentModel] = [] + var selectedEffort: String = UserDefaults.standard.string(forKey: "selectedEffort") ?? "auto" { didSet { UserDefaults.standard.set(selectedEffort, forKey: "selectedEffort") } } + // MARK: - Summarization + + var summarizationProvider: SummarizationProvider = SummarizationProvider(rawValue: UserDefaults.standard.string(forKey: "summarizationProvider") ?? "") ?? .selectedClient { + didSet { UserDefaults.standard.set(summarizationProvider.rawValue, forKey: "summarizationProvider") } + } + + var openAISummarizationEndpoint: String = UserDefaults.standard.string(forKey: "openAISummarizationEndpoint") ?? AppState.defaultOpenAISummarizationEndpoint { + didSet { UserDefaults.standard.set(openAISummarizationEndpoint, forKey: "openAISummarizationEndpoint") } + } + + var openAISummarizationAPIKey: String = KeychainHelper.readString( + service: AppState.openAISummarizationKeychainService, + account: AppState.openAISummarizationKeychainAccount + ) ?? "" { + didSet { + let trimmed = openAISummarizationAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) + do { + if trimmed.isEmpty { + try KeychainHelper.delete( + service: AppState.openAISummarizationKeychainService, + account: AppState.openAISummarizationKeychainAccount + ) + } else if let data = trimmed.data(using: .utf8) { + try KeychainHelper.save( + data, + service: AppState.openAISummarizationKeychainService, + account: AppState.openAISummarizationKeychainAccount + ) + } + } catch { + logger.warning("Failed to update OpenAI summarization API key: \(error.localizedDescription)") + } + } + } + + var openAISummarizationModel: String = UserDefaults.standard.string(forKey: "openAISummarizationModel") ?? "" { + didSet { UserDefaults.standard.set(openAISummarizationModel, forKey: "openAISummarizationModel") } + } + + var openAISummarizationModels: [String] = [] + var openAISummarizationModelsError: String? + var isLoadingOpenAISummarizationModels = false + // MARK: - Notifications var notificationsEnabled: Bool = (UserDefaults.standard.object(forKey: "notificationsEnabled") as? Bool) ?? true { @@ -291,6 +408,8 @@ final class AppState { /// `refreshRateLimitUsage()`; nil until first successful fetch (or when not /// signed in to Claude Code). var latestRateLimitUsage: RateLimitUsage? + var latestCodexRateLimitUsage: RateLimitUsage? + @ObservationIgnored private var rateLimitUsageRefreshTasks: [AgentProvider: Task] = [:] /// Sessions currently streaming, anywhere across all windows. var inProgressSessionCount: Int { @@ -303,11 +422,85 @@ final class AppState { sessionStates.values.reduce(0) { $0 + ($1.hasUncheckedCompletion ? 1 : 0) } } - /// Force-refresh the rate-limit usage. Cheap to call repeatedly — RateLimitService - /// caches for 5 minutes on its own. + func cachedRateLimitUsage(for provider: AgentProvider) -> RateLimitUsage? { + switch provider { + case .claudeCode: + return latestRateLimitUsage + case .codex: + return latestCodexRateLimitUsage + } + } + + func rateLimitUsage(for provider: AgentProvider, forceRefresh: Bool = false) async -> RateLimitUsage? { + if !forceRefresh, let cached = cachedRateLimitUsage(for: provider) { + return cached + } + + if let task = rateLimitUsageRefreshTasks[provider] { + return await task.value ?? cachedRateLimitUsage(for: provider) + } + + let task = Task { [weak self] in + guard let self else { return nil } + switch provider { + case .claudeCode: + return await RateLimitService.shared.fetchUsage(forceRefresh: forceRefresh) + case .codex: + guard self.codexInstalled else { return nil } + return await self.codex.fetchRateLimits(forceRefresh: forceRefresh) + } + } + rateLimitUsageRefreshTasks[provider] = task + + let usage = await task.value + rateLimitUsageRefreshTasks[provider] = nil + + if let usage { + storeRateLimitUsage(usage, for: provider) + return usage + } + return cachedRateLimitUsage(for: provider) + } + + private func storeRateLimitUsage(_ usage: RateLimitUsage, for provider: AgentProvider) { + switch provider { + case .claudeCode: + latestRateLimitUsage = usage + case .codex: + latestCodexRateLimitUsage = usage + } + } + + /// Force-refresh the shared Claude rate-limit usage. func refreshRateLimitUsage(forceRefresh: Bool = false) async { - let usage = await RateLimitService.shared.fetchUsage(forceRefresh: forceRefresh) - latestRateLimitUsage = usage + _ = await rateLimitUsage(for: .claudeCode, forceRefresh: forceRefresh) + } + + /// Warm Codex usage early so the status bar can render Codex limits from cache. + func refreshCodexRateLimitUsage(forceRefresh: Bool = false) async { + _ = await rateLimitUsage(for: .codex, forceRefresh: forceRefresh) + } + + func refreshSelectedAgentRateLimitUsage(forceRefresh: Bool = false) async { + switch selectedAgentProvider { + case .claudeCode: + await refreshRateLimitUsage(forceRefresh: forceRefresh) + case .codex: + await refreshCodexRateLimitUsage(forceRefresh: forceRefresh) + } + } + + func setDefaultAgentProvider(_ provider: AgentProvider) { + guard provider != selectedAgentProvider else { return } + + selectedAgentProvider = provider + if let model = availableAgentModelSections() + .first(where: { $0.provider == provider })? + .models + .first? + .id { + selectedModel = model + } } /// Ref-counted set of projectIds with at least one open dedicated project window. @@ -350,10 +543,13 @@ final class AppState { } /// Sets the model for the current session and persists it in the session state. - func setSessionModel(_ model: String, in window: WindowState) { + func setSessionModel(_ model: String, provider: AgentProvider? = nil, in window: WindowState) { + let resolvedProvider = provider ?? window.sessionAgentProvider ?? selectedAgentProvider + window.sessionAgentProvider = resolvedProvider window.sessionModel = model let key = window.currentSessionId ?? window.newSessionKey updateState(key) { state in + state.agentProvider = resolvedProvider state.model = model // Drop the cached CLI-reported name so the status line reflects the // user's choice immediately; the next system event will refill it. @@ -361,6 +557,21 @@ final class AppState { } } + /// Sets only the active client for the current session. The model is resolved to the + /// current default for that client when possible, otherwise the first available model. + func setSessionProvider(_ provider: AgentProvider, in window: WindowState) { + let currentProvider = window.sessionAgentProvider ?? selectedAgentProvider + guard provider != currentProvider else { return } + + let fallbackModel = availableAgentModelSections() + .first { $0.provider == provider }? + .models + .first? + .id + let model = selectedAgentProvider == provider ? selectedModel : (fallbackModel ?? selectedModel) + setSessionModel(model, provider: provider, in: window) + } + /// Sets the effort for the current session and persists it in the session state. func setSessionEffort(_ effort: String?, in window: WindowState) { window.sessionEffort = effort @@ -407,11 +618,11 @@ final class AppState { } } - func modelDisplayName(for model: String, in window: WindowState) -> String { + func modelDisplayName(for model: String, provider: AgentProvider, in window: WindowState) -> String { if let active = activeModelName(in: window) { return active } - return Self.modelDisplayName(model) + return Self.modelDisplayName(model, provider: provider) } static func formatModelId(_ raw: String) -> String { @@ -446,6 +657,9 @@ final class AppState { // MARK: - CLI Version var claudeVersion: String? + var codexVersion: String? + var claudeBinaryPath: String? + var codexBinaryPath: String? // MARK: - Marketplace @@ -457,6 +671,7 @@ final class AppState { // MARK: - Onboarding var claudeInstalled = false + var codexInstalled = false var onboardingCompleted = UserDefaults.standard.bool(forKey: "onboardingCompleted") // MARK: - App Initialization @@ -473,6 +688,8 @@ final class AppState { let metaStore = SessionMetaStore() let cliStore: CLISessionStore let claude: ClaudeService + let codex: CodexAppServer + let openAISummarization = OpenAISummarizationService() let persistence: PersistenceService let marketplace = MarketplaceService() let mcp: MCPService @@ -507,6 +724,7 @@ final class AppState { self.cliStore = cliStore let claude = ClaudeService(cliStore: cliStore) self.claude = claude + self.codex = CodexAppServer() self.persistence = PersistenceService(metaStore: metaStore, cliStore: cliStore) self.mcp = MCPService(claudeService: claude) self.threadStore = ThreadStore.make() @@ -755,12 +973,29 @@ final class AppState { if state.isStreaming { return .streaming } if state.hasUncheckedCompletion { return .done } } - if !window.pendingPermissions.isEmpty, window.currentSessionId == id { + if window.pendingPermissions.contains(where: { $0.sessionId == id }) { return .awaitingPermission } return .idle } + func todoProgress(forSessionId id: String) -> ChatTodoProgress? { + if let messages = sessionStates[id]?.messages, + let todos = TodoExtractor.latest(in: messages) { + return ChatTodoProgress(todos: todos) + } + + guard let snapshot = threadStore.fetchTodoSnapshot(sessionId: id), snapshot.total > 0 else { + return nil + } + + return ChatTodoProgress( + done: snapshot.done, + total: snapshot.total, + inProgress: snapshot.inProgress > 0 + ) + } + // MARK: - Initialization /// Once per app launch — start services and load shared data @@ -769,16 +1004,7 @@ final class AppState { ThemeStore.shared.fontSizeAdjustment = fontSizeAdjustment ThemeStore.shared.messageFontSizeAdjustment = messageFontSizeAdjustment - let binary = await claude.findClaudeBinary() - claudeInstalled = binary != nil - - if binary != nil { - do { - claudeVersion = try await claude.checkVersion() - } catch { - logger.warning("Failed to fetch Claude CLI version: \(error.localizedDescription)") - } - } + await refreshAgentInstallations() projects = await persistence.loadProjects() var seenPaths = Set() @@ -803,7 +1029,7 @@ final class AppState { persistedQueues = threadStore.loadAllQueues() - if claudeInstalled && !onboardingCompleted { + if (claudeInstalled || codexInstalled) && !onboardingCompleted { onboardingCompleted = true UserDefaults.standard.set(true, forKey: "onboardingCompleted") } @@ -826,6 +1052,7 @@ final class AppState { // popover is opened. RateLimitService caches for 5 minutes internally. Task { [weak self] in await self?.refreshRateLimitUsage() + await self?.refreshCodexRateLimitUsage() } // Recurring probe so disconnected MCP servers surface promptly even @@ -837,6 +1064,65 @@ final class AppState { isInitialized = true } + func refreshAgentInstallations() async { + let claudeBinary = await claude.findClaudeBinary() + claudeBinaryPath = claudeBinary + claudeInstalled = claudeBinary != nil + claudeVersion = nil + + if claudeBinary != nil { + do { + claudeVersion = try await claude.checkVersion() + } catch { + logger.warning("Failed to fetch Claude CLI version: \(error.localizedDescription)") + } + } + + let codexBinary = await codex.findCodexBinary() + codexBinaryPath = codexBinary + codexInstalled = codexBinary != nil + codexVersion = nil + + if codexBinary != nil { + do { + codexVersion = try await codex.checkVersion() + codexModels = await codex.fetchModels() + logger.info("Codex CLI detected; fetched \(self.codexModels.count) Codex models") + if codexModels.isEmpty { + logger.warning("Codex model discovery returned empty; using built-in Codex fallback models") + } + Task { [weak self] in + await self?.refreshCodexRateLimitUsage() + } + } catch { + logger.warning("Failed to fetch Codex CLI version or models: \(error.localizedDescription)") + } + } else { + codexModels = [] + logger.info("Codex CLI not detected; Codex model list cleared") + } + } + + func refreshOpenAISummarizationModels() async { + let endpoint = openAISummarizationEndpoint + let apiKey = openAISummarizationAPIKey + + isLoadingOpenAISummarizationModels = true + openAISummarizationModelsError = nil + defer { isLoadingOpenAISummarizationModels = false } + + do { + let models = try await openAISummarization.fetchModels(endpoint: endpoint, apiKey: apiKey) + openAISummarizationModels = models + if openAISummarizationModel.isEmpty || !models.contains(openAISummarizationModel) { + openAISummarizationModel = models.first ?? "" + } + } catch { + openAISummarizationModelsError = error.localizedDescription + logger.warning("Failed to fetch OpenAI summarization models: \(error.localizedDescription)") + } + } + /// Per-window initialization — restore selected project and load session history func initializeWindow(_ window: WindowState, selectingProjectId: UUID? = nil) async { // Subscribe to permission broadcasts — appends requests to this window's pendingPermissions. @@ -945,8 +1231,12 @@ final class AppState { guard let self, let window else { return } await self.editAndResend(messageId: messageId, newContent: newContent, in: window) } - bridge.fetchRateLimitHandler = { - await RateLimitService.shared.fetchUsage() + bridge.fetchRateLimitHandler = { [weak self] provider in + await self?.rateLimitUsage(for: provider) + } + bridge.setSessionProviderHandler = { [weak self, weak window] provider in + guard let self, let window else { return } + self.setSessionProvider(provider, in: window) } bridge.togglePlanModeHandler = { [weak self, weak window] in guard let self, let window else { return } @@ -994,7 +1284,10 @@ final class AppState { bridge.streamingStartDate = state.streamingStartDate bridge.liveOutputTokens = state.currentTurnOutputTokens bridge.lastTurnContextUsedPercentage = state.lastTurnContextUsedPercentage - bridge.modelDisplayName = modelDisplayName(for: window.sessionModel ?? selectedModel, in: window) + let provider = window.sessionAgentProvider ?? selectedAgentProvider + let currentModel = window.sessionModel ?? selectedModel + bridge.agentProvider = provider + bridge.modelDisplayName = modelDisplayName(for: currentModel, provider: provider, in: window) bridge.sessionStats = ChatSessionStats( costUsd: state.costUsd, inputTokens: state.inputTokens, @@ -1013,6 +1306,7 @@ final class AppState { bridge.autoPreviewSettings = self.autoPreviewSettings bridge.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" bridge.claudeVersion = self.claudeVersion + bridge.codexVersion = self.codexVersion } onChange: { Task { @MainActor in observeSettings() } } @@ -1054,7 +1348,8 @@ final class AppState { // S2: warn (in logs) if another process touched the same jsonl very // recently — likely a `claude` running in the terminal on the same // session. We don't block, but the operator can spot it after the fact. - if let sid = window.currentSessionId, + if (window.sessionAgentProvider ?? selectedAgentProvider) == .claudeCode, + let sid = window.currentSessionId, let cwd = window.selectedProject?.path, cliStore.detectExternalActivity(sid: sid, cwd: cwd, withinSeconds: 5) { logger.warning("Session \(sid, privacy: .public) jsonl was modified within 5s — another claude process may be active") @@ -1089,8 +1384,10 @@ final class AppState { case "model": if parts.count > 1 { let arg = String(parts[1]).trimmingCharacters(in: .whitespaces).lowercased() - let matched = Self.availableModels.first { $0 == arg } ?? Self.availableModels.first { arg.contains($0) } ?? arg - setSessionModel(matched, in: window) + let flattened = availableAgentModelSections().flatMap(\.models) + let matched = flattened.first { $0.id.lowercased() == arg } + ?? flattened.first { arg.contains($0.id.lowercased()) } + setSessionModel(matched?.id ?? arg, provider: matched?.provider, in: window) } else { window.showModelPicker = true } @@ -1238,10 +1535,12 @@ final class AppState { let tempId = "pending-\(streamId.uuidString)" window.currentSessionId = tempId window.insertPendingPlaceholder(tempId) + let snapProvider = window.sessionAgentProvider ?? selectedAgentProvider let snapModel = window.sessionModel let snapEffort = window.sessionEffort let snapPermission = window.sessionPermissionMode updateState(tempId) { state in + state.agentProvider = snapProvider state.model = snapModel state.effort = snapEffort state.permissionMode = snapPermission @@ -1271,7 +1570,16 @@ final class AppState { // otherwise race the insertion at line ~1168 and bail with "no summary". if isNewSession { let initialTitle = ChatSession.placeholderTitle(from: displayText ?? prompt) - let placeholder = ChatSession(id: sessionKey, projectId: project.id, title: initialTitle, messages: [], origin: .cliBacked) + let provider = window.sessionAgentProvider ?? selectedAgentProvider + let placeholder = ChatSession( + id: sessionKey, + projectId: project.id, + title: initialTitle, + messages: [], + agentProvider: provider, + model: window.sessionModel ?? selectedModel, + origin: provider == .codex ? .codexAppServer : .cliBacked + ) allSessionSummaries.insert(placeholder.summary, at: 0) threadStore.upsert(placeholder.summary) } @@ -1309,8 +1617,11 @@ final class AppState { // ExitPlanMode is always exempt from auto-approve (see PermissionServer.autoApproveReason), // so the plan card still surfaces. let hookSessionMode = basePermissionMode + let launchAgentProvider = sessionStates[sessionKey]?.agentProvider + ?? window.sessionAgentProvider + ?? selectedAgentProvider var hookSettingsPath: String? - if !cliPermissionMode.skipsHookPipeline { + if launchAgentProvider == .claudeCode && !cliPermissionMode.skipsHookPipeline { do { hookSettingsPath = try await permission.writeHookSettingsFile() } catch { @@ -1330,6 +1641,10 @@ final class AppState { let effectiveCwd = sessionStates[sessionKey]?.worktreePath ?? allSessionSummaries.first(where: { $0.id == sessionKey })?.worktreePath ?? project.path + let effectiveProvider = sessionStates[sessionKey]?.agentProvider + ?? window.sessionAgentProvider + ?? selectedAgentProvider + let effectiveModel = window.sessionModel ?? selectedModel let task = Task { [weak self, window] in guard let self else { return } @@ -1339,7 +1654,8 @@ final class AppState { cwd: effectiveCwd, cliSessionId: cliSessionId, internalSessionKey: sessionKey, - model: window.sessionModel ?? self.selectedModel, + agentProvider: effectiveProvider, + model: effectiveModel, effort: window.sessionEffort ?? (self.selectedEffort == "auto" ? nil : self.selectedEffort), hookSettingsPath: hookSettingsPath, permissionMode: cliPermissionMode, @@ -1428,6 +1744,7 @@ final class AppState { cwd: String, cliSessionId: String?, internalSessionKey: String, + agentProvider: AgentProvider, model: String?, effort: String? = nil, hookSettingsPath: String?, @@ -1445,16 +1762,30 @@ final class AppState { var sessionKey = internalSessionKey - let stream = await claude.send( - streamId: streamId, - prompt: prompt, - cwd: cwd, - sessionId: cliSessionId, - model: model, - effort: effort, - hookSettingsPath: hookSettingsPath, - permissionMode: permissionMode - ) + let stream: AsyncStream + switch agentProvider { + case .claudeCode: + stream = await claude.send( + streamId: streamId, + prompt: prompt, + cwd: cwd, + sessionId: cliSessionId, + model: model, + effort: effort, + hookSettingsPath: hookSettingsPath, + permissionMode: permissionMode + ) + case .codex: + stream = await codex.send( + streamId: streamId, + prompt: prompt, + cwd: cwd, + threadId: cliSessionId, + model: model, + permissionMode: registerMode, + permissionServer: permission + ) + } startFlushTimer(for: sessionKey) @@ -1474,11 +1805,10 @@ final class AppState { let ownsSession = stateForSession(sessionKey).activeStreamId == streamId - if !ownsSession { + if !ownsSession { if case .result(let resultEvent) = event { logger.info("[Stream:UI] event #\(eventCount) .result received after losing ownership — saving to disk") - await claude.closeStdin(streamId: streamId) - await claude.finalize(streamId: streamId) + await finalizeAgentStream(agentProvider: agentProvider, streamId: streamId) if sessionKey != resultEvent.sessionId { if let state = sessionStates.removeValue(forKey: sessionKey) { sessionStates[resultEvent.sessionId] = state @@ -1544,6 +1874,10 @@ final class AppState { messages: [], createdAt: old.createdAt, updatedAt: old.createdAt, + agentProvider: old.agentProvider, + model: old.model, + effort: old.effort, + permissionMode: old.permissionMode, origin: old.origin ) allSessionSummaries.removeAll { $0.id == expectedPlaceholder || $0.id == sid } @@ -1595,6 +1929,7 @@ final class AppState { createdAt: old.createdAt, updatedAt: old.updatedAt, isPinned: old.isPinned, + agentProvider: old.agentProvider, model: old.model, effort: old.effort, permissionMode: old.permissionMode, @@ -1619,6 +1954,7 @@ final class AppState { createdAt: firstUserDate, updatedAt: firstUserDate, isPinned: false, + agentProvider: agentProvider, origin: .cliBacked ) allSessionSummaries.insert(inserted, at: 0) @@ -1636,6 +1972,12 @@ final class AppState { case .assistant(let assistantMessage): logger.debug("[Stream:UI] event #\(eventCount) .assistant (gap=\(String(format: "%.1f", gap))s, blocks=\(assistantMessage.content.count))") + if assistantMessage.content.contains(where: { + if case .thinking = $0 { return true } + return false + }) { + updateState(sessionKey) { $0.isThinking = true } + } // A turn can contain several model invocations (one per tool round-trip); // each emits its own `usage.output_tokens` starting from zero. Track the // running max per message id and sum across ids to get the turn total. @@ -1648,6 +1990,10 @@ final class AppState { state.currentTurnOutputTokensUnkeyed = max(state.currentTurnOutputTokensUnkeyed, liveOutput) } } + if agentProvider == .codex { + let total = stateForSession(sessionKey).currentTurnOutputTokens + logger.info("[Stream:UI] Codex usage applied messageId=\(assistantMessage.id ?? "", privacy: .public) output=\(liveOutput) total=\(total)") + } } // Extract text only when no text_delta has been received in the current turn. // Normally content_block_delta(text_delta) is the primary path, so this branch rarely executes. @@ -1679,8 +2025,7 @@ final class AppState { // With `--input-format stream-json` the CLI stays alive waiting for more // input. Close stdin on `result` so it exits cleanly, then finalize so // any subagent children that survived the parent CLI get reaped. - await claude.closeStdin(streamId: streamId) - await claude.finalize(streamId: streamId) + await finalizeAgentStream(agentProvider: agentProvider, streamId: streamId) if sessionKey != resultEvent.sessionId { if let state = sessionStates.removeValue(forKey: sessionKey) { @@ -1708,7 +2053,8 @@ final class AppState { if isFg { window.currentSessionId = resultEvent.sessionId if resultEvent.isError { - let errText = await claude.consumeStderr(for: streamId) ?? "Claude returned an error." + let errText = await consumeAgentStderr(agentProvider: agentProvider, streamId: streamId) + ?? "\(agentProvider.displayName) returned an error." addErrorMessage(errText, in: window) } } @@ -1719,16 +2065,20 @@ final class AppState { messages: stateForSession(sessionKey).messages ) - reconcileFromDisk(sessionId: resultEvent.sessionId, projectId: projectId, cwd: cwd) + if agentProvider == .claudeCode { + reconcileFromDisk(sessionId: resultEvent.sessionId, projectId: projectId, cwd: cwd) + } if !resultEvent.isError { let sid = resultEvent.sessionId let key = sessionKey let cwdCapture = cwd - Task { [weak self] in - guard let self else { return } - if let pct = await claude.fetchContextPercentage(sessionId: sid, cwd: cwdCapture) { - updateState(key) { $0.lastTurnContextUsedPercentage = pct } + if agentProvider == .claudeCode { + Task { [weak self] in + guard let self else { return } + if let pct = await claude.fetchContextPercentage(sessionId: sid, cwd: cwdCapture) { + updateState(key) { $0.lastTurnContextUsedPercentage = pct } + } } } @@ -1776,7 +2126,7 @@ final class AppState { // Consume any remaining stderr — used as error message content below. // If already consumed at result.isError time, this returns nil. - let stderrOutput = await claude.consumeStderr(for: streamId) + let stderrOutput = await consumeAgentStderr(agentProvider: agentProvider, streamId: streamId) if eventCount == 0 { // User cancellation revokes activeStreamId or cancels the task — distinguish @@ -1831,6 +2181,25 @@ final class AppState { } } + private func finalizeAgentStream(agentProvider: AgentProvider, streamId: UUID) async { + switch agentProvider { + case .claudeCode: + await claude.closeStdin(streamId: streamId) + await claude.finalize(streamId: streamId) + case .codex: + await codex.finalize(streamId: streamId) + } + } + + private func consumeAgentStderr(agentProvider: AgentProvider, streamId: UUID) async -> String? { + switch agentProvider { + case .claudeCode: + return await claude.consumeStderr(for: streamId) + case .codex: + return await codex.consumeStderr(for: streamId) + } + } + // MARK: - Text Delta Throttle (50ms) private func startFlushTimer(for sessionKey: String) { @@ -1872,15 +2241,20 @@ final class AppState { state.pendingToolResults.removeAll(keepingCapacity: true) if let idx = lastAssistantIdx() { for (toolUseId, content, isError) in results { - let editPersistInfo: (path: String, hunks: [PreviewFile.EditHunk], isWrite: Bool)? = { + let editPersistInfos: [(path: String, hunks: [PreviewFile.EditHunk], isWrite: Bool)] = { guard !isError, let blockIdx = state.messages[idx].toolCallIndex(id: toolUseId), let call = state.messages[idx].blocks[blockIdx].toolCall, - ["edit", "multiedit", "multi_edit", "write"].contains(call.name.lowercased()), - let path = call.editedFilePath else { return nil } - let hunks = call.fileEditHunks - guard !hunks.isEmpty else { return nil } - return (path, hunks, call.name.lowercased() == "write") + ["edit", "multiedit", "multi_edit", "write"].contains(call.name.lowercased()) + else { return [] } + let claudeHunks = call.fileEditHunks + if !claudeHunks.isEmpty, let path = call.editedFilePath { + return [(path, claudeHunks, call.name.lowercased() == "write")] + } + // Codex `fileChange` shape: one tool call may touch multiple files. + let codexDiffs = call.fileChangeDiffs + guard !codexDiffs.isEmpty else { return [] } + return codexDiffs.map { ($0.path, [$0.hunk], false) } }() // Preserve the user-decision summary on ExitPlanMode. After the user // accepts/rejects the plan, the CLI emits its own follow-up tool_result @@ -1897,13 +2271,15 @@ final class AppState { if !skipResultOverwrite { state.messages[idx].setToolResult(id: toolUseId, result: content, isError: isError) } - if let info = editPersistInfo { - threadStore.appendFileEdit( - sessionId: key, - path: info.path, - hunks: info.hunks, - containsWrite: info.isWrite - ) + if !editPersistInfos.isEmpty { + for info in editPersistInfos { + threadStore.appendFileEdit( + sessionId: key, + path: info.path, + hunks: info.hunks, + containsWrite: info.isWrite + ) + } threadFileEditsRevision &+= 1 } } @@ -2069,7 +2445,13 @@ final class AppState { sessionStates[key]?.activeStreamId = nil if let streamToCancel { - await claude.cancel(streamId: streamToCancel) + let provider = sessionStates[key]?.agentProvider ?? window.sessionAgentProvider ?? selectedAgentProvider + switch provider { + case .claudeCode: + await claude.cancel(streamId: streamToCancel) + case .codex: + await codex.cancel(streamId: streamToCancel) + } } flushPendingUpdates(for: key) @@ -2339,7 +2721,20 @@ final class AppState { let state = sessionStates[currentId], !state.messages.isEmpty { let title = allSessionSummaries.first(where: { $0.id == currentId })?.title ?? "Session" - let session = ChatSession(id: currentId, projectId: currentProject.id, title: title, messages: state.messages, updatedAt: lastResponseDate(from: state.messages)) + let provider = state.agentProvider ?? allSessionSummaries.first(where: { $0.id == currentId })?.agentProvider ?? selectedAgentProvider + let origin = allSessionSummaries.first(where: { $0.id == currentId })?.origin ?? (provider == .codex ? .codexAppServer : .cliBacked) + let session = ChatSession( + id: currentId, + projectId: currentProject.id, + title: title, + messages: state.messages, + updatedAt: lastResponseDate(from: state.messages), + agentProvider: provider, + model: state.model, + effort: state.effort, + permissionMode: state.permissionMode, + origin: origin + ) Task { do { try await self.persistence.saveSession(session) } catch { self.logger.error("Failed to save current session before project switch: \(error.localizedDescription)") } @@ -2418,6 +2813,7 @@ final class AppState { if sessionStates[session.id] == nil { var state = SessionStreamState() + state.agentProvider = session.agentProvider state.model = session.model state.effort = session.effort state.permissionMode = session.permissionMode @@ -2441,6 +2837,7 @@ final class AppState { let project = window.selectedProject { if var state = sessionStates[session.id] { if state.model == nil { state.model = session.model } + if state.agentProvider == nil { state.agentProvider = session.agentProvider } if state.effort == nil { state.effort = session.effort } if state.permissionMode == nil { state.permissionMode = session.permissionMode } state.isLoadingFromDisk = true @@ -2459,6 +2856,7 @@ final class AppState { updateState(session.id) { $0.hasUncheckedCompletion = false } window.currentSessionId = session.id + window.sessionAgentProvider = sessionStates[session.id]?.agentProvider ?? session.agentProvider window.sessionModel = sessionStates[session.id]?.model ?? session.model window.sessionEffort = sessionStates[session.id]?.effort ?? session.effort window.sessionPermissionMode = sessionStates[session.id]?.permissionMode ?? session.permissionMode @@ -2482,7 +2880,21 @@ final class AppState { guard let self else { return } if !outgoingMessages.isEmpty, let project = window.selectedProject { let title = allSessionSummaries.first(where: { $0.id == outgoingId })?.title ?? "Session" - let outgoing = ChatSession(id: outgoingId, projectId: project.id, title: title, messages: outgoingMessages, updatedAt: lastResponseDate(from: outgoingMessages)) + let state = sessionStates[outgoingId] + let provider = state?.agentProvider ?? allSessionSummaries.first(where: { $0.id == outgoingId })?.agentProvider ?? selectedAgentProvider + let origin = allSessionSummaries.first(where: { $0.id == outgoingId })?.origin ?? (provider == .codex ? .codexAppServer : .cliBacked) + let outgoing = ChatSession( + id: outgoingId, + projectId: project.id, + title: title, + messages: outgoingMessages, + updatedAt: lastResponseDate(from: outgoingMessages), + agentProvider: provider, + model: state?.model, + effort: state?.effort, + permissionMode: state?.permissionMode, + origin: origin + ) do { try await persistence.saveSession(outgoing) } catch { logger.error("Failed to save outgoing session: \(error.localizedDescription)") } } @@ -2568,6 +2980,7 @@ final class AppState { saveQueue(in: window) releaseOutgoingSession(window.currentSessionId, in: window) window.currentSessionId = nil + window.sessionAgentProvider = nil window.sessionModel = nil window.sessionEffort = nil window.sessionPermissionMode = nil @@ -2610,9 +3023,9 @@ final class AppState { return current } - /// Spawn a one-shot Claude call to generate a 3–6 word title for the given session, - /// then persist it via `renameSession` if the title is still the placeholder. No-op - /// if the session was already renamed manually or the LLM call fails. + /// Spawn a one-shot summarization call to generate a 3–6 word title for the given + /// session, then persist it via `renameSession` if the title is still the placeholder. + /// No-op if the session was already renamed manually or the LLM call fails. func maybeGenerateLLMTitle(for sessionId: String) async { let resolved = resolveCurrentSessionId(sessionId) guard let summary = allSessionSummaries.first(where: { $0.id == resolved }) else { @@ -2624,7 +3037,7 @@ final class AppState { let firstUser = ChatSession.stripAttachmentMarkers(from: firstUserRaw) guard !firstUser.isEmpty else { return } guard isAutoGeneratedTitle(summary.title, firstUserMessage: firstUserRaw) else { return } - guard let title = await claude.generateSessionTitle(firstUserMessage: firstUser) else { return } + guard let title = await generateSessionTitle(firstUserMessage: firstUser, summary: summary) else { return } // Re-resolve after the LLM call — the id may have been swapped while we waited. let currentId = resolveCurrentSessionId(sessionId) guard let stillPlaceholder = allSessionSummaries.first(where: { $0.id == currentId }), @@ -2640,6 +3053,55 @@ final class AppState { await renameSession(session, to: title) } + private func generateSessionTitle(firstUserMessage: String, summary: ChatSession.Summary) async -> String? { + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateSessionTitle(firstUserMessage: firstUserMessage, provider: provider, model: model) + case .claudeCode: + return await generateSessionTitle( + firstUserMessage: firstUserMessage, + provider: .claudeCode, + model: selectedSummarizationModel(for: .claudeCode) + ) + case .codex: + return await generateSessionTitle( + firstUserMessage: firstUserMessage, + provider: .codex, + model: selectedSummarizationModel(for: .codex) + ) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateSessionTitle( + firstUserMessage: firstUserMessage, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + } + } + + private func generateSessionTitle(firstUserMessage: String, provider: AgentProvider, model: String?) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateSessionTitle(firstUserMessage: firstUserMessage, model: model ?? "haiku") + case .codex: + return await codex.generateSessionTitle(firstUserMessage: firstUserMessage, model: model) + } + } + + private func selectedSummarizationModel(for provider: AgentProvider) -> String? { + if selectedAgentProvider == provider { + return selectedModel + } + return availableAgentModelSections() + .first(where: { $0.provider == provider })? + .models + .first? + .id + } + func togglePinSession(_ session: ChatSession) async { guard let si = allSessionSummaries.firstIndex(where: { $0.id == session.id }) else { return } allSessionSummaries[si].isPinned.toggle() @@ -2742,6 +3204,7 @@ final class AppState { let updated = (snap ?? ChatSession.Summary( id: sessionId, projectId: project.id, title: ChatSession.defaultTitle, createdAt: Date(), updatedAt: Date(), isPinned: false, + agentProvider: sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider, worktreePath: info.path.path, worktreeBranch: info.branch )).makeSession() await updateSessionMetadata(updated) { s in @@ -2793,7 +3256,7 @@ final class AppState { var updated: ChatSession = switch summary.origin { case .cliBacked: summary.makeSession() - case .legacyRxCode: + case .legacyRxCode, .codexAppServer: persistence.loadLegacySessionSync(projectId: session.projectId, sessionId: session.id) ?? session } mutate(&updated) @@ -3068,7 +3531,8 @@ final class AppState { ?? ChatSession.Summary( id: sessionId, projectId: projectId, title: "", createdAt: Date(), updatedAt: Date(), isPinned: false, - origin: .cliBacked + agentProvider: sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider, + origin: (sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider) == .codex ? .codexAppServer : .cliBacked ) } @@ -3179,10 +3643,26 @@ final class AppState { } let sessionModel = sessionStates[sessionId]?.model + ?? allSessionSummaries.first(where: { $0.id == sessionId })?.model + let sessionAgentProvider = sessionStates[sessionId]?.agentProvider + ?? allSessionSummaries.first(where: { $0.id == sessionId })?.agentProvider + ?? selectedAgentProvider let sessionEffort = sessionStates[sessionId]?.effort let sessionPermissionMode = sessionStates[sessionId]?.permissionMode - let origin = allSessionSummaries.first(where: { $0.id == sessionId })?.origin ?? .cliBacked - let session = ChatSession(id: sessionId, projectId: projectId, title: title, messages: messages, updatedAt: lastResponseDate(from: messages), model: sessionModel, effort: sessionEffort, permissionMode: sessionPermissionMode, origin: origin) + let origin = allSessionSummaries.first(where: { $0.id == sessionId })?.origin + ?? (sessionAgentProvider == .codex ? .codexAppServer : .cliBacked) + let session = ChatSession( + id: sessionId, + projectId: projectId, + title: title, + messages: messages, + updatedAt: lastResponseDate(from: messages), + agentProvider: sessionAgentProvider, + model: sessionModel, + effort: sessionEffort, + permissionMode: sessionPermissionMode, + origin: origin + ) do { try await persistence.saveSession(session) @@ -3359,8 +3839,9 @@ final class AppState { await permission.refreshRunToken() let currentPermissionMode = sessionStates[sessionKey]?.permissionMode ?? permissionMode + let agentProvider = sessionStates[sessionKey]?.agentProvider ?? selectedAgentProvider var hookSettingsPath: String? - if !currentPermissionMode.skipsHookPipeline { + if agentProvider == .claudeCode && !currentPermissionMode.skipsHookPipeline { do { hookSettingsPath = try await permission.writeHookSettingsFile() } catch { logger.error("Failed to write hook settings for background queue: \(error.localizedDescription)") } } @@ -3377,6 +3858,7 @@ final class AppState { cwd: cwd, cliSessionId: sessionKey, internalSessionKey: sessionKey, + agentProvider: agentProvider, model: model, effort: effort, hookSettingsPath: hookSettingsPath, diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 8bd5db22..ce7093f3 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -103,11 +103,11 @@ private struct MenuBarLabel: View { var body: some View { let inProgress = appState.inProgressSessionCount - let unchecked = appState.uncheckedFinishedSessionCount - let totalJobs = inProgress + unchecked - let fiveHour = appState.latestRateLimitUsage?.fiveHourPercent + let provider = appState.selectedAgentProvider + let usage = provider == .codex ? appState.latestCodexRateLimitUsage : appState.latestRateLimitUsage + let fiveHour = usage?.fiveHourPercent - if let image = Self.renderLabelImage(fiveHour: fiveHour, inProgress: inProgress, totalJobs: totalJobs) { + if let image = Self.renderLabelImage(agentText: Self.agentText(for: provider), fiveHour: fiveHour, inProgress: inProgress) { Image(nsImage: image) } else { Image(systemName: "message") @@ -115,10 +115,11 @@ private struct MenuBarLabel: View { } @MainActor - private static func renderLabelImage(fiveHour: Double?, inProgress: Int, totalJobs: Int) -> NSImage? { + private static func renderLabelImage(agentText: String, fiveHour: Double?, inProgress: Int) -> NSImage? { let content = MenuBarLabelContent( + agentText: agentText, fiveHourText: fiveHour.map { "\(formatPercent($0))%" } ?? "—%", - jobsText: inProgress > 0 ? "\(inProgress) Job\(inProgress == 1 ? "" : "s")" : nil + statusText: inProgress > 0 ? "\(inProgress)job\(inProgress == 1 ? "" : "s")" : "IDLE" ) let renderer = ImageRenderer(content: content) renderer.scale = NSScreen.main?.backingScaleFactor ?? 2 @@ -130,6 +131,13 @@ private struct MenuBarLabel: View { return image } + private static func agentText(for provider: AgentProvider) -> String { + switch provider { + case .claudeCode: return "CC" + case .codex: return "CODEX" + } + } + private static func formatPercent(_ value: Double) -> String { if value > 0 && value < 1 { return String(format: "%.1f", value) @@ -139,32 +147,50 @@ private struct MenuBarLabel: View { } private struct MenuBarLabelContent: View { + private static let textSize: CGFloat = 9 + + let agentText: String let fiveHourText: String - let jobsText: String? + let statusText: String var body: some View { - HStack(spacing: 6) { - Image(systemName: "message") - .font(.system(size: 11, weight: .medium)) - - VStack(alignment: .trailing, spacing: -1) { - HStack(alignment: .bottom, spacing: 1) { - Text(fiveHourText) - .font(.system(size: 11, weight: .semibold)) - .monospacedDigit() - Text("5h") - .font(.system(size: 7, weight: .medium)) - } - if let jobsText { - Text(jobsText) - .font(.system(size: 8, weight: .semibold)) - .monospacedDigit() - } + HStack(alignment: .center, spacing: 8) { + Text(agentText) + .font(.system(size: Self.textSize, weight: .bold)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .fixedSize(horizontal: true, vertical: false) + .frame(height: 18, alignment: .center) + + VStack(alignment: .leading, spacing: -1) { + usageLine + Text(statusText) + .font(.system(size: Self.textSize, weight: .semibold)) + .monospacedDigit() + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } + .fixedSize(horizontal: true, vertical: false) } .padding(.vertical, 1) + .fixedSize(horizontal: true, vertical: true) .foregroundStyle(.black) } + + private var usageLine: some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(fiveHourText) + .font(.system(size: Self.textSize, weight: .semibold)) + .monospacedDigit() + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + Text("5h") + .font(.system(size: Self.textSize, weight: .medium)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .fixedSize(horizontal: true, vertical: false) + } } // MARK: - Menu Bar Content @@ -173,21 +199,52 @@ private struct MenuBarContentView: View { @Environment(AppState.self) private var appState @State private var isRefreshing = false + private var selectedUsage: RateLimitUsage? { + switch appState.selectedAgentProvider { + case .claudeCode: return appState.latestRateLimitUsage + case .codex: return appState.latestCodexRateLimitUsage + } + } + + private var secondaryLimitLabel: String { + switch appState.selectedAgentProvider { + case .claudeCode: return "7-day limit" + case .codex: return "24-hour limit" + } + } + + private var secondaryLimitPercent: Double? { + switch appState.selectedAgentProvider { + case .claudeCode: return selectedUsage?.sevenDayPercent + case .codex: return selectedUsage?.twentyFourHourPercent + } + } + + private var secondaryLimitResetsAt: Date? { + switch appState.selectedAgentProvider { + case .claudeCode: return selectedUsage?.sevenDayResetsAt + case .codex: return selectedUsage?.twentyFourHourResetsAt + } + } + var body: some View { VStack(alignment: .leading, spacing: 14) { header + agentPicker VStack(alignment: .leading, spacing: 12) { MenuBarUsageBar( label: "5-hour limit", - percent: appState.latestRateLimitUsage?.fiveHourPercent, - resetsAt: appState.latestRateLimitUsage?.fiveHourResetsAt + percent: selectedUsage?.fiveHourPercent, + resetsAt: selectedUsage?.fiveHourResetsAt, + emptyText: emptyUsageText ) MenuBarUsageBar( - label: "7-day limit", - percent: appState.latestRateLimitUsage?.sevenDayPercent, - resetsAt: appState.latestRateLimitUsage?.sevenDayResetsAt + label: secondaryLimitLabel, + percent: secondaryLimitPercent, + resetsAt: secondaryLimitResetsAt, + emptyText: emptyUsageText ) } @@ -202,13 +259,16 @@ private struct MenuBarContentView: View { .padding(14) .frame(width: 280) .task { - await appState.refreshRateLimitUsage() + await appState.refreshSelectedAgentRateLimitUsage() + } + .onChange(of: appState.selectedAgentProvider) { + Task { await appState.refreshSelectedAgentRateLimitUsage() } } } private var header: some View { HStack { - Text("Claude Usage") + Text("\(appState.selectedAgentProvider.displayName) Usage") .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -219,7 +279,7 @@ private struct MenuBarContentView: View { guard !isRefreshing else { return } isRefreshing = true Task { - await appState.refreshRateLimitUsage(forceRefresh: true) + await appState.refreshSelectedAgentRateLimitUsage(forceRefresh: true) isRefreshing = false } } label: { @@ -232,6 +292,29 @@ private struct MenuBarContentView: View { } } + private var agentPicker: some View { + Picker("Client", selection: Binding( + get: { appState.selectedAgentProvider }, + set: { provider in + appState.setDefaultAgentProvider(provider) + Task { await appState.refreshSelectedAgentRateLimitUsage() } + } + )) { + ForEach(AgentProvider.allCases, id: \.self) { provider in + Text(provider.displayName) + .tag(provider) + } + } + .pickerStyle(.segmented) + } + + private var emptyUsageText: String { + switch appState.selectedAgentProvider { + case .claudeCode: return "Sign in to Claude Code to see usage" + case .codex: return "Sign in to Codex to see usage" + } + } + private var chatActivitySection: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { @@ -289,6 +372,7 @@ private struct MenuBarUsageBar: View { let label: String let percent: Double? let resetsAt: Date? + let emptyText: String var body: some View { VStack(alignment: .leading, spacing: 5) { @@ -330,7 +414,7 @@ private struct MenuBarUsageBar: View { .font(.system(size: ClaudeTheme.size(10))) .foregroundStyle(.tertiary) } else if percent == nil { - Text("Sign in to Claude Code to see usage") + Text(emptyText) .font(.system(size: ClaudeTheme.size(10))) .foregroundStyle(.tertiary) } diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index 6e33aaa2..f5386509 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -2,13 +2,13 @@ import Foundation import RxCodeCore import os -// MARK: - ClaudeService +// MARK: - ClaudeCodeServer /// Manages the Claude Code CLI process lifecycle and NDJSON streaming. /// /// Spawns the `claude` binary with stream-json I/O, reads stdout as an /// ``AsyncStream``, and writes user messages to stdin in NDJSON format. -actor ClaudeService { +actor ClaudeCodeServer { // MARK: - State @@ -33,7 +33,7 @@ actor ClaudeService { private let cliStore: CLISessionStore private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.claudework", - category: "ClaudeService" + category: "ClaudeCodeServer" ) init(cliStore: CLISessionStore) { @@ -263,7 +263,7 @@ actor ClaudeService { /// Uses a one-shot `claude -p` invocation with Haiku — runs outside of the streaming /// pipeline and does NOT hit the PermissionServer hook (no `--settings` passed). /// Returns nil on any failure; callers should keep the placeholder title in that case. - func generateSessionTitle(firstUserMessage: String) async -> String? { + func generateSessionTitle(firstUserMessage: String, model: String = "claude-haiku-4-5-20251001") async -> String? { guard let binary = await findClaudeBinary() else { return nil } let trimmedUser = String(firstUserMessage.prefix(500)) let prompt = """ @@ -276,7 +276,7 @@ actor ClaudeService { // user's broken tool schema (e.g. an MCP server returning invalid JSON schema) // can't blow up the title call with "API Error: 400 tools.NN.custom.input_schema". let emptyMCPConfigPath = writeEmptyMCPConfig() - var args: [String] = ["-p", prompt, "--output-format", "text", "--model", "claude-haiku-4-5-20251001"] + var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] if let emptyMCPConfigPath { args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) } @@ -908,3 +908,5 @@ actor ClaudeService { stdinHandles.removeAll() } } + +typealias ClaudeService = ClaudeCodeServer diff --git a/RxCode/Services/CodexAppServer.swift b/RxCode/Services/CodexAppServer.swift new file mode 100644 index 00000000..485000ca --- /dev/null +++ b/RxCode/Services/CodexAppServer.swift @@ -0,0 +1,1060 @@ +import Foundation +import RxCodeCore +import os + +actor CodexAppServer { + enum CodexError: LocalizedError { + case binaryNotFound + case versionCheckFailed(String) + case spawnFailed(String) + + var errorDescription: String? { + switch self { + case .binaryNotFound: + return "Could not find the codex CLI binary." + case .versionCheckFailed(let detail): + return "Version check failed: \(detail)" + case .spawnFailed(let detail): + return "Failed to spawn codex app-server: \(detail)" + } + } + } + + private struct RunningProcess { + let process: Process + let stdin: FileHandle + } + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.claudework", + category: "CodexAppServer" + ) + private var running: [UUID: RunningProcess] = [:] + private var stderrBuffers: [UUID: String] = [:] + private var cachedShellPath: String? + private var cachedRateLimits: RateLimitUsage? + private var cachedRateLimitsAt: Date? + private var rateLimitsFetchTask: Task? + private let rateLimitsCacheTTL: TimeInterval = 300 + private var notificationMethodCounts: [String: Int] = [:] + + private static var candidatePaths: [String] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return [ + "/opt/homebrew/bin/codex", + "/usr/local/bin/codex", + "\(home)/.local/bin/codex", + "\(home)/.npm-global/bin/codex", + "\(home)/.nvm/versions/node", + ] + } + + func findCodexBinary() async -> String? { + let fm = FileManager.default + for path in Self.candidatePaths { + if path.hasSuffix("/node") { + if let found = findNvmCodexBinary(root: path) { return found } + continue + } + let resolved = (path as NSString).resolvingSymlinksInPath + if fm.fileExists(atPath: resolved) && fm.isExecutableFile(atPath: path) { + logger.info("Found codex binary at \(path, privacy: .public)") + return path + } + } + + do { + let output = try await runShellCommand("/bin/zsh", arguments: ["-ilc", "whence -p codex"]) + let path = output.trimmingCharacters(in: .whitespacesAndNewlines) + if !path.isEmpty, fm.isExecutableFile(atPath: path) { + logger.info("Found codex binary via shell at \(path, privacy: .public)") + return path + } + } catch { + logger.warning("Shell fallback failed while locating codex: \(error.localizedDescription)") + } + return nil + } + + func checkVersion() async throws -> String { + guard let binary = await findCodexBinary() else { throw CodexError.binaryNotFound } + let output = try await runShellCommand(binary, arguments: ["--version"]) + let version = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !version.isEmpty else { throw CodexError.versionCheckFailed("Empty version output") } + return version + } + + func fetchModels() async -> [AgentModel] { + guard let binary = await findCodexBinary() else { + logger.warning("Skipping Codex model fetch because no codex binary was found") + return [] + } + do { + let streamId = UUID() + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: nil) + defer { finalize(streamId: streamId) } + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "model/list", params: ["includeHidden": .bool(false)]), to: handles.stdin) + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line), + Self.idString(object["id"]) == "2", + let result = object["result"] else { continue } + let models = Self.parseModels(from: result) + logger.info("Codex app-server model/list returned \(models.count) models") + if !models.isEmpty { return models } + logger.warning("Codex app-server model/list returned no parseable models; trying codex debug models") + break + } + } catch { + logger.warning("Codex model/list failed: \(error.localizedDescription)") + } + let debugModels = await fetchModelsFromDebugCommand(binary: binary) + if !debugModels.isEmpty { + logger.info("Codex debug models returned \(debugModels.count) models") + } else { + logger.warning("Codex debug models returned no parseable models; UI will use built-in fallback models") + } + return debugModels + } + + func fetchRateLimits(forceRefresh: Bool = false) async -> RateLimitUsage? { + if !forceRefresh, + let cachedRateLimits, + let cachedRateLimitsAt, + Date().timeIntervalSince(cachedRateLimitsAt) < rateLimitsCacheTTL { + return cachedRateLimits + } + + if let rateLimitsFetchTask { + return await rateLimitsFetchTask.value ?? cachedRateLimits + } + + let task = Task { await self.fetchRateLimitsUncached() } + rateLimitsFetchTask = task + let usage = await task.value + rateLimitsFetchTask = nil + + if let usage { + cachedRateLimits = usage + cachedRateLimitsAt = Date() + return usage + } + return cachedRateLimits + } + + func generateSessionTitle(firstUserMessage: String, model: String?) async -> String? { + guard let binary = await findCodexBinary() else { return nil } + let trimmedUser = String(firstUserMessage.prefix(500)) + let prompt = """ + Summarize the following user message as a 3-6 word chat title. Reply with ONLY the title, no quotes, no markdown, no punctuation at the end. + + \(trimmedUser) + """ + + let streamId = UUID() + var title = "" + + do { + let cwd = FileManager.default.homeDirectoryForCurrentUser.path + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) + defer { finalize(streamId: streamId) } + + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + + var activeThreadId: String? + var turnStarted = false + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line) else { continue } + + if let id = Self.idString(object["id"]), object["method"] == nil { + switch id { + case "1": + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) + case "2": + if let result = object["result"] { + activeThreadId = Self.threadId(from: result) ?? UUID().uuidString + } + if let activeThreadId, !turnStarted { + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model)), to: handles.stdin) + turnStarted = true + } + default: + break + } + continue + } + + guard let method = object["method"]?.stringValue else { continue } + if let requestId = Self.idString(object["id"]) { + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) + continue + } + + let params = object["params"]?.objectValue ?? [:] + switch method { + case "item/agentMessage/delta", "item/agent_message/delta": + title += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" + case "turn/completed": + return cleanTitle(title) + case "turn/failed", "error": + return nil + default: + break + } + } + } catch { + logger.warning("Codex title generation failed: \(error.localizedDescription)") + } + return cleanTitle(title) + } + + private func fetchRateLimitsUncached() async -> RateLimitUsage? { + guard let binary = await findCodexBinary() else { + logger.warning("Skipping Codex rate-limit fetch because no codex binary was found") + return nil + } + do { + let streamId = UUID() + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: nil) + defer { finalize(streamId: streamId) } + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "account/rateLimits/read", params: .null), to: handles.stdin) + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line) else { continue } + + if let requestId = Self.idString(object["id"]), object["method"] != nil { + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) + continue + } + + if Self.idString(object["id"]) == "2", let result = object["result"] { + let usage = Self.parseCodexRateLimits(from: result) + if let usage { + logger.info("Codex rate limits 5h=\(usage.fiveHourPercent)% 24h=\(usage.twentyFourHourPercent ?? 0)%") + } else { + logger.warning("Codex account/rateLimits/read returned no parseable limits") + } + return usage + } + + if object["method"]?.stringValue == "account/rateLimits/updated", + let params = object["params"] { + return Self.parseCodexRateLimits(from: params) + } + } + } catch { + logger.warning("Codex rate-limit fetch failed: \(error.localizedDescription)") + } + return nil + } + + private func fetchModelsFromDebugCommand(binary: String) async -> [AgentModel] { + do { + let output = try await runShellCommand(binary, arguments: ["debug", "models"]) + guard let value = Self.decodeJSONValue(output) else { + logger.warning("Could not decode codex debug models output as JSON") + return [] + } + return Self.parseModels(from: value) + } catch { + logger.warning("Codex debug models failed: \(error.localizedDescription)") + return [] + } + } + + func send( + streamId: UUID, + prompt: String, + cwd: String, + threadId: String?, + model: String?, + permissionMode: PermissionMode, + permissionServer: PermissionServer + ) -> AsyncStream { + AsyncStream { continuation in + let task = Task.detached { [weak self] in + guard let self else { + continuation.finish() + return + } + await self.runTurn( + streamId: streamId, + prompt: prompt, + cwd: cwd, + threadId: threadId, + model: model, + permissionMode: permissionMode, + permissionServer: permissionServer, + continuation: continuation + ) + } + continuation.onTermination = { _ in + task.cancel() + Task { await self.finalize(streamId: streamId) } + } + } + } + + func finalize(streamId: UUID) { + guard let entry = running.removeValue(forKey: streamId) else { return } + try? entry.stdin.close() + if entry.process.isRunning { + entry.process.terminate() + } + } + + func cancel(streamId: UUID) { + finalize(streamId: streamId) + } + + func consumeStderr(for streamId: UUID) -> String? { + let value = stderrBuffers.removeValue(forKey: streamId) + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private func runTurn( + streamId: UUID, + prompt: String, + cwd: String, + threadId: String?, + model: String?, + permissionMode: PermissionMode, + permissionServer: PermissionServer, + continuation: AsyncStream.Continuation + ) async { + do { + guard let binary = await findCodexBinary() else { throw CodexError.binaryNotFound } + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + + var activeThreadId = threadId + var turnStarted = false + var turnCompleted = false + var finalUsage: UsageInfo? + let startedAt = Date() + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard !Task.isCancelled else { break } + guard let object = Self.decodeObject(line) else { continue } + + if let id = Self.idString(object["id"]), object["method"] == nil { + switch id { + case "1": + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + let method = activeThreadId == nil ? "thread/start" : "thread/resume" + try Self.writeJSONLine(Self.request(id: 2, method: method, params: threadParams(threadId: activeThreadId, cwd: cwd)), to: handles.stdin) + case "2": + if let result = object["result"] { + activeThreadId = Self.threadId(from: result) ?? activeThreadId ?? UUID().uuidString + continuation.yield(.system(SystemEvent( + subtype: "init", + sessionId: activeThreadId, + tools: nil, + model: model, + claudeCodeVersion: nil + ))) + } + if let activeThreadId, !turnStarted { + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model)), to: handles.stdin) + turnStarted = true + } + case "3": + break + default: + break + } + continue + } + + if let method = object["method"]?.stringValue { + if let requestId = Self.idString(object["id"]) { + try await handleServerRequest( + requestId: requestId, + method: method, + object: object, + activeThreadId: activeThreadId, + permissionMode: permissionMode, + permissionServer: permissionServer, + stdin: handles.stdin + ) + } else { + handleNotification(method: method, object: object, activeThreadId: activeThreadId, continuation: continuation) + if method == "turn/completed" || method == "turn/failed" { + finalUsage = Self.usageInfo(from: object) ?? finalUsage + turnCompleted = method == "turn/completed" + break + } + } + } + } + + let sid = activeThreadId ?? threadId ?? UUID().uuidString + let duration = Date().timeIntervalSince(startedAt) * 1000 + continuation.yield(.result(ResultEvent( + durationMs: duration, + totalCostUsd: nil, + sessionId: sid, + isError: !turnCompleted, + totalTurns: 1, + usage: finalUsage, + contextWindow: nil + ))) + } catch { + stderrBuffers[streamId, default: ""] += "\n\(error.localizedDescription)" + let sid = threadId ?? "codex-\(streamId.uuidString)" + continuation.yield(.result(ResultEvent( + durationMs: nil, + totalCostUsd: nil, + sessionId: sid, + isError: true, + totalTurns: nil, + usage: nil, + contextWindow: nil + ))) + } + finalize(streamId: streamId) + continuation.finish() + } + + private func handleNotification( + method: String, + object: [String: JSONValue], + activeThreadId: String?, + continuation: AsyncStream.Continuation + ) { + let params = object["params"]?.objectValue ?? [:] + notificationMethodCounts[method, default: 0] += 1 + if notificationMethodCounts[method] == 1 { + logger.info("[CodexAppServer] notification method=\(method, privacy: .public)") + } + + let liveUsage = method == "thread/tokenUsage/updated" + ? Self.liveTokenUsage(from: params) + : nil + if method == "thread/tokenUsage/updated" { + let outputTokens = Self.tokenUsageOutputTokens(from: params) ?? 0 + let tokenUsage = Self.tokenUsageTokensUsed(from: params) ?? 0 + let tokenBudget = Self.tokenUsageTokenBudget(from: params) ?? 0 + logger.info("[CodexAppServer] tokenUsage.updated outputTokens=\(outputTokens) tokensUsed=\(tokenUsage) tokenBudget=\(tokenBudget) parsedOutput=\(liveUsage?.outputTokens ?? 0)") + if liveUsage?.outputTokens ?? 0 == 0 { + logger.info("[CodexAppServer] tokenUsage.payload \(JSONValue.object(params).description, privacy: .public)") + } + } + if let usage = liveUsage ?? Self.usageInfo(from: object) { + continuation.yield(.assistant(AssistantMessage( + id: Self.usageMessageId(from: params, fallback: activeThreadId), + role: "assistant", + content: [], + usage: usage + ))) + } + switch method { + case "thread/started": + let sid = Self.threadId(from: .object(params)) ?? activeThreadId + continuation.yield(.system(SystemEvent(subtype: "init", sessionId: sid, tools: nil, model: nil, claudeCodeVersion: nil))) + case "item/agentMessage/delta", "item/agent_message/delta": + if let text = Self.firstString(in: params, keys: ["delta", "text", "content"]) { + continuation.yield(.unknown(Self.claudeTextDelta(text))) + } + case "item/reasoning/textDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/summaryPartAdded": + let text = Self.firstString(in: params, keys: ["delta", "text", "content", "summary"]) ?? "" + continuation.yield(.unknown(Self.claudeThinkingDelta(text))) + case "item/started": + if let item = params["item"]?.objectValue ?? params["itemInfo"]?.objectValue { + emitToolStart(item: item, continuation: continuation) + } + case "item/completed": + if let item = params["item"]?.objectValue ?? params["itemInfo"]?.objectValue { + emitToolCompletion(item: item, continuation: continuation) + } + case "error", "turn/failed": + if let message = Self.firstString(in: params, keys: ["message", "error"]) { + continuation.yield(.unknown(Self.claudeTextDelta(message))) + } + default: + break + } + } + + private func emitToolStart(item: [String: JSONValue], continuation: AsyncStream.Continuation) { + guard let name = toolName(from: item), name != "message" else { return } + let id = Self.firstString(in: item, keys: ["id", "itemId", "callId"]) ?? UUID().uuidString + let input = item["input"]?.objectValue ?? item["arguments"]?.objectValue ?? item + continuation.yield(.unknown(Self.claudeToolStart(id: id, name: name))) + continuation.yield(.unknown(Self.claudeInputDelta(input))) + continuation.yield(.unknown(Self.claudeContentBlockStop())) + } + + private func emitToolCompletion(item: [String: JSONValue], continuation: AsyncStream.Continuation) { + guard let name = toolName(from: item), name != "message" else { return } + let id = Self.firstString(in: item, keys: ["id", "itemId", "callId"]) ?? UUID().uuidString + let output = Self.firstString(in: item, keys: ["output", "result", "summary", "message"]) ?? "" + let isError = item["error"] != nil || item["isError"]?.boolValue == true + continuation.yield(.user(UserMessage(toolUseId: id, content: output, isError: isError))) + } + + private func handleServerRequest( + requestId: String, + method: String, + object: [String: JSONValue], + activeThreadId: String?, + permissionMode: PermissionMode, + permissionServer: PermissionServer, + stdin: FileHandle + ) async throws { + let params = object["params"]?.objectValue ?? [:] + switch method { + case "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "request/approval": + let toolUseId = Self.firstString(in: params, keys: ["itemId", "callId", "id"]) ?? requestId + let command = Self.firstString(in: params, keys: ["command", "cmd"]) + let toolName = command == nil ? "Edit" : "Bash" + var input = params + if let command { input["command"] = .string(command) } + let decision = await permissionServer.requestDecision( + toolUseId: toolUseId, + sessionId: activeThreadId, + toolName: toolName, + toolInput: input, + mode: permissionMode + ) + let approved = decision == .allow || { + if case .allowAlwaysCommand = decision { return true } + if case .allowSessionTool = decision { return true } + if case .allowAndSetMode = decision { return true } + return false + }() + try Self.writeJSONLine(Self.response(id: requestId, result: [ + "decision": .string(approved ? "accept" : "reject") + ]), to: stdin) + case "userInput/request": + let toolUseId = Self.firstString(in: params, keys: ["itemId", "callId", "id"]) ?? requestId + let decision = await permissionServer.requestDecision( + toolUseId: toolUseId, + sessionId: activeThreadId, + toolName: "AskUserQuestion", + toolInput: params, + mode: permissionMode + ) + try Self.writeJSONLine(Self.response(id: requestId, result: [ + "decision": .string(decision == .allow ? "accept" : "reject") + ]), to: stdin) + default: + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: stdin) + } + } + + private func initializeParams() -> [String: JSONValue] { + [ + "clientInfo": .object([ + "name": .string("RxCode"), + "title": .string("RxCode"), + "version": .string(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0") + ]), + "capabilities": .object([:]) + ] + } + + private func threadParams(threadId: String?, cwd: String) -> [String: JSONValue] { + var params: [String: JSONValue] = ["cwd": .string(cwd)] + if let threadId { params["threadId"] = .string(threadId) } + return params + } + + private func turnParams(threadId: String, prompt: String, cwd: String, model: String?) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "threadId": .string(threadId), + "cwd": .string(cwd), + "input": .array([ + .object(["type": .string("text"), "text": .string(prompt)]) + ]) + ] + if let model { params["model"] = .string(model) } + return params + } + + private func spawnAppServer(binary: String, streamId: UUID, cwd: String?) async throws -> (process: Process, stdin: FileHandle, stdout: Pipe) { + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = ["app-server", "--listen", "stdio://"] + if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } + process.environment = await resolvedEnvironment() + + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + throw CodexError.spawnFailed(error.localizedDescription) + } + + let stdinHandle = stdin.fileHandleForWriting + running[streamId] = RunningProcess(process: process, stdin: stdinHandle) + readStderr(stderr, streamId: streamId) + return (process, stdinHandle, stdout) + } + + private func readStderr(_ stderr: Pipe, streamId: UUID) { + Task.detached { [weak self] in + let data = stderr.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } + await self?.appendStderr(text, streamId: streamId) + } + } + + private func appendStderr(_ text: String, streamId: UUID) { + stderrBuffers[streamId, default: ""] += text + } + + private func findNvmCodexBinary(root: String) -> String? { + let fm = FileManager.default + guard let versions = try? fm.contentsOfDirectory(atPath: root) else { return nil } + for version in versions.sorted(by: >) { + let candidate = "\(root)/\(version)/bin/codex" + let resolved = (candidate as NSString).resolvingSymlinksInPath + if fm.fileExists(atPath: resolved) && fm.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private func resolvedEnvironment() async -> [String: String] { + var env = ProcessInfo.processInfo.environment + if let cachedShellPath { + env["PATH"] = cachedShellPath + return env + } + let rawShellPath = try? await runShellCommand("/bin/zsh", arguments: ["-ilc", "print -rn -- $PATH"], injectPath: false) + let shellPath = rawShellPath?.trimmingCharacters(in: .whitespacesAndNewlines) + if let shellPath, !shellPath.isEmpty { + cachedShellPath = shellPath + env["PATH"] = shellPath + } + return env + } + + private func runShellCommand(_ executable: String, arguments: [String], injectPath: Bool = true) async throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if injectPath { + process.environment = await resolvedEnvironment() + } + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + let out = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if process.terminationStatus != 0 { + let err = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw CodexError.versionCheckFailed(err.isEmpty ? out : err) + } + return out + } + + private func cleanTitle(_ raw: String) -> String? { + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "codex error"] + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(80)) + } + + private func toolName(from item: [String: JSONValue]) -> String? { + if let type = Self.firstString(in: item, keys: ["type", "kind"]) { + let normalizedType = type.lowercased() + if normalizedType.contains("command") { return "Bash" } + if normalizedType.contains("file") || normalizedType.contains("patch") { return "Edit" } + if normalizedType.contains("message") { return "message" } + return type + } + guard let name = Self.firstString(in: item, keys: ["name", "toolName"]) else { return nil } + return name.lowercased().contains("message") ? "message" : name + } + + private static func parseModels(from value: JSONValue) -> [AgentModel] { + let root = value.objectValue + let rawModels = root?["data"]?.arrayValue ?? root?["models"]?.arrayValue ?? root?["items"]?.arrayValue ?? value.arrayValue ?? [] + return rawModels.compactMap { entry in + if let id = entry.stringValue { + return AgentModel(provider: .codex, id: id, displayName: AppStateModelFormatter.codexDisplayName(id), description: "Codex model served by the Codex app-server.") + } + guard let object = entry.objectValue, + let id = firstString(in: object, keys: ["id", "slug", "model", "name"]) else { return nil } + if object["hidden"]?.boolValue == true || object["visibility"]?.stringValue == "hidden" { + return nil + } + let displayName = firstString(in: object, keys: ["displayName", "display_name", "name"]) ?? AppStateModelFormatter.codexDisplayName(id) + let description = firstString(in: object, keys: ["description", "detail"]) ?? "Codex model served by the Codex app-server." + return AgentModel(provider: .codex, id: id, displayName: displayName, description: description) + } + } + + private static func request(id: Int, method: String, params: [String: JSONValue]) -> JSONValue { + request(id: id, method: method, params: .object(params)) + } + + private static func request(id: Int, method: String, params: JSONValue) -> JSONValue { + .object(["jsonrpc": .string("2.0"), "id": .number(Double(id)), "method": .string(method), "params": params]) + } + + private static func response(id: String, result: [String: JSONValue]) -> JSONValue { + let idValue = Double(id).map(JSONValue.number) ?? .string(id) + return .object(["jsonrpc": .string("2.0"), "id": idValue, "result": .object(result)]) + } + + private static func notification(method: String, params: [String: JSONValue]) -> JSONValue { + .object(["jsonrpc": .string("2.0"), "method": .string(method), "params": .object(params)]) + } + + private static func writeJSONLine(_ value: JSONValue, to handle: FileHandle) throws { + let data = try JSONEncoder().encode(value) + try handle.write(contentsOf: data) + try handle.write(contentsOf: Data([0x0A])) + } + + private static func decodeObject(_ line: String) -> [String: JSONValue]? { + guard let data = line.data(using: .utf8), + let value = try? JSONDecoder().decode(JSONValue.self, from: data) else { return nil } + return value.objectValue + } + + private static func decodeJSONValue(_ text: String) -> JSONValue? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(JSONValue.self, from: data) + } + + private static func idString(_ value: JSONValue?) -> String? { + if let s = value?.stringValue { return s } + if let n = value?.numberValue { + if n.rounded() == n { return String(Int(n)) } + return String(n) + } + return nil + } + + private static func threadId(from value: JSONValue) -> String? { + if let object = value.objectValue { + if let id = firstString(in: object, keys: ["threadId", "thread_id", "id"]) { return id } + if let nested = object["thread"]?.objectValue { + return firstString(in: nested, keys: ["threadId", "thread_id", "id"]) + } + } + return value.stringValue + } + + private static func firstString(in object: [String: JSONValue], keys: [String]) -> String? { + for key in keys { + if let value = object[key]?.stringValue { return value } + if let number = object[key]?.numberValue { return String(number) } + } + return nil + } + + private struct CodexRateLimitWindow { + let percent: Double + let resetsAt: Date? + let durationMinutes: Int? + } + + private static func parseCodexRateLimits(from value: JSONValue) -> RateLimitUsage? { + guard let snapshot = codexRateLimitSnapshot(from: value) else { return nil } + let windows = [ + parseCodexRateLimitWindow(snapshot["primary"]), + parseCodexRateLimitWindow(snapshot["secondary"]) + ].compactMap { $0 } + + guard !windows.isEmpty else { return nil } + let fiveHour = windows.first { $0.durationMinutes == 300 } ?? windows.first + let twentyFourHour = windows.first { $0.durationMinutes == 1_440 } + ?? windows.first { $0.durationMinutes != fiveHour?.durationMinutes } + ?? windows.dropFirst().first + + return RateLimitUsage( + fiveHourPercent: fiveHour?.percent ?? 0, + sevenDayPercent: 0, + twentyFourHourPercent: twentyFourHour?.percent, + fiveHourResetsAt: fiveHour?.resetsAt, + sevenDayResetsAt: nil, + twentyFourHourResetsAt: twentyFourHour?.resetsAt + ) + } + + private static func codexRateLimitSnapshot(from value: JSONValue) -> [String: JSONValue]? { + guard let object = value.objectValue else { return nil } + + if let byLimitId = object["rateLimitsByLimitId"]?.objectValue { + if let codex = byLimitId["codex"]?.objectValue { + return codex + } + for snapshot in byLimitId.values.compactMap(\.objectValue) { + if firstString(in: snapshot, keys: ["limitId", "limit_id"]) == "codex" { + return snapshot + } + } + } + + if let rateLimits = object["rateLimits"]?.objectValue { + return rateLimits + } + + if object["primary"] != nil || object["secondary"] != nil { + return object + } + + return nil + } + + private static func parseCodexRateLimitWindow(_ value: JSONValue?) -> CodexRateLimitWindow? { + guard let object = value?.objectValue else { return nil } + let percent = firstDouble(in: object, keys: ["usedPercent", "used_percent", "utilization"]) + let duration = firstOptionalInt(in: object, keys: ["windowDurationMins", "window_duration_mins", "windowMinutes"]) + return CodexRateLimitWindow( + percent: percent, + resetsAt: parseUnixOrISODate(object["resetsAt"] ?? object["resets_at"]), + durationMinutes: duration + ) + } + + private static func firstDouble(in object: [String: JSONValue], keys: [String]) -> Double { + for key in keys { + if let value = object[key]?.numberValue { return value } + if let value = object[key]?.stringValue, let doubleValue = Double(value) { return doubleValue } + } + return 0 + } + + private static func firstOptionalInt(in object: [String: JSONValue], keys: [String]) -> Int? { + for key in keys { + if let value = object[key]?.numberValue { return Int(value) } + if let value = object[key]?.stringValue, let intValue = Int(value) { return intValue } + } + return nil + } + + private static func parseUnixOrISODate(_ value: JSONValue?) -> Date? { + if let number = value?.numberValue { + let seconds = number > 1_000_000_000_000 ? number / 1_000 : number + return Date(timeIntervalSince1970: seconds) + } + guard let string = value?.stringValue else { return nil } + if let number = Double(string) { + let seconds = number > 1_000_000_000_000 ? number / 1_000 : number + return Date(timeIntervalSince1970: seconds) + } + return ISO8601DateFormatter().date(from: string) + } + + private static func usageInfo(from object: [String: JSONValue]) -> UsageInfo? { + let params = object["params"]?.objectValue ?? object + let usageKeys = ["usage", "tokenUsage", "token_usage", "totalUsage", "total_usage", "metrics"] + let usage = firstObject(in: params, keys: [ + "usage", "tokenUsage", "token_usage", "totalUsage", "total_usage", "metrics" + ]) ?? firstNestedObject(in: .object(params), keys: usageKeys) ?? params + + var inputTokens = firstInt(in: usage, keys: [ + "input_tokens", "inputTokens", "prompt_tokens", "promptTokens" + ]) + var outputTokens = firstInt(in: usage, keys: [ + "output_tokens", "outputTokens", "completion_tokens", "completionTokens" + ]) + outputTokens += firstInt(in: usage, keys: [ + "reasoning_output_tokens", "reasoningOutputTokens" + ]) + let cacheCreationTokens = firstInt(in: usage, keys: [ + "cache_creation_input_tokens", "cacheCreationInputTokens", "cacheWriteInputTokens", + "cached_creation_tokens", "cachedCreationTokens" + ]) + let explicitCacheReadTokens = firstInt(in: usage, keys: [ + "cache_read_input_tokens", "cacheReadInputTokens", "cached_input_tokens", + "cachedInputTokens", "cache_read_tokens", "cacheReadTokens" + ]) + let cacheReadTokens = explicitCacheReadTokens > 0 ? explicitCacheReadTokens : nestedInputCacheReadTokens(in: usage) + + let total = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + let totalTokens = firstInt(in: usage, keys: ["total_tokens", "totalTokens"]) + if total == 0, totalTokens > 0 { + inputTokens = totalTokens + } + + guard inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens > 0 else { + return nil + } + return UsageInfo( + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens + ) + } + + private static func liveTokenUsage(from params: [String: JSONValue]) -> UsageInfo? { + guard let outputTokens = tokenUsageOutputTokens(from: params), + outputTokens > 0 else { return nil } + return UsageInfo( + inputTokens: 0, + outputTokens: outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0 + ) + } + + private static func tokenUsageOutputTokens(from params: [String: JSONValue]) -> Int? { + let usage = tokenUsageSummary(from: params) + let outputTokens = firstInt(in: usage, keys: ["outputTokens", "output_tokens"]) + let reasoningOutputTokens = firstInt(in: usage, keys: [ + "reasoningOutputTokens", "reasoning_output_tokens" + ]) + let total = outputTokens + reasoningOutputTokens + return total > 0 ? total : nil + } + + private static func tokenUsageTokensUsed(from params: [String: JSONValue]) -> Int? { + let usage = tokenUsageSummary(from: params) + let tokensUsed = firstInt(in: usage, keys: [ + "tokensUsed", "tokens_used", "totalTokens", "total_tokens", + "total", "used", "currentTokens", "current_tokens" + ]) + return tokensUsed > 0 ? tokensUsed : nil + } + + private static func tokenUsageTokenBudget(from params: [String: JSONValue]) -> Int? { + let usage = firstObject(in: params, keys: ["tokenUsage", "token_usage"]) + ?? firstNestedObject(in: .object(params), keys: ["tokenUsage", "token_usage"]) + ?? params + let tokenBudget = firstInt(in: usage, keys: [ + "tokenBudget", "token_budget", "modelContextWindow", "model_context_window" + ]) + return tokenBudget > 0 ? tokenBudget : nil + } + + private static func tokenUsageSummary(from params: [String: JSONValue]) -> [String: JSONValue] { + let usage = firstObject(in: params, keys: ["tokenUsage", "token_usage"]) + ?? firstNestedObject(in: .object(params), keys: ["tokenUsage", "token_usage"]) + ?? params + return firstObject(in: usage, keys: ["last"]) + ?? firstObject(in: usage, keys: ["total"]) + ?? usage + } + + private static func firstObject(in object: [String: JSONValue], keys: [String]) -> [String: JSONValue]? { + for key in keys { + if let value = object[key]?.objectValue { return value } + } + return nil + } + + private static func firstNestedObject(in value: JSONValue, keys: [String]) -> [String: JSONValue]? { + if let object = value.objectValue { + if let direct = firstObject(in: object, keys: keys) { return direct } + for nested in object.values { + if let found = firstNestedObject(in: nested, keys: keys) { return found } + } + } + if let array = value.arrayValue { + for nested in array { + if let found = firstNestedObject(in: nested, keys: keys) { return found } + } + } + return nil + } + + private static func firstInt(in object: [String: JSONValue], keys: [String]) -> Int { + for key in keys { + if let value = object[key]?.numberValue { return Int(value) } + if let value = object[key]?.stringValue, let intValue = Int(value) { return intValue } + } + return 0 + } + + private static func nestedInputCacheReadTokens(in usage: [String: JSONValue]) -> Int { + guard let details = firstObject(in: usage, keys: [ + "input_tokens_details", "inputTokensDetails", "prompt_tokens_details", "promptTokensDetails" + ]) else { return 0 } + return firstInt(in: details, keys: [ + "cached_tokens", "cachedTokens", "cache_read_input_tokens", "cacheReadInputTokens" + ]) + } + + private static func usageMessageId(from params: [String: JSONValue], fallback: String?) -> String { + firstString(in: params, keys: [ + "messageId", "message_id", "responseId", "response_id", + "turnId", "turn_id", "itemId", "item_id", "id" + ]) ?? fallback ?? "codex-turn" + } + + private static func claudeTextDelta(_ text: String) -> String { + jsonString([ + "type": "content_block_delta", + "delta": ["type": "text_delta", "text": text] + ]) + } + + private static func claudeThinkingDelta(_ text: String) -> String { + jsonString([ + "type": "content_block_delta", + "delta": ["type": "thinking_delta", "thinking": text] + ]) + } + + private static func claudeToolStart(id: String, name: String) -> String { + jsonString([ + "type": "content_block_start", + "content_block": ["type": "tool_use", "id": id, "name": name] + ]) + } + + private static func claudeInputDelta(_ input: [String: JSONValue]) -> String { + let data = (try? JSONEncoder().encode(JSONValue.object(input))) ?? Data("{}".utf8) + let inputText = String(data: data, encoding: .utf8) ?? "{}" + return jsonString([ + "type": "content_block_delta", + "delta": ["type": "input_json_delta", "partial_json": inputText] + ]) + } + + private static func claudeContentBlockStop() -> String { + jsonString(["type": "content_block_stop"]) + } + + private static func jsonString(_ object: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: object), + let text = String(data: data, encoding: .utf8) else { return "{}" } + return text + } +} + +private enum AppStateModelFormatter { + nonisolated static func codexDisplayName(_ model: String) -> String { + model + .replacingOccurrences(of: "-", with: " ") + .split(separator: " ") + .map { part in + part.uppercased().hasPrefix("GPT") ? part.uppercased() : part.capitalized + } + .joined(separator: " ") + } +} diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift new file mode 100644 index 00000000..040d23a4 --- /dev/null +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -0,0 +1,148 @@ +import Foundation +import RxCodeCore +import os + +actor OpenAISummarizationService { + enum OpenAIError: LocalizedError { + case invalidEndpoint + case requestFailed(Int, String) + case emptyResponse + + var errorDescription: String? { + switch self { + case .invalidEndpoint: + return "Enter a valid OpenAI-compatible endpoint." + case .requestFailed(let status, let message): + return "OpenAI request failed (\(status)): \(message)" + case .emptyResponse: + return "OpenAI returned an empty response." + } + } + } + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.claudework", + category: "OpenAISummarizationService" + ) + + func fetchModels(endpoint: String, apiKey: String) async throws -> [String] { + var request = try makeRequest(endpoint: endpoint, path: "/models", apiKey: apiKey) + request.httpMethod = "GET" + + let value = try await send(request) + let rawModels = value.objectValue?["data"]?.arrayValue + ?? value.objectValue?["models"]?.arrayValue + ?? value.arrayValue + ?? [] + + let models = rawModels.compactMap { item -> String? in + if let id = item.stringValue { return id } + return item.objectValue?["id"]?.stringValue + ?? item.objectValue?["model"]?.stringValue + ?? item.objectValue?["name"]?.stringValue + } + + return Array(Set(models)).sorted() + } + + func generateSessionTitle(firstUserMessage: String, endpoint: String, apiKey: String, model: String) async -> String? { + let trimmedUser = String(firstUserMessage.prefix(500)) + let prompt = """ + Summarize the following user message as a 3-6 word chat title. Reply with ONLY the title, no quotes, no markdown, no punctuation at the end. + + \(trimmedUser) + """ + + let body: JSONValue = .object([ + "model": .string(model), + "messages": .array([ + .object([ + "role": .string("system"), + "content": .string("You generate concise chat titles.") + ]), + .object([ + "role": .string("user"), + "content": .string(prompt) + ]) + ]), + "temperature": .number(0.2), + "max_tokens": .number(32) + ]) + + do { + var request = try makeRequest(endpoint: endpoint, path: "/chat/completions", apiKey: apiKey) + request.httpMethod = "POST" + request.httpBody = try JSONEncoder().encode(body) + + let value = try await send(request) + let content = value.objectValue?["choices"]?.arrayValue?.first? + .objectValue?["message"]?.objectValue?["content"]?.stringValue + ?? value.objectValue?["choices"]?.arrayValue?.first? + .objectValue?["text"]?.stringValue + return cleanTitle(content) + } catch { + logger.warning("OpenAI title generation failed: \(error.localizedDescription)") + return nil + } + } + + private func makeRequest(endpoint: String, path: String, apiKey: String) throws -> URLRequest { + guard let url = endpointURL(endpoint: endpoint, path: path) else { + throw OpenAIError.invalidEndpoint + } + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + return request + } + + private func endpointURL(endpoint: String, path: String) -> URL? { + var base = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + if base.isEmpty { + base = AppState.defaultOpenAISummarizationEndpoint + } + while base.hasSuffix("/") { + base.removeLast() + } + + if base.hasSuffix("/models") { + base.removeLast("/models".count) + } else if base.hasSuffix("/chat/completions") { + base.removeLast("/chat/completions".count) + } + + if let url = URL(string: base), + url.host == "api.openai.com", + url.path.isEmpty { + base += "/v1" + } + + return URL(string: base + path) + } + + private func send(_ request: URLRequest) async throws -> JSONValue { + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + let message = String(data: data, encoding: .utf8) ?? HTTPURLResponse.localizedString(forStatusCode: status) + throw OpenAIError.requestFailed(status, message) + } + guard !data.isEmpty else { throw OpenAIError.emptyResponse } + return try JSONDecoder().decode(JSONValue.self, from: data) + } + + private func cleanTitle(_ raw: String?) -> String? { + guard let raw else { return nil } + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "openai error"] + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(80)) + } +} diff --git a/RxCode/Services/PermissionServer.swift b/RxCode/Services/PermissionServer.swift index ac55bca8..79555eed 100644 --- a/RxCode/Services/PermissionServer.swift +++ b/RxCode/Services/PermissionServer.swift @@ -235,6 +235,32 @@ actor PermissionServer { } } + /// Reuses the same permission UI/decision queue for non-Claude transports + /// such as Codex app-server JSON-RPC requests. + func requestDecision( + toolUseId: String, + sessionId: String?, + toolName: String, + toolInput: [String: JSONValue], + mode: PermissionMode? + ) async -> PermissionDecision { + let request = PermissionRequest( + id: toolUseId, + toolName: toolName, + toolInput: toolInput, + runToken: runToken, + streamPermissionMode: mode, + sessionId: sessionId + ) + let outcome = await waitForDecision( + toolUseId: toolUseId, + sessionId: sessionId, + toolName: toolName, + emit: request + ) + return outcome.decision + } + /// Idempotent. func registerSession(sid: String, projectKey: String, mode: PermissionMode) async { await loadBashAllowlistIfNeeded() diff --git a/RxCode/Services/PersistenceService.swift b/RxCode/Services/PersistenceService.swift index 7da519ac..bdd5ec13 100644 --- a/RxCode/Services/PersistenceService.swift +++ b/RxCode/Services/PersistenceService.swift @@ -49,6 +49,7 @@ actor PersistenceService { meta: SessionMetaStore.Meta( title: titleToWrite, isPinned: session.isPinned, + agentProvider: session.agentProvider, model: session.model, effort: session.effort, permissionMode: session.permissionMode, @@ -58,7 +59,7 @@ actor PersistenceService { ) ) - case .legacyRxCode: + case .legacyRxCode, .codexAppServer: let dir = baseURL .appendingPathComponent("sessions") .appendingPathComponent(session.projectId.uuidString) @@ -90,11 +91,12 @@ actor PersistenceService { guard var summary = decode(ChatSession.Summary.self, from: url) else { return nil } // Files saved before this change may decode as `.legacyRxCode` // already (default), but be defensive. - if summary.origin != .legacyRxCode { + if summary.origin != .legacyRxCode && summary.origin != .codexAppServer { summary = ChatSession.Summary( id: summary.id, projectId: summary.projectId, title: summary.title, createdAt: summary.createdAt, updatedAt: summary.updatedAt, - isPinned: summary.isPinned, model: summary.model, + isPinned: summary.isPinned, agentProvider: summary.agentProvider, + model: summary.model, effort: summary.effort, permissionMode: summary.permissionMode, origin: .legacyRxCode ) @@ -148,7 +150,7 @@ actor PersistenceService { // it survives, the merge in AppState falls back to it after the // jsonl is gone and the entry resurrects on the next reload. try removeLegacySessionFile(projectId: projectId, sessionId: sessionId) - case .legacyRxCode: + case .legacyRxCode, .codexAppServer: try removeLegacySessionFile(projectId: projectId, sessionId: sessionId) } } @@ -175,7 +177,7 @@ actor PersistenceService { cwd: cwd, projectId: summary.projectId ) - case .legacyRxCode: + case .legacyRxCode, .codexAppServer: return loadLegacySessionSync(projectId: summary.projectId, sessionId: summary.id) } } diff --git a/RxCode/Services/RateLimitService.swift b/RxCode/Services/RateLimitService.swift index c4aa3b5f..bf156518 100644 --- a/RxCode/Services/RateLimitService.swift +++ b/RxCode/Services/RateLimitService.swift @@ -16,6 +16,7 @@ actor RateLimitService { private var cached: RateLimitUsage? private var cachedAt: Date? + private var fetchTask: Task? private let cacheTTL: TimeInterval = 300 // 5 minutes private var authFailed = false @@ -24,6 +25,18 @@ actor RateLimitService { return c } + if let fetchTask { + return await fetchTask.value ?? cached + } + + let task = Task { await self.fetchUsageUncached(forceRefresh: forceRefresh) } + fetchTask = task + let usage = await task.value + fetchTask = nil + return usage ?? cached + } + + private func fetchUsageUncached(forceRefresh: Bool) async -> RateLimitUsage? { if authFailed && !forceRefresh { return cached } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index b7008576..c1ceeae4 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -35,7 +35,8 @@ struct MainView: View { private var navigationTitleText: String { if let id = windowState.currentSessionId, let title = appState.allSessionSummaries.first(where: { $0.id == id })?.title, - !title.isEmpty { + !title.isEmpty + { return title } return windowState.selectedProject?.name ?? "" @@ -87,7 +88,7 @@ struct MainView: View { .onAppear { windowState.focusMode = appState.focusMode } - .navigationTitle(navigationTitleText) + .toolbar(removing: .title) .toolbarBackground(.hidden, for: .windowToolbar) .background(UnifiedTitleBarAccessor()) .toolbar { @@ -120,6 +121,11 @@ struct MainView: View { handleFolderSelection(result) } } + + ToolbarItem(placement: .navigation) { + Text(navigationTitleText) + .padding(.trailing) + } } } @@ -198,8 +204,8 @@ struct MainView: View { editHunks: file.editHunks, gitDiffMode: file.gitDiffMode ) - .frame(minWidth: 1000, idealWidth: 1400, maxWidth: 1920, - minHeight: 600, idealHeight: 1000, maxHeight: 1200) + .frame(minWidth: 1000, idealWidth: 1400, maxWidth: 1920, + minHeight: 600, idealHeight: 1000, maxHeight: 1200) } .alert("Error", isPresented: Bindable(windowState).showError) { Button("OK", role: .cancel) {} @@ -444,6 +450,7 @@ struct ChatToolbarControls: View { private var effectiveMode: PermissionMode { windowState.sessionPermissionMode ?? appState.permissionMode } private var effectiveModel: String { windowState.sessionModel ?? appState.selectedModel } + private var effectiveProvider: AgentProvider { windowState.sessionAgentProvider ?? appState.selectedAgentProvider } var body: some View { HStack(spacing: placement == .composer ? 8 : 4) { @@ -476,18 +483,22 @@ struct ChatToolbarControls: View { Menu { Section("Model Picker") { - ForEach(AppState.availableModels, id: \.self) { model in - Button { - appState.setSessionModel(model, in: windowState) - } label: { - Text(AppState.modelDisplayName(model)) - if effectiveModel == model { Image(systemName: "checkmark") } + ForEach(appState.availableAgentModelSections(), id: \.provider) { section in + Section(section.provider.displayName) { + ForEach(section.models, id: \.key) { model in + Button { + appState.setSessionModel(model.id, provider: model.provider, in: windowState) + } label: { + let isSelected = effectiveProvider == model.provider && effectiveModel == model.id + Text(isSelected ? "\(model.displayName) ✓" : model.displayName) + } + } } } } } label: { controlLabel( - title: AppState.modelDisplayName(effectiveModel), + title: "\(effectiveProvider == .codex ? "Codex · " : "")\(AppState.modelDisplayName(effectiveModel, provider: effectiveProvider))", icon: nil, isAccent: false, isActive: false @@ -495,7 +506,7 @@ struct ChatToolbarControls: View { } .menuStyle(.borderlessButton) .fixedSize() - .help("Model: \(AppState.modelDisplayName(effectiveModel))") + .help("Model: \(effectiveProvider.displayName) · \(AppState.modelDisplayName(effectiveModel, provider: effectiveProvider))") Menu { Section("Effort Picker") { @@ -715,6 +726,8 @@ struct ModelPickerSheet: View { @FocusState private var isFocused: Bool private var effectiveModel: String { windowState.sessionModel ?? appState.selectedModel } + private var effectiveProvider: AgentProvider { windowState.sessionAgentProvider ?? appState.selectedAgentProvider } + private var flatModels: [AgentModel] { appState.availableAgentModelSections().flatMap(\.models) } var body: some View { VStack(spacing: 16) { @@ -723,33 +736,42 @@ struct ModelPickerSheet: View { .foregroundStyle(ClaudeTheme.textPrimary) VStack(spacing: 8) { - ForEach(AppState.availableModels.indices, id: \.self) { index in - let model = AppState.availableModels[index] - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - Text(AppState.modelDisplayName(model)) - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - .foregroundStyle(ClaudeTheme.textPrimary) - Text(AppState.modelDescription(model)) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - if effectiveModel == model { - Image(systemName: "checkmark") - .foregroundStyle(ClaudeTheme.accent) - .padding(.top, 2) + ForEach(appState.availableAgentModelSections(), id: \.provider) { section in + VStack(alignment: .leading, spacing: 6) { + Text(section.provider.displayName) + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.horizontal, 4) + + ForEach(section.models, id: \.key) { model in + let index = flatModels.firstIndex(where: { $0.key == model.key }) ?? 0 + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(model.displayName) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(model.description) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + if effectiveProvider == model.provider && effectiveModel == model.id { + Image(systemName: "checkmark") + .foregroundStyle(ClaudeTheme.accent) + .padding(.top, 2) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(index == selectedIndex ? ClaudeTheme.accentSubtle : ClaudeTheme.surfacePrimary) + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + .onTapGesture { + appState.setSessionModel(model.id, provider: model.provider, in: windowState) + dismiss() + } } } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(index == selectedIndex ? ClaudeTheme.accentSubtle : ClaudeTheme.surfacePrimary) - .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) - .onTapGesture { - appState.setSessionModel(model, in: windowState) - dismiss() - } } } @@ -763,15 +785,16 @@ struct ModelPickerSheet: View { .focusable() .focused($isFocused) .onKeyPress(.upArrow) { - selectedIndex = (selectedIndex - 1 + AppState.availableModels.count) % AppState.availableModels.count + selectedIndex = (selectedIndex - 1 + flatModels.count) % flatModels.count return .handled } .onKeyPress(.downArrow) { - selectedIndex = (selectedIndex + 1) % AppState.availableModels.count + selectedIndex = (selectedIndex + 1) % flatModels.count return .handled } .onKeyPress(.return) { - appState.setSessionModel(AppState.availableModels[selectedIndex], in: windowState) + let model = flatModels[selectedIndex] + appState.setSessionModel(model.id, provider: model.provider, in: windowState) dismiss() return .handled } @@ -780,7 +803,7 @@ struct ModelPickerSheet: View { return .handled } .onAppear { - selectedIndex = AppState.availableModels.firstIndex(of: effectiveModel) ?? 0 + selectedIndex = flatModels.firstIndex { $0.provider == effectiveProvider && $0.id == effectiveModel } ?? 0 DispatchQueue.main.async { isFocused = true } } } diff --git a/RxCode/Views/Onboarding/OnboardingView.swift b/RxCode/Views/Onboarding/OnboardingView.swift index 2d30fc1c..9c9e31f6 100644 --- a/RxCode/Views/Onboarding/OnboardingView.swift +++ b/RxCode/Views/Onboarding/OnboardingView.swift @@ -3,10 +3,16 @@ import RxCodeCore struct OnboardingView: View { @Environment(AppState.self) private var appState + var onCompletion: (() -> Void)? = nil @State private var isCheckingCLI = false - @State private var cliInstalled = false - @State private var cliVersion: String? - @State private var cliError: String? + @State private var claudeInstalled = false + @State private var claudeVersion: String? + @State private var claudeError: String? + @State private var codexInstalled = false + @State private var codexVersion: String? + @State private var codexError: String? + + private var canContinue: Bool { claudeInstalled || codexInstalled } var body: some View { VStack(spacing: 0) { @@ -21,7 +27,7 @@ struct OnboardingView: View { .padding(.bottom, 24) } .padding(.horizontal, 40) - .frame(width: 560, height: 420) + .frame(width: 600, height: 560) .background(ClaudeTheme.background) .task { await checkCLI() @@ -43,59 +49,81 @@ struct OnboardingView: View { if isCheckingCLI { ProgressView("Checking...") - } else if cliInstalled { - Label("Installed — \(cliVersion ?? "")", systemImage: "checkmark.circle.fill") - .foregroundStyle(ClaudeTheme.statusSuccess) - .font(.body) - } else { - VStack(spacing: 12) { - Label("Claude CLI not found", systemImage: "xmark.circle.fill") - .foregroundStyle(ClaudeTheme.statusError) - .font(.body) - - if let error = cliError { - Text(error) - .font(.caption) - .foregroundStyle(ClaudeTheme.textSecondary) - } + } - VStack(alignment: .leading, spacing: 6) { - Text("Install command:") - .font(.subheadline) - .foregroundStyle(ClaudeTheme.textSecondary) + VStack(spacing: 10) { + cliStatusRow( + title: "Claude Code", + installed: claudeInstalled, + version: claudeVersion, + error: claudeError, + installCommand: "npm install -g @anthropic-ai/claude-code" + ) + cliStatusRow( + title: "Codex", + installed: codexInstalled, + version: codexVersion, + error: codexError, + installCommand: "npm install -g @openai/codex" + ) + } - HStack { - Text("npm install -g @anthropic-ai/claude-code") - .font(.system(.body, design: .monospaced)) - .foregroundStyle(ClaudeTheme.textPrimary) - .textSelection(.enabled) - .padding(8) - .background(ClaudeTheme.codeBackground) - .clipShape(RoundedRectangle(cornerRadius: 6)) - - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString( - "npm install -g @anthropic-ai/claude-code", - forType: .string - ) - } label: { - Image(systemName: "doc.on.doc") - .foregroundStyle(ClaudeTheme.textSecondary) - } - .buttonStyle(.borderless) - .help("Copy") - } - } - } + Button("Check Again") { + Task { await checkCLI() } + } + .buttonStyle(ClaudeSecondaryButtonStyle()) + .padding(.top, 4) + } + } + + private func cliStatusRow( + title: String, + installed: Bool, + version: String?, + error: String?, + installCommand: String + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label( + installed ? "\(title) installed\(version.map { " — \($0)" } ?? "")" : "\(title) not found", + systemImage: installed ? "checkmark.circle.fill" : "xmark.circle.fill" + ) + .foregroundStyle(installed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) + .font(.body) + Spacer() + } - Button("Check Again") { - Task { await checkCLI() } + if !installed { + if let error { + Text(error) + .font(.caption) + .foregroundStyle(ClaudeTheme.textSecondary) + } + HStack { + Text(installCommand) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(ClaudeTheme.textPrimary) + .textSelection(.enabled) + .padding(8) + .background(ClaudeTheme.codeBackground) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(installCommand, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .foregroundStyle(ClaudeTheme.textSecondary) + } + .buttonStyle(.borderless) + .help("Copy") } - .buttonStyle(ClaudeSecondaryButtonStyle()) - .padding(.top, 4) } } + .padding(12) + .background(ClaudeTheme.surfacePrimary) + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) } // MARK: - Navigation @@ -105,9 +133,10 @@ struct OnboardingView: View { Spacer() Button("Get Started") { appState.skipGitHubLogin() + onCompletion?() } .buttonStyle(ClaudeAccentButtonStyle()) - .disabled(!cliInstalled) + .disabled(!canContinue) } } @@ -115,22 +144,54 @@ struct OnboardingView: View { private func checkCLI() async { isCheckingCLI = true - cliError = nil + claudeError = nil + codexError = nil do { let version = try await appState.claude.checkVersion() - cliVersion = version - cliInstalled = true + claudeVersion = version + claudeInstalled = true appState.claudeInstalled = true + appState.claudeVersion = version + appState.claudeBinaryPath = await appState.claude.findClaudeBinary() } catch { - cliInstalled = false - cliError = error.localizedDescription + claudeInstalled = false + claudeError = error.localizedDescription let binary = await appState.claude.findClaudeBinary() + appState.claudeBinaryPath = binary if let binary { - cliError = "Binary found: \(binary), but version check failed" - cliInstalled = true + claudeError = "Binary found: \(binary), but version check failed" + claudeInstalled = true appState.claudeInstalled = true + appState.claudeVersion = nil + } else { + appState.claudeInstalled = false + appState.claudeVersion = nil + } + } + + do { + let version = try await appState.codex.checkVersion() + codexVersion = version + codexInstalled = true + appState.codexInstalled = true + appState.codexVersion = version + appState.codexBinaryPath = await appState.codex.findCodexBinary() + } catch { + codexInstalled = false + codexError = error.localizedDescription + + let binary = await appState.codex.findCodexBinary() + appState.codexBinaryPath = binary + if let binary { + codexError = "Binary found: \(binary), but version check failed" + codexInstalled = true + appState.codexInstalled = true + appState.codexVersion = nil + } else { + appState.codexInstalled = false + appState.codexVersion = nil } } diff --git a/RxCode/Views/Permission/PermissionQueueBanner.swift b/RxCode/Views/Permission/PermissionQueueBanner.swift index 36fba23a..6bcf2477 100644 --- a/RxCode/Views/Permission/PermissionQueueBanner.swift +++ b/RxCode/Views/Permission/PermissionQueueBanner.swift @@ -9,8 +9,40 @@ struct PermissionQueueBanner: View { @Environment(WindowState.self) private var windowState @State private var isHovered: Bool = false + private var pendingRequests: [PermissionRequest] { + windowState.pendingPermissions + } + + private var questionCount: Int { + pendingRequests.filter { $0.toolName == "AskUserQuestion" }.count + } + + private var permissionCount: Int { + pendingRequests.count - questionCount + } + + private var bannerIcon: String { + permissionCount == 0 ? "questionmark.circle.fill" : "exclamationmark.shield.fill" + } + + private var bannerText: String { + if questionCount > 0, permissionCount == 0 { + return countText(questionCount, singular: "question pending", plural: "questions pending") + } + + if permissionCount > 0, questionCount == 0 { + return countText(permissionCount, singular: "permission request pending", plural: "permission requests pending") + } + + return countText(pendingRequests.count, singular: "request pending", plural: "requests pending") + } + + private var actionText: String { + permissionCount == 0 ? "Answer" : "Review" + } + var body: some View { - if !windowState.pendingPermissions.isEmpty { + if !pendingRequests.isEmpty { content .transition(.move(edge: .bottom).combined(with: .opacity)) } @@ -19,17 +51,17 @@ struct PermissionQueueBanner: View { private var content: some View { Button(action: open) { HStack(spacing: 10) { - Image(systemName: "questionmark.circle.fill") + Image(systemName: bannerIcon) .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) .foregroundStyle(ClaudeTheme.accent) - Text("\(windowState.pendingPermissions.count) question(s) pending") + Text(bannerText) .font(.system(size: ClaudeTheme.size(13), weight: .medium)) .foregroundStyle(ClaudeTheme.textPrimary) Spacer(minLength: 8) - Text("Answer") + Text(actionText) .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, 12) @@ -57,6 +89,10 @@ struct PermissionQueueBanner: View { } private func open() { - windowState.presentedPermissionId = windowState.pendingPermissions.first?.id + windowState.presentedPermissionId = pendingRequests.first?.id + } + + private func countText(_ count: Int, singular: String, plural: String) -> String { + "\(count) \(count == 1 ? singular : plural)" } } diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 25ebfcd2..27c9e066 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -9,10 +9,14 @@ struct SettingsView: View { @State private var selectedTab = 0 @State private var showUserManual = false + @State private var showOnboarding = false var body: some View { TabView(selection: $selectedTab) { - GeneralSettingsTab(showUserManual: $showUserManual) + GeneralSettingsTab( + showUserManual: $showUserManual, + showOnboarding: $showOnboarding + ) .tabItem { Label("General", systemImage: "slider.horizontal.3") } @@ -53,6 +57,12 @@ struct SettingsView: View { .sheet(isPresented: $showUserManual) { UserManualView() } + .sheet(isPresented: $showOnboarding) { + OnboardingView { + showOnboarding = false + } + .environment(appState) + } } } @@ -61,6 +71,7 @@ struct SettingsView: View { struct GeneralSettingsTab: View { @Environment(AppState.self) private var appState @Binding var showUserManual: Bool + @Binding var showOnboarding: Bool @State private var showSkillMarket = false @State private var showThemePicker = false @AppStorage("showMenuBarExtra") private var showMenuBarExtra: Bool = true @@ -79,6 +90,7 @@ struct GeneralSettingsTab: View { Divider() VStack(alignment: .leading, spacing: 8) { skillMarketSection + onboardingSection helpSection sourceCodeSection } @@ -295,7 +307,7 @@ struct GeneralSettingsTab: View { // MARK: - Source Code Section private var sourceCodeSection: some View { - Link(destination: URL(string: "https://github.com/ttnear/RxCode")!) { + Link(destination: URL(string: "https://github.com/rxtech-lab/rxcode")!) { HStack(spacing: 10) { Image(systemName: "chevron.left.forwardslash.chevron.right") .font(.system(size: ClaudeTheme.size(14))) @@ -304,7 +316,7 @@ struct GeneralSettingsTab: View { Text("Open Source") .font(.system(size: ClaudeTheme.size(13))) .foregroundStyle(.primary) - Text(verbatim: "github.com/ttnear/RxCode") + Text(verbatim: "github.com/rxtech-lab/rxcode") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) } @@ -327,6 +339,39 @@ struct GeneralSettingsTab: View { // MARK: - Help Section + private var onboardingSection: some View { + Button { + showOnboarding = true + } label: { + HStack(spacing: 10) { + Image(systemName: "sparkles") + .font(.system(size: ClaudeTheme.size(14))) + .frame(width: 20) + VStack(alignment: .leading, spacing: 1) { + Text("Show Onboarding") + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(.primary) + Text("Review the CLI setup check") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + private var helpSection: some View { Button { showUserManual = true @@ -360,12 +405,17 @@ struct GeneralSettingsTab: View { struct ChatSettingsTab: View { @Environment(AppState.self) private var appState + @State private var isRefreshingAgentStatus = false var body: some View { @Bindable var appState = appState ScrollView { VStack(alignment: .leading, spacing: 24) { - modelSection(selectedModel: $appState.selectedModel) + agentRuntimeSection + Divider() + modelSection + Divider() + summarizationSection Divider() permissionModeSection Divider() @@ -382,6 +432,97 @@ struct ChatSettingsTab: View { } } + // MARK: - Agent Runtime Section + + private var agentRuntimeSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Agent Runtimes") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Spacer() + + Button { + Task { + isRefreshingAgentStatus = true + await appState.refreshAgentInstallations() + isRefreshingAgentStatus = false + } + } label: { + if isRefreshingAgentStatus { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .buttonStyle(.borderless) + .disabled(isRefreshingAgentStatus) + .help("Refresh installation status") + } + + VStack(spacing: 8) { + agentRuntimeRow( + title: "Claude Code", + installed: appState.claudeInstalled, + version: appState.claudeVersion, + path: appState.claudeBinaryPath + ) + agentRuntimeRow( + title: "Codex", + installed: appState.codexInstalled, + version: appState.codexVersion, + path: appState.codexBinaryPath + ) + } + } + } + + private func agentRuntimeRow( + title: String, + installed: Bool, + version: String?, + path: String? + ) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: installed ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(installed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) + .font(.system(size: ClaudeTheme.size(14))) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(title) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text(installed ? "Installed" : "Not found") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(installed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) + if let version, !version.isEmpty { + Text(version) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + + Text(path ?? "No executable detected") + .font(.system(size: ClaudeTheme.size(11), design: .monospaced)) + .foregroundStyle(path == nil ? .secondary : ClaudeTheme.textPrimary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + } + .padding(10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + // MARK: - Archive Section private var archiveSection: some View { @@ -420,7 +561,7 @@ struct ChatSettingsTab: View { // MARK: - Model Section - private func modelSection(selectedModel: Binding) -> some View { + private var modelSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Default Model") .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) @@ -429,21 +570,176 @@ struct ChatSettingsTab: View { .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) - Picker("", selection: selectedModel) { - ForEach(AppState.availableModels, id: \.self) { model in - Text(AppState.modelDisplayName(model)).tag(model) + Picker("", selection: defaultModelKey) { + ForEach(appState.availableAgentModelSections(), id: \.provider) { section in + Section(section.provider.displayName) { + ForEach(section.models, id: \.key) { model in + Text(model.displayName).tag(model.key) + } + } } } .labelsHidden() .pickerStyle(.menu) .fixedSize() - Text(AppState.modelDescription(selectedModel.wrappedValue)) + Text(selectedDefaultModel.description) .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) } } + private var defaultModelKey: Binding { + Binding( + get: { "\(appState.selectedAgentProvider.rawValue):\(appState.selectedModel)" }, + set: { key in + let parts = key.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, let provider = AgentProvider(rawValue: parts[0]) else { return } + appState.selectedAgentProvider = provider + appState.selectedModel = parts[1] + } + ) + } + + private var selectedDefaultModel: AgentModel { + appState.availableAgentModelSections() + .flatMap(\.models) + .first { $0.provider == appState.selectedAgentProvider && $0.id == appState.selectedModel } + ?? AgentModel( + provider: appState.selectedAgentProvider, + id: appState.selectedModel, + displayName: AppState.modelDisplayName(appState.selectedModel, provider: appState.selectedAgentProvider), + description: AppState.modelDescription(appState.selectedModel, provider: appState.selectedAgentProvider) + ) + } + + // MARK: - Summarization Section + + private var summarizationSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + Text("Summarization Model") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Text("Used to generate short session titles. The default follows the selected chat client.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + Picker("Provider", selection: $appState.summarizationProvider) { + ForEach(SummarizationProvider.allCases) { provider in + Text(provider.displayName).tag(provider) + } + } + .pickerStyle(.menu) + .fixedSize() + .onChange(of: appState.summarizationProvider) { _, newValue in + guard newValue == .openAI, appState.openAISummarizationModels.isEmpty else { return } + Task { await appState.refreshOpenAISummarizationModels() } + } + + switch appState.summarizationProvider { + case .selectedClient: + Text("Uses \(appState.selectedAgentProvider.displayName) with \(selectedDefaultModel.displayName).") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + case .claudeCode: + Text("Uses Claude Code with \(summarizationModelName(for: .claudeCode)).") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + case .codex: + Text("Uses Codex with \(summarizationModelName(for: .codex)).") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + case .openAI: + openAISummarizationForm + } + } + } + + private var openAISummarizationForm: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 10) { + settingsTextFieldRow( + label: "Endpoint", + text: $appState.openAISummarizationEndpoint, + prompt: AppState.defaultOpenAISummarizationEndpoint + ) + + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("API Key") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(width: 84, alignment: .leading) + SecureField("sk-...", text: $appState.openAISummarizationAPIKey) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + } + + HStack(spacing: 10) { + Picker("Model", selection: $appState.openAISummarizationModel) { + if !appState.openAISummarizationModel.isEmpty, + !appState.openAISummarizationModels.contains(appState.openAISummarizationModel) { + Text(appState.openAISummarizationModel).tag(appState.openAISummarizationModel) + } + ForEach(appState.openAISummarizationModels, id: \.self) { model in + Text(model).tag(model) + } + if appState.openAISummarizationModels.isEmpty && appState.openAISummarizationModel.isEmpty { + Text("Fetch models first").tag("") + } + } + .pickerStyle(.menu) + .frame(width: 260, alignment: .leading) + + Button { + Task { await appState.refreshOpenAISummarizationModels() } + } label: { + if appState.isLoadingOpenAISummarizationModels { + ProgressView() + .controlSize(.small) + } else { + Label("Fetch Models", systemImage: "arrow.clockwise") + } + } + .disabled(appState.isLoadingOpenAISummarizationModels) + } + + if let error = appState.openAISummarizationModelsError { + Text(error) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusError) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear { + guard appState.openAISummarizationModels.isEmpty else { return } + Task { await appState.refreshOpenAISummarizationModels() } + } + } + + private func settingsTextFieldRow(label: String, text: Binding, prompt: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(label) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(width: 84, alignment: .leading) + TextField(prompt, text: text) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + } + } + + private func summarizationModelName(for provider: AgentProvider) -> String { + if appState.selectedAgentProvider == provider { + return selectedDefaultModel.displayName + } + let model = appState.availableAgentModelSections() + .first(where: { $0.provider == provider })? + .models + .first + return model?.displayName ?? "the first available model" + } + // MARK: - Permission Mode Section private var permissionModeSection: some View { diff --git a/RxCode/Views/Sidebar/HistoryListView.swift b/RxCode/Views/Sidebar/HistoryListView.swift index f7129e68..fac90ec8 100644 --- a/RxCode/Views/Sidebar/HistoryListView.swift +++ b/RxCode/Views/Sidebar/HistoryListView.swift @@ -344,15 +344,27 @@ struct HistoryListView: View { ) } - private static let relativeDateFormatter: RelativeDateTimeFormatter = { - let f = RelativeDateTimeFormatter() - f.locale = .current - f.unitsStyle = .abbreviated - return f - }() - private func formattedDate(_ date: Date) -> String { - Self.relativeDateFormatter.localizedString(for: date, relativeTo: Date()) + Self.compactElapsedTime(since: date) + } + + private static func compactElapsedTime(since date: Date, now: Date = Date()) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + if seconds < 60 { return "0m" } + + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m" } + + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + + let days = hours / 24 + if days < 7 { return "\(days)d" } + + let weeks = days / 7 + if weeks < 52 { return "\(weeks)w" } + + return "\(days / 365)y" } } diff --git a/RxCode/Views/Sidebar/ProjectChatRow.swift b/RxCode/Views/Sidebar/ProjectChatRow.swift index 5c96be07..85d72875 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -12,6 +12,26 @@ public enum ChatStatus: Sendable, Equatable { case error(String) } +// MARK: - ChatTodoProgress + +struct ChatTodoProgress: Sendable, Equatable { + let done: Int + let total: Int + let inProgress: Bool + + init(done: Int, total: Int, inProgress: Bool) { + self.done = done + self.total = total + self.inProgress = inProgress + } + + init(todos: [TodoItem]) { + self.done = todos.filter { $0.status == .completed }.count + self.total = todos.count + self.inProgress = todos.contains { $0.status == .inProgress } + } +} + // MARK: - StatusBadgeDot struct StatusBadgeDot: View { @@ -57,6 +77,7 @@ struct ProjectChatRow: View { let summary: ChatSession.Summary let isCurrent: Bool let status: ChatStatus + let todoProgress: ChatTodoProgress? let onSelect: () -> Void let onRename: () -> Void let onTogglePin: () -> Void @@ -65,16 +86,9 @@ struct ProjectChatRow: View { @State private var isHovered = false - private static let relativeDateFormatter: RelativeDateTimeFormatter = { - let f = RelativeDateTimeFormatter() - f.locale = .current - f.unitsStyle = .abbreviated - return f - }() - private var isActiveStatus: Bool { switch status { - case .streaming, .awaitingPermission, .done, .error: return true + case .awaitingPermission, .done, .error: return true default: return false } } @@ -107,9 +121,13 @@ struct ProjectChatRow: View { .foregroundStyle(ClaudeTheme.textTertiary) } - Text(Self.relativeDateFormatter.localizedString(for: summary.updatedAt, relativeTo: Date())) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textTertiary) + if case .streaming = status { + CompactSessionProgressView(progress: todoProgress) + } else { + Text(Self.compactElapsedTime(since: summary.updatedAt)) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + } } .padding(.leading, 18) .padding(.trailing, 14) @@ -157,15 +175,61 @@ struct ProjectChatRow: View { @ViewBuilder private var statusIndicator: some View { switch status { - case .streaming: - ProgressView() - .controlSize(.mini) - .frame(width: 8, height: 8) - .help("Response in progress") case .awaitingPermission, .done, .error: StatusBadgeDot(status: status) - case .idle: + case .idle, .streaming: EmptyView() } } + + private static func compactElapsedTime(since date: Date, now: Date = Date()) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + if seconds < 60 { return "0m" } + + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m" } + + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + + let days = hours / 24 + if days < 7 { return "\(days)d" } + + let weeks = days / 7 + if weeks < 52 { return "\(weeks)w" } + + return "\(days / 365)y" + } +} + +// MARK: - CompactSessionProgressView + +private struct CompactSessionProgressView: View { + let progress: ChatTodoProgress? + + private var fraction: Double? { + guard let progress, progress.total > 0 else { return nil } + return min(1, max(0, Double(progress.done) / Double(progress.total))) + } + + private var helpText: String { + guard let progress, progress.total > 0 else { return "Response in progress" } + return "Todos \(progress.done)/\(progress.total)" + } + + var body: some View { + Group { + if let fraction { + ProgressView(value: fraction, total: 1) + } else { + ProgressView() + } + } + .progressViewStyle(.circular) + .controlSize(.small) + .frame(width: 14, height: 14) + .help(helpText) + .accessibilityLabel(helpText) + .animation(.easeInOut(duration: 0.2), value: fraction) + } } diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index a95a3187..30940b6c 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -366,11 +366,15 @@ private struct ProjectChatsList: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState + private static let defaultVisibleThreadCount = 5 + let project: Project let onSelectSession: (String) -> Void let onRenameSession: (ChatSession) -> Void let onDeleteSession: (ChatSession) -> Void + @State private var showsAllThreads = false + private var sessions: [ChatSession.Summary] { appState.allSessionSummaries .filter { $0.projectId == project.id } @@ -380,6 +384,15 @@ private struct ProjectChatsList: View { } } + private var visibleSessions: [ChatSession.Summary] { + guard !showsAllThreads else { return sessions } + return Array(sessions.prefix(Self.defaultVisibleThreadCount)) + } + + private var hiddenThreadCount: Int { + max(0, sessions.count - Self.defaultVisibleThreadCount) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { if sessions.isEmpty { @@ -389,34 +402,78 @@ private struct ProjectChatsList: View { .padding(.leading, 28) .padding(.vertical, 4) } else { - ForEach(sessions) { summary in - ProjectChatRow( - summary: summary, - isCurrent: windowState.currentSessionId == summary.id, - status: appState.chatStatus(forSessionId: summary.id, in: windowState), - onSelect: { onSelectSession(summary.id) }, - onRename: { onRenameSession(summary.makeSession()) }, - onTogglePin: { - Task { await appState.togglePinSession(summary.makeSession()) } - }, - onToggleArchive: { - let session = summary.makeSession() - Task { - if summary.isArchived { - await appState.unarchiveSession(session, in: windowState) - } else { - await appState.archiveSession(session, in: windowState) - } - } - }, - onDelete: { - onDeleteSession(summary.makeSession()) - } - ) + ForEach(visibleSessions) { summary in + chatRow(for: summary) + } + + if hiddenThreadCount > 0 { + threadLimitToggle } } } } + + private var threadLimitToggle: some View { + Button { + withAnimation(.easeInOut(duration: 0.16)) { + showsAllThreads.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: showsAllThreads ? "chevron.up" : "chevron.down") + .font(.system(size: ClaudeTheme.size(9), weight: .semibold)) + .frame(width: 12, height: 12) + + Text(showsAllThreads ? "Hide more" : "Show more") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + + if !showsAllThreads { + Text("\(hiddenThreadCount)") + .font(.system(size: ClaudeTheme.size(10), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.leading, 28) + .padding(.trailing, 14) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(showsAllThreads ? "Show only the first five threads" : "Show all threads in this project") + } + + private func chatRow(for summary: ChatSession.Summary) -> some View { + let sessionId = summary.id + let session = summary.makeSession() + let status = appState.chatStatus(forSessionId: sessionId, in: windowState) + let progress = appState.todoProgress(forSessionId: sessionId) + + return ProjectChatRow( + summary: summary, + isCurrent: windowState.currentSessionId == sessionId, + status: status, + todoProgress: progress, + onSelect: { onSelectSession(sessionId) }, + onRename: { onRenameSession(session) }, + onTogglePin: { + Task { await appState.togglePinSession(session) } + }, + onToggleArchive: { + Task { + if summary.isArchived { + await appState.unarchiveSession(session, in: windowState) + } else { + await appState.archiveSession(session, in: windowState) + } + } + }, + onDelete: { + onDeleteSession(session) + } + ) + } } // MARK: - All-Chats sheet