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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ RxCode is a native macOS desktop client for the Codex CLI. Written in Swift + Sw

## Writing Rules

- All text committed to the project — code comments, commit messages, PR descriptions, log messages — must be written in **English**. Chat responses to the user remain in Korean.
- All text committed to the project — code comments, commit messages, PR descriptions, log messages — must be written in **English**.

## Build & Run

Expand Down Expand Up @@ -41,8 +41,8 @@ xcodebuild -project RxCode.xcodeproj -scheme RxCode -configuration Release build

The codebase is split into two Swift packages under `Packages/`:

| Package | Role |
| -------------- | ---------------------------------------------------------------- |
| Package | Role |
| --------------- | ---------------------------------------------------------------- |
| `RxCodeCore` | Shared models, theme, utilities — no UI dependencies |
| `RxCodeChatKit` | Chat UI components (ChatView, MessageBubble, InputBarView, etc.) |

Expand All @@ -53,7 +53,7 @@ The codebase is split into two Swift packages under `Packages/`:
| `ClaudeService` | Spawns Codex CLI as a subprocess, parses stdout NDJSON stream, buffers text deltas at 50ms intervals |
| `PermissionServer` | Network framework-based local HTTP server (ports 19836–19846). Receives CLI PreToolUse hook requests and holds the connection until UI approval |
| `GitHubService` | OAuth Device Flow authentication, Keychain token storage, SSH key generation/registration, repo cloning |
| `PersistenceService` | JSON file-based persistence at `~/Library/Application Support/RxCode/`. Per-project/session directory structure |
| `PersistenceService` | JSON file-based persistence at `~/Library/Application Support/RxCode/`. Per-project/session directory structure |
| `MarketplaceService` | Parallel fetch of plugin catalog from 4 Anthropic GitHub repos, 5-minute cache |
| `RateLimitService` | Anthropic usage API polling, OAuth token refresh, usage tracking |
| `UpdateService` | Sparkle-based auto-update manager. Starts updater on launch; exposes `checkForUpdates()` for menu-initiated checks |
Expand Down
28 changes: 14 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ RxCode is a native macOS desktop client for the Claude Code CLI. Written in Swif

## Writing Rules

- All text committed to the project — code comments, commit messages, PR descriptions, log messages — must be written in **English**. Chat responses to the user remain in Korean.
- All text committed to the project — code comments, commit messages, PR descriptions, log messages — must be written in **English**.

## Build & Run

Expand Down Expand Up @@ -41,23 +41,23 @@ xcodebuild -project RxCode.xcodeproj -scheme RxCode -configuration Release build

The codebase is split into two Swift packages under `Packages/`:

| Package | Role |
|---------|------|
| `RxCodeCore` | Shared models, theme, utilities — no UI dependencies |
| Package | Role |
| --------------- | ---------------------------------------------------------------- |
| `RxCodeCore` | Shared models, theme, utilities — no UI dependencies |
| `RxCodeChatKit` | Chat UI components (ChatView, MessageBubble, InputBarView, etc.) |

### Service Layer (`Services/`)

| Service | Role |
|---------|------|
| `ClaudeService` | Spawns Claude CLI as a subprocess, parses stdout NDJSON stream, buffers text deltas at 50ms intervals |
| `PermissionServer` | Network framework-based local HTTP server (ports 19836–19846). Receives CLI PreToolUse hook requests and holds the connection until UI approval |
| `GitHubService` | OAuth Device Flow authentication, Keychain token storage, SSH key generation/registration, repo cloning |
| `PersistenceService` | JSON file-based persistence at `~/Library/Application Support/RxCode/`. Per-project/session directory structure |
| `MarketplaceService` | Parallel fetch of plugin catalog from 4 Anthropic GitHub repos, 5-minute cache |
| `RateLimitService` | Anthropic usage API polling, OAuth token refresh, usage tracking |
| `UpdateService` | Sparkle-based auto-update manager. Starts updater on launch; exposes `checkForUpdates()` for menu-initiated checks |
| `BashSafety` | Whitelist-based read-only command validator. Blocks mutating git/claude/npm subcommands and write redirections |
| Service | Role |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `ClaudeService` | Spawns Claude CLI as a subprocess, parses stdout NDJSON stream, buffers text deltas at 50ms intervals |
| `PermissionServer` | Network framework-based local HTTP server (ports 19836–19846). Receives CLI PreToolUse hook requests and holds the connection until UI approval |
| `GitHubService` | OAuth Device Flow authentication, Keychain token storage, SSH key generation/registration, repo cloning |
| `PersistenceService` | JSON file-based persistence at `~/Library/Application Support/RxCode/`. Per-project/session directory structure |
| `MarketplaceService` | Parallel fetch of plugin catalog from 4 Anthropic GitHub repos, 5-minute cache |
| `RateLimitService` | Anthropic usage API polling, OAuth token refresh, usage tracking |
| `UpdateService` | Sparkle-based auto-update manager. Starts updater on launch; exposes `checkForUpdates()` for menu-initiated checks |
| `BashSafety` | Whitelist-based read-only command validator. Blocks mutating git/claude/npm subcommands and write redirections |

### Data Flow

Expand Down
105 changes: 104 additions & 1 deletion Packages/Sources/RxCodeCore/Models/MCPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ public enum MCPScope: String, Codable, Sendable, CaseIterable, Identifiable {
}
}

public enum MCPProjectOverride: String, Codable, Sendable, CaseIterable, Identifiable {
case inherit
case enabled
case disabled

public var id: String { rawValue }

public var displayName: String {
switch self {
case .inherit: return "Inherit"
case .enabled: return "On"
case .disabled: return "Off"
}
}
}

public enum MCPProvider: String, Codable, Sendable, CaseIterable, Identifiable {
case claudeCode
case codex

public var id: String { rawValue }
}

// MARK: - Status

public enum MCPStatus: Sendable, Equatable {
Expand Down Expand Up @@ -75,14 +98,30 @@ public struct MCPServerInfo: Identifiable, Sendable, Equatable, Hashable {
public let status: MCPStatus
public var scope: MCPScope?
public var projectPath: String?
public var isGloballyEnabled: Bool
public var projectOverride: MCPProjectOverride
public var effectiveEnabled: Bool

public init(name: String, transport: MCPTransport, endpoint: String, status: MCPStatus, scope: MCPScope? = nil, projectPath: String? = nil) {
public init(
name: String,
transport: MCPTransport,
endpoint: String,
status: MCPStatus,
scope: MCPScope? = nil,
projectPath: String? = nil,
isGloballyEnabled: Bool = true,
projectOverride: MCPProjectOverride = .inherit,
effectiveEnabled: Bool = true
) {
self.name = name
self.transport = transport
self.endpoint = endpoint
self.status = status
self.scope = scope
self.projectPath = projectPath
self.isGloballyEnabled = isGloballyEnabled
self.projectOverride = projectOverride
self.effectiveEnabled = effectiveEnabled
}

public func hash(into hasher: inout Hasher) {
Expand Down Expand Up @@ -144,6 +183,70 @@ public struct MCPServerSpec: Sendable, Equatable {
}
}

public struct MCPServerRecord: Codable, Sendable, Equatable, Identifiable {
public var id: String { name }
public var name: String
public var transport: MCPTransport
public var url: String?
public var command: String?
public var args: [String]
public var env: [String: String]
public var headers: [String: String]
public var cwd: String?
public var bearerTokenEnvVar: String?
public var isGloballyEnabled: Bool
public var projectOverrides: [String: MCPProjectOverride]

public init(
name: String,
transport: MCPTransport,
url: String? = nil,
command: String? = nil,
args: [String] = [],
env: [String: String] = [:],
headers: [String: String] = [:],
cwd: String? = nil,
bearerTokenEnvVar: String? = nil,
isGloballyEnabled: Bool = true,
projectOverrides: [String: MCPProjectOverride] = [:]
) {
self.name = name
self.transport = transport
self.url = url
self.command = command
self.args = args
self.env = env
self.headers = headers
self.cwd = cwd
self.bearerTokenEnvVar = bearerTokenEnvVar
self.isGloballyEnabled = isGloballyEnabled
self.projectOverrides = projectOverrides
}

public func projectOverride(for projectPath: String?) -> MCPProjectOverride {
guard let projectPath, !projectPath.isEmpty else { return .inherit }
return projectOverrides[projectPath] ?? .inherit
}

public func isEnabled(for projectPath: String?) -> Bool {
switch projectOverride(for: projectPath) {
case .inherit: return isGloballyEnabled
case .enabled: return true
case .disabled: return false
}
}
}

public struct MCPConfiguration: Codable, Sendable, Equatable {
public var version: Int
public var servers: [MCPServerRecord]

public init(version: Int = 1, servers: [MCPServerRecord] = []) {
self.version = version
self.servers = servers
}
}

public struct MCPKeyValue: Sendable, Equatable, Identifiable {
public let id: UUID
public var key: String
Expand Down
13 changes: 13 additions & 0 deletions Packages/Sources/RxCodeCore/Models/StreamEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public enum StreamEvent: Sendable {
case user(UserMessage)
case result(ResultEvent)
case rateLimitEvent(RateLimitInfo)
case todoSnapshot(TodoSnapshotEvent)
case unknown(String)
}

Expand Down Expand Up @@ -67,6 +68,18 @@ public struct UserMessage: Sendable {
}
}

// MARK: - Todo Snapshot Event

public struct TodoSnapshotEvent: Sendable {
public let sessionId: String?
public let items: [TodoItem]

public init(sessionId: String?, items: [TodoItem]) {
self.sessionId = sessionId
self.items = items
}
}

// MARK: - Usage Info

public struct UsageInfo: Sendable {
Expand Down
27 changes: 27 additions & 0 deletions Packages/Sources/RxCodeCore/Models/TodoItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,31 @@ public enum TodoExtractor {
return TodoItem(id: index, content: content, activeForm: activeForm, status: status)
}
}

/// Parse Codex app-server `turn/plan/updated` params into the same todo
/// shape used by `TodoWrite`.
public static func parseCodexPlanUpdate(params: [String: JSONValue]) -> [TodoItem]? {
guard let array = params["plan"]?.arrayValue else { return nil }
return array.enumerated().map { index, value in
let step = value["step"]?.stringValue ?? ""
let rawStatus = value["status"]?.stringValue ?? ""
return TodoItem(
id: index,
content: step,
activeForm: step,
status: parseCodexPlanStatus(rawStatus)
)
}
}

private static func parseCodexPlanStatus(_ rawStatus: String) -> TodoItem.Status {
switch rawStatus {
case "completed":
return .completed
case "inProgress", "in_progress":
return .inProgress
default:
return .pending
}
}
}
4 changes: 2 additions & 2 deletions Packages/Sources/RxCodeCore/Models/TodoSnapshot.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Foundation
import SwiftData

/// Persisted snapshot of the latest `TodoWrite` tool call for a chat session.
/// One row per session id; updated each time the CLI emits a new TodoWrite.
/// Persisted snapshot of the latest todo-style progress for a chat session.
/// One row per session id; updated from Claude `TodoWrite` or Codex plan events.
@Model
public final class TodoSnapshot {
@Attribute(.unique) public var sessionId: String
Expand Down
44 changes: 44 additions & 0 deletions Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Testing
@testable import RxCodeCore

@Suite("Todo extraction")
struct TodoExtractorTests {

@Test("Codex plan update maps steps to todos")
func codexPlanUpdateMapsStepsToTodos() {
let todos = TodoExtractor.parseCodexPlanUpdate(params: [
"threadId": .string("thread-1"),
"turnId": .string("turn-1"),
"plan": .array([
.object([
"step": .string("Inspect app-server schema"),
"status": .string("completed")
]),
.object([
"step": .string("Wire plan updates into todo UI"),
"status": .string("inProgress")
]),
.object([
"step": .string("Run focused tests"),
"status": .string("pending")
])
])
])

#expect(todos == [
TodoItem(id: 0, content: "Inspect app-server schema", activeForm: "Inspect app-server schema", status: .completed),
TodoItem(id: 1, content: "Wire plan updates into todo UI", activeForm: "Wire plan updates into todo UI", status: .inProgress),
TodoItem(id: 2, content: "Run focused tests", activeForm: "Run focused tests", status: .pending)
])
}

@Test("Missing Codex plan returns nil")
func missingCodexPlanReturnsNil() {
let todos = TodoExtractor.parseCodexPlanUpdate(params: [
"threadId": .string("thread-1"),
"turnId": .string("turn-1")
])

#expect(todos == nil)
}
}
Loading