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: 7 additions & 1 deletion Packages/Sources/RxCodeCore/Models/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,24 @@ public struct Project: Identifiable, Codable, Sendable, Hashable {
public var path: String
public var gitHubRepo: String?
public var lastSessionId: String?
public var lastAgentProvider: AgentProvider?
public var lastModel: String?

public init(
id: UUID = UUID(),
name: String,
path: String,
gitHubRepo: String? = nil,
lastSessionId: String? = nil
lastSessionId: String? = nil,
lastAgentProvider: AgentProvider? = nil,
lastModel: String? = nil
) {
self.id = id
self.name = name
self.path = path
self.gitHubRepo = gitHubRepo
self.lastSessionId = lastSessionId
self.lastAgentProvider = lastAgentProvider
self.lastModel = lastModel
}
}
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
RxCode is a lightweight native macOS desktop client for Claude Code, Codex, and Agent Client Protocol (ACP) clients such as Gemini CLI and OpenCode. 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)
Expand All @@ -21,7 +21,7 @@ RxCode is a lightweight native macOS desktop client for Claude Code and Codex. I

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 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:
RxCode was built so my non-developer coworkers could use Claude Code, Codex, and ACP-compatible clients without learning a shell first. It doesn't reinvent the agent. It talks to the real runtimes underneath: `claude` for Claude Code, the Codex app-server through the `codex` CLI for Codex sessions, and ACP clients installed from the public registry. Your existing CLI setup stays the source of truth when you bring your own runtime. 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.
Expand All @@ -38,7 +38,8 @@ Same agents, no terminal required.
| 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. |
| **Claude Code, Codex, and ACP support** | Choose the agent runtime per session. RxCode detects installed `claude` and `codex` binaries, surfaces available models, and also runs ACP-compatible clients such as Gemini CLI and OpenCode. |
| **ACP registry installer** | Browse the public ACP registry from Settings, install compatible clients directly, and add registry agents to the chat model picker without manual setup. |
| **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. |
Expand All @@ -52,14 +53,14 @@ Same agents, no terminal required.

| Feature | Description |
|---------|-------------|
| **Streaming Chat** | Real-time Claude Code and Codex conversations with Markdown rendering, tool call visualization, diff views, and error bubbles for failed empty responses. |
| **Streaming Chat** | Real-time Claude Code, Codex, and ACP client 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 and Codex models with localized descriptions, including Opus, Sonnet, Haiku, 1M context, plan variants, and GPT coding models. |
| **Model Selection** | Claude Code aliases, Codex models, and ACP-advertised models with localized descriptions, including Opus, Sonnet, Haiku, 1M context, plan variants, GPT coding models, and client-provided model lists. |
| **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. |
Expand Down Expand Up @@ -87,6 +88,7 @@ Same agents, no terminal required.
- At least one supported agent runtime installed and authenticated:
- **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)**
- **Codex CLI**
- **ACP-compatible clients**, installed from Settings -> ACP Clients -> Registry or configured externally
- **Xcode with Swift 6.2+ toolchain** for building the current source tree

---
Expand Down
99 changes: 81 additions & 18 deletions RxCode/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,61 @@ final class AppState {
(openProjectWindowCounts[projectId] ?? 0) > 0
}

private func isModelAvailable(_ model: String, provider: AgentProvider) -> Bool {
availableAgentModelSections()
.flatMap(\.models)
.contains { $0.provider == provider && $0.id == model }
}

private func projectDefaultModelSelection(for project: Project?) -> (provider: AgentProvider, model: String)? {
guard let project,
let provider = project.lastAgentProvider,
let model = project.lastModel,
isModelAvailable(model, provider: provider)
else {
return nil
}
return (provider, model)
}

func defaultModelSelection(for project: Project?) -> (provider: AgentProvider, model: String) {
projectDefaultModelSelection(for: project) ?? (selectedAgentProvider, selectedModel)
}

func effectiveModelSelection(in window: WindowState) -> (provider: AgentProvider, model: String) {
if let provider = window.sessionAgentProvider, let model = window.sessionModel {
return (provider, model)
}
if let model = window.sessionModel {
return (window.sessionAgentProvider ?? selectedAgentProvider, model)
}
return defaultModelSelection(for: window.selectedProject)
}

private func rememberProjectModelSelection(_ model: String, provider: AgentProvider, in window: WindowState) {
guard let project = window.selectedProject,
let index = projects.firstIndex(where: { $0.id == project.id })
else {
return
}

guard projects[index].lastAgentProvider != provider || projects[index].lastModel != model else {
return
}

projects[index].lastAgentProvider = provider
projects[index].lastModel = model
window.selectedProject = projects[index]

Task {
do {
try await persistence.saveProjects(projects)
} catch {
logger.error("Failed to save project model selection: \(error.localizedDescription)")
}
}
}
Comment on lines +664 to +717

/// Routes a notification tap to the right window without spawning a new one.
/// Hands off to an existing project window if one is open for that project;
/// otherwise navigates the supplied main window in place.
Expand All @@ -687,6 +742,7 @@ final class AppState {
if let acpParts {
selectedACPClientId = acpParts.clientId
}
rememberProjectModelSelection(model, provider: resolvedProvider, in: window)
updateState(key) { state in
state.agentProvider = resolvedProvider
state.model = model
Expand Down Expand Up @@ -1659,8 +1715,9 @@ final class AppState {
bridge.streamingStartDate = state.streamingStartDate
bridge.liveOutputTokens = state.currentTurnOutputTokens
bridge.lastTurnContextUsedPercentage = state.lastTurnContextUsedPercentage
let provider = window.sessionAgentProvider ?? selectedAgentProvider
let currentModel = window.sessionModel ?? selectedModel
let selection = effectiveModelSelection(in: window)
let provider = selection.provider
let currentModel = selection.model
bridge.agentProvider = provider
bridge.modelDisplayName = modelDisplayName(for: currentModel, provider: provider, in: window)
bridge.sessionStats = ChatSessionStats(
Expand Down Expand Up @@ -1724,7 +1781,7 @@ 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 (window.sessionAgentProvider ?? selectedAgentProvider) == .claudeCode,
if effectiveModelSelection(in: window).provider == .claudeCode,
let sid = window.currentSessionId,
let cwd = window.selectedProject?.path,
cliStore.detectExternalActivity(sid: sid, cwd: cwd, withinSeconds: 5)
Expand Down Expand Up @@ -1912,8 +1969,11 @@ final class AppState {
let tempId = "pending-\(streamId.uuidString)"
window.currentSessionId = tempId
window.insertPendingPlaceholder(tempId)
let snapProvider = window.sessionAgentProvider ?? selectedAgentProvider
let snapModel = window.sessionModel
let snapSelection = effectiveModelSelection(in: window)
let snapProvider = snapSelection.provider
let snapModel = snapSelection.model
window.sessionAgentProvider = snapProvider
window.sessionModel = snapModel
let snapEffort = window.sessionEffort
let snapPermission = window.sessionPermissionMode
updateState(tempId) { state in
Expand Down Expand Up @@ -1948,14 +2008,15 @@ 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 provider = window.sessionAgentProvider ?? selectedAgentProvider
let selection = effectiveModelSelection(in: window)
let provider = selection.provider
let placeholder = ChatSession(
id: sessionKey,
projectId: project.id,
title: initialTitle,
messages: [],
agentProvider: provider,
model: window.sessionModel ?? selectedModel,
model: selection.model,
origin: provider.defaultSessionOrigin
)
allSessionSummaries.insert(placeholder.summary, at: 0)
Expand Down Expand Up @@ -2020,10 +2081,9 @@ 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 selection = effectiveModelSelection(in: window)
let effectiveProvider = sessionStates[sessionKey]?.agentProvider ?? selection.provider
let effectiveModel = sessionStates[sessionKey]?.model ?? selection.model

let task = Task { [weak self, window] in
guard let self else { return }
Expand Down Expand Up @@ -2895,7 +2955,7 @@ final class AppState {
sessionStates[key]?.activeStreamId = nil

if let streamToCancel {
let provider = sessionStates[key]?.agentProvider ?? window.sessionAgentProvider ?? selectedAgentProvider
let provider = sessionStates[key]?.agentProvider ?? effectiveModelSelection(in: window).provider
switch provider {
case .claudeCode:
await claude.cancel(streamId: streamToCancel)
Expand Down Expand Up @@ -3717,10 +3777,11 @@ final class AppState {
}
// Persist via sidecar meta
let snap = allSessionSummaries.first(where: { $0.id == sessionId })
let fallbackProvider = defaultModelSelection(for: project).provider
let updated = (snap ?? ChatSession.Summary(
id: sessionId, projectId: project.id, title: ChatSession.defaultTitle,
createdAt: Date(), updatedAt: Date(), isPinned: false,
agentProvider: sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider,
agentProvider: sessionStates[sessionId]?.agentProvider ?? fallbackProvider,
worktreePath: info.path.path, worktreeBranch: info.branch
)).makeSession()
await updateSessionMetadata(updated) { s in
Expand Down Expand Up @@ -4050,12 +4111,13 @@ final class AppState {
/// indexed yet (e.g. brand-new session whose `.result` arrived before the
/// summary list refresh).
private func summaryFor(sessionId: String, projectId: UUID) -> ChatSession.Summary {
allSessionSummaries.first(where: { $0.id == sessionId })
let fallbackProvider = defaultModelSelection(for: projects.first { $0.id == projectId }).provider
return allSessionSummaries.first(where: { $0.id == sessionId })
?? ChatSession.Summary(
id: sessionId, projectId: projectId, title: "",
createdAt: Date(), updatedAt: Date(), isPinned: false,
agentProvider: sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider,
origin: (sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider).defaultSessionOrigin
agentProvider: sessionStates[sessionId]?.agentProvider ?? fallbackProvider,
origin: (sessionStates[sessionId]?.agentProvider ?? fallbackProvider).defaultSessionOrigin
)
}

Expand Down Expand Up @@ -4389,7 +4451,8 @@ final class AppState {
await permission.refreshRunToken()

let currentPermissionMode = sessionStates[sessionKey]?.permissionMode ?? permissionMode
let agentProvider = sessionStates[sessionKey]?.agentProvider ?? selectedAgentProvider
let projectSelection = defaultModelSelection(for: projects.first { $0.id == projectId })
let agentProvider = sessionStates[sessionKey]?.agentProvider ?? projectSelection.provider
var hookSettingsPath: String?
if agentProvider == .claudeCode, !currentPermissionMode.skipsHookPipeline {
do { hookSettingsPath = try await permission.writeHookSettingsFile() }
Expand All @@ -4398,7 +4461,7 @@ final class AppState {

await permission.registerSession(sid: sessionKey, projectKey: cwd, mode: currentPermissionMode)

let model = sessionStates[sessionKey]?.model ?? selectedModel
let model = sessionStates[sessionKey]?.model ?? projectSelection.model
let effort = sessionStates[sessionKey]?.effort ?? (selectedEffort == "auto" ? nil : selectedEffort)
let task = Task { [weak self, window] in
guard let self else { return }
Expand Down
8 changes: 4 additions & 4 deletions RxCode/Views/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,8 @@ 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 }
private var effectiveModel: String { appState.effectiveModelSelection(in: windowState).model }
private var effectiveProvider: AgentProvider { appState.effectiveModelSelection(in: windowState).provider }
Comment on lines +452 to +453

var body: some View {
HStack(spacing: placement == .composer ? 8 : 4) {
Expand Down Expand Up @@ -788,8 +788,8 @@ struct ModelPickerSheet: View {
@State private var selectedIndex: Int = 0
@FocusState private var isFocused: Bool

private var effectiveModel: String { windowState.sessionModel ?? appState.selectedModel }
private var effectiveProvider: AgentProvider { windowState.sessionAgentProvider ?? appState.selectedAgentProvider }
private var effectiveModel: String { appState.effectiveModelSelection(in: windowState).model }
private var effectiveProvider: AgentProvider { appState.effectiveModelSelection(in: windowState).provider }
private var flatModels: [AgentModel] { appState.availableAgentModelSections().flatMap(\.models) }

var body: some View {
Expand Down
Loading