some View {
+ Group {
+ if let url, url.isFileURL {
+ LocalFileImage(url: url)
+ } else {
+ DefaultImageProvider.default.makeImage(url: url)
+ }
+ }
+ }
+}
+
+private struct LocalFileImage: View {
+ let url: URL
+
+ var body: some View {
+ if let loaded = LocalFileImage.load(url: url) {
+ loaded.image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(maxWidth: loaded.size.width)
+ } else {
+ Text("⚠︎ \(url.lastPathComponent)")
+ .font(.caption)
+ .foregroundStyle(ClaudeTheme.textTertiary)
+ }
+ }
+
+ private struct Loaded {
+ let image: Image
+ let size: CGSize
+ }
+
+ private static func load(url: URL) -> Loaded? {
+ #if canImport(AppKit)
+ guard let nsImage = NSImage(contentsOf: url) else { return nil }
+ return Loaded(image: Image(nsImage: nsImage), size: nsImage.size)
+ #elseif canImport(UIKit)
+ guard let data = try? Data(contentsOf: url),
+ let uiImage = UIImage(data: data) else { return nil }
+ return Loaded(image: Image(uiImage: uiImage), size: uiImage.size)
+ #else
+ return nil
+ #endif
+ }
+}
+
// MARK: - Markdown Link Helpers
func sanitizeMarkdownLinkURLs(_ text: String) -> String {
diff --git a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings
index 3d96fd5c..86cb5f50 100644
--- a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings
+++ b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings
@@ -224,6 +224,9 @@
}
}
}
+ },
+ "⚠︎ %@" : {
+
},
"$" : {
"localizations" : {
diff --git a/README.md b/README.md
index cc6de79c..08e35b06 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ RxCode brings Claude Code, Codex, and Agent Client Protocol (ACP) clients into o



+[](https://discord.gg/ytt8SjRRNN)
@@ -164,6 +165,10 @@ xcodebuild -project RxCode.xcodeproj -scheme RxCode -configuration Release build
| `website/` | Public website and screenshot assets used by this README. |
| `scripts/` | Build, notarization, Sparkle signing, and release automation. |
+## Community
+
+Join the RxCode Discord to ask questions, share feedback, and follow development: [discord.gg/ytt8SjRRNN](https://discord.gg/ytt8SjRRNN).
+
## License
Apache License 2.0. See [LICENSE](LICENSE) for details.
diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj
index 107a9c0d..a7149dde 100644
--- a/RxCode.xcodeproj/project.pbxproj
+++ b/RxCode.xcodeproj/project.pbxproj
@@ -357,9 +357,17 @@
path = RxCodeUITests;
sourceTree = "";
};
+ DF46311C2FC97C0D002D9562 /* UserMenus */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ path = UserMenus;
+ sourceTree = "";
+ };
E673352F2F7356F600FD26C7 = {
isa = PBXGroup;
children = (
+ DF46311C2FC97C0D002D9562 /* UserMenus */,
DF23F7352FB8C3EC008929A6 /* icon.icon */,
DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */,
DF5B0DDA2FC023BE000CE36F /* MobileUITestPlan-iPhone.xctestplan */,
diff --git a/RxCode/App/AppState+Agents.swift b/RxCode/App/AppState+Agents.swift
index c7e7b8bc..10be876e 100644
--- a/RxCode/App/AppState+Agents.swift
+++ b/RxCode/App/AppState+Agents.swift
@@ -16,39 +16,6 @@ extension AppState {
await memoryService.search(query, projectId: projectId, limit: limit)
}
- func systemPromptMemoryItems(projectId: UUID?, provider: AgentProvider, model: String?) async -> [MemoryItem] {
- let items = await memoryService.allMemories()
- var injected: [MemoryItem] = []
- for item in items {
- if let memoryProjectId = item.projectId {
- guard memoryProjectId == projectId else { continue }
- }
- if await shouldInjectMemoryIntoSystemPrompt(item, provider: provider, model: model) {
- injected.append(item)
- }
- }
- return injected
- }
-
- func shouldInjectMemoryIntoSystemPrompt(_ item: MemoryItem, provider: AgentProvider, model: String?) async -> Bool {
- let cacheKey = memoryInjectionIntentCacheKey(for: item, provider: provider, model: model)
- if let cached = memoryInjectionIntentCache[cacheKey] {
- return cached
- }
-
- let rawDecision = await generateMemoryInjectionIntent(
- content: item.content,
- kind: item.kind,
- scope: item.scope,
- provider: provider,
- model: model
- )
- let decision = Self.parseMemoryInjectionDecision(rawDecision)
- ?? Self.fallbackShouldInjectMemoryIntoSystemPrompt(item)
- memoryInjectionIntentCache[cacheKey] = decision
- return decision
- }
-
@discardableResult
func addMemoryItem(content: String, projectId: UUID?, kind: String = "fact", scope: String = "project") async -> MemoryItem? {
guard memoryEnabled else { return nil }
@@ -90,35 +57,24 @@ extension AppState {
memoryRevision &+= 1
}
- func memoryContextSystemPrompt(
- systemItems: [MemoryItem],
- relatedHits: [MemoryService.Hit]
- ) -> String {
- guard memoryEnabled, memoryInjectEnabled, !systemItems.isEmpty || !relatedHits.isEmpty else { return "" }
+ func memoryContextSystemPrompt(relatedHits: [MemoryService.Hit]) -> String {
+ guard memoryEnabled, memoryInjectEnabled, !relatedHits.isEmpty else { return "" }
- let systemIds = Set(systemItems.map(\.id))
- let systemLines = systemItems.enumerated().map { idx, item in
- "\(idx + 1). \(item.content)"
- }.joined(separator: "\n")
let relatedLines = relatedHits
- .filter { !systemIds.contains($0.item.id) }
.prefix(memoryMaxContextItems)
.enumerated()
.map { idx, hit in
"\(idx + 1). \(hit.item.content)"
}
.joined(separator: "\n")
- let sections = [
- systemLines.isEmpty ? nil : "Always apply these saved user preferences and recurring instructions:\n\(systemLines)",
- relatedLines.isEmpty ? nil : "Related memories for this turn:\n\(relatedLines)"
- ].compactMap { $0 }.joined(separator: "\n\n")
return """
# Relevant user memory
The notes below are durable user/project memories saved locally in RxCode. Use them as background context for this turn. They may be incomplete or stale; the current user message still has priority.
- \(sections)
+ Related memories for this turn:
+ \(relatedLines)
"""
}
@@ -133,79 +89,14 @@ extension AppState {
"""
}
- private func generateMemoryInjectionIntent(
- content: String,
- kind: String,
- scope: String,
- provider: AgentProvider,
- model: String?
- ) async -> String? {
- switch summarizationProvider {
- case .selectedClient:
- let selectedModel = model ?? selectedSummarizationModel(for: provider)
- switch provider {
- case .claudeCode:
- return await claude.determineMemoryInjectionIntent(
- content: content,
- kind: kind,
- scope: scope,
- model: selectedModel ?? "haiku"
- )
- case .codex:
- return await codex.determineMemoryInjectionIntent(
- content: content,
- kind: kind,
- scope: scope,
- model: selectedModel
- )
- case .acp:
- return nil
- }
- case .openAI:
- guard !openAISummarizationModel.isEmpty else { return nil }
- return await openAISummarization.determineMemoryInjectionIntent(
- content: content,
- kind: kind,
- scope: scope,
- endpoint: openAISummarizationEndpoint,
- apiKey: openAISummarizationAPIKey,
- model: openAISummarizationModel
- )
- case .appleFoundationModel:
- return await foundationModelSummarization.determineMemoryInjectionIntent(
- content: content,
- kind: kind,
- scope: scope
- )
- }
- }
-
- private func memoryInjectionIntentCacheKey(for item: MemoryItem, provider: AgentProvider, model: String?) -> String {
- [
- item.id,
- item.updatedAt.timeIntervalSince1970.description,
- item.kind,
- item.scope,
- item.content,
- summarizationProvider.rawValue,
- provider.rawValue,
- model ?? "",
- selectedAgentProvider.rawValue,
- selectedModel,
- openAISummarizationEndpoint,
- openAISummarizationModel
- ].joined(separator: "\u{1f}")
- }
-
func scheduleMemoryExtraction(
sessionId: String,
projectId: UUID,
messages: [ChatMessage]
) {
guard memoryEnabled, memoryAutoCreateEnabled else { return }
- let userMessage = lastUserMessageText(in: messages)
- let finalResponse = lastAssistantResponseText(in: messages)
- guard !userMessage.isEmpty, !finalResponse.isEmpty else { return }
+ let userMessages = userMessageTexts(in: messages)
+ guard !userMessages.isEmpty else { return }
let sourceMessageId = messages.last(where: { $0.role == .user && !$0.isError })?.id
let summary = allSessionSummaries.first(where: { $0.id == sessionId })
?? summaryFor(sessionId: sessionId, projectId: projectId)
@@ -216,8 +107,7 @@ extension AppState {
sessionId: sessionId,
projectId: projectId,
sourceMessageId: sourceMessageId,
- userMessage: userMessage,
- finalResponse: finalResponse,
+ userMessages: userMessages,
summary: summary
)
}
@@ -227,20 +117,19 @@ extension AppState {
sessionId: String,
projectId: UUID,
sourceMessageId: UUID?,
- userMessage: String,
- finalResponse: String,
+ userMessages: [String],
summary: ChatSession.Summary
) async {
+ let memorySource = userMessages.joined(separator: "\n\n")
let relatedHits = await memoryService.search(
- "\(userMessage)\n\(finalResponse)",
+ memorySource,
projectId: projectId,
limit: 6
)
let related = relatedHits.map { (id: $0.item.id, content: $0.item.content) }
guard let raw = await generateMemoryOperations(
existingMemories: related,
- userMessage: userMessage,
- finalResponse: finalResponse,
+ userMessages: userMessages,
summary: summary
) else { return }
let operations = Self.parseMemoryOperations(raw)
@@ -321,19 +210,6 @@ extension AppState {
}
}
- static func parseMemoryInjectionDecision(_ raw: String?) -> Bool? {
- guard let raw else { return nil }
- let trimmed = stripJSONFence(raw)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
- .lowercased()
- if ["true", "yes", "inject"].contains(trimmed) { return true }
- if ["false", "no", "skip"].contains(trimmed) { return false }
- if trimmed.hasPrefix("true") { return true }
- if trimmed.hasPrefix("false") { return false }
- return nil
- }
-
static func stripJSONFence(_ raw: String) -> String {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if text.hasPrefix("```") {
diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift
index c261e4a3..e49bba44 100644
--- a/RxCode/App/AppState+CrossProject.swift
+++ b/RxCode/App/AppState+CrossProject.swift
@@ -6,161 +6,6 @@ import RxCodeSync
import SwiftUI
extension AppState {
- // MARK: - Cross-Project Send (used by ide__send_to_thread)
-
- struct CrossProjectSendResult: Sendable {
- let threadId: String
- let projectId: UUID
- let done: Bool
- let assistantText: String
- let error: String?
- }
-
- enum CrossProjectSendError: Error, LocalizedError {
- case unknownProject(UUID)
- case unknownThread(String)
-
- var errorDescription: String? {
- switch self {
- case .unknownProject(let id): return "No project with id \(id.uuidString)"
- case .unknownThread(let id): return "No thread with id \(id)"
- }
- }
- }
-
- /// Send a prompt to a thread in any project. The send runs through the
- /// normal `sendPrompt` pipeline via a synthetic `WindowState`, so all the
- /// usual side-effects (title generation, briefing updates, persistence)
- /// still fire and any UI windows currently bound to the same session see
- /// the assistant tokens live via the shared `sessionStates` dictionary.
- func sendCrossProject(
- projectId: UUID?,
- threadId: String?,
- prompt: String,
- agentProvider: AgentProvider? = nil,
- model: String? = nil,
- effort: String? = nil,
- permissionMode: PermissionMode? = nil,
- waitForResponse: Bool = true,
- timeoutSeconds: TimeInterval = 120
- ) async throws -> CrossProjectSendResult {
- // Resolve target project + thread.
- let resolvedProject: Project
- let resolvedThreadId: String?
-
- if let threadId {
- guard let summary = allSessionSummaries.first(where: { $0.id == threadId })
- ?? threadStore.fetch(id: threadId).map({ $0.toSummary() })
- else {
- throw CrossProjectSendError.unknownThread(threadId)
- }
- guard let proj = projects.first(where: { $0.id == summary.projectId }) else {
- throw CrossProjectSendError.unknownProject(summary.projectId)
- }
- resolvedProject = proj
- resolvedThreadId = threadId
- } else if let projectId {
- guard let proj = projects.first(where: { $0.id == projectId }) else {
- throw CrossProjectSendError.unknownProject(projectId)
- }
- resolvedProject = proj
- resolvedThreadId = nil
- } else {
- throw CrossProjectSendError.unknownProject(UUID())
- }
-
- // Build a synthetic WindowState. AppState.sessionStates is shared across
- // windows, so the message + stream are visible to any real window that
- // happens to also be viewing this session.
- let window = WindowState()
- window.selectedProject = resolvedProject
- window.currentSessionId = resolvedThreadId
-
- // Carry over per-session overrides for a new thread; for an existing
- // thread we leave the session's own stored values alone (the resume
- // path in sendPrompt reads from `sessionStates[sessionKey]`).
- if resolvedThreadId == nil {
- if let agentProvider {
- window.sessionAgentProvider = agentProvider
- }
- if let model {
- window.sessionModel = model
- }
- if let effort {
- window.sessionEffort = effort
- }
- if let permissionMode {
- window.sessionPermissionMode = permissionMode
- }
- }
-
- guard let streamId = await sendPrompt(prompt, displayText: prompt, in: window) else {
- return CrossProjectSendResult(
- threadId: resolvedThreadId ?? "",
- projectId: resolvedProject.id,
- done: false,
- assistantText: "",
- error: "Send failed: no session could be allocated."
- )
- }
-
- // After sendPrompt returns, window.currentSessionId is the (possibly
- // pending-) key the stream is bound to. Resolve the real CLI session
- // id before returning so the caller's agent never sees `pending-…`
- // (which it can't use to follow up via `get_thread_messages` etc.).
- let postSendKey = window.currentSessionId ?? resolvedThreadId ?? ""
- let resolvedThreadIdForReturn: String
- if postSendKey.hasPrefix("pending-") {
- // Cap the rename wait at the request's timeout so we still honor
- // the caller's deadline; 60s upper bound matches typical first-token
- // latency under healthy conditions.
- let renameTimeout = min(max(timeoutSeconds, 1), 60)
- resolvedThreadIdForReturn = await awaitSessionRename(
- pendingKey: postSendKey,
- timeout: renameTimeout
- ) ?? postSendKey
- } else {
- resolvedThreadIdForReturn = postSendKey
- }
-
- if !waitForResponse {
- // Don't leak the result in the dictionary — the caller is
- // fire-and-forget. Drop it once it lands.
- Task { [weak self] in
- _ = await self?.awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds)
- }
- return CrossProjectSendResult(
- threadId: resolvedThreadIdForReturn,
- projectId: resolvedProject.id,
- done: false,
- assistantText: "",
- error: nil
- )
- }
-
- let completion = await awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds)
- if let completion {
- return CrossProjectSendResult(
- threadId: completion.sessionId,
- projectId: resolvedProject.id,
- done: completion.error == nil,
- assistantText: completion.assistantText,
- error: completion.error
- )
- } else {
- // Timed out. Surface the partial assistant text we have so far so
- // the caller can decide whether to poll back via get_thread_messages.
- let partial = lastAssistantResponseText(in: stateForSession(window.currentSessionId ?? "").messages)
- return CrossProjectSendResult(
- threadId: resolvedThreadIdForReturn,
- projectId: resolvedProject.id,
- done: false,
- assistantText: partial,
- error: nil
- )
- }
- }
-
/// Drop "No response requested." text blocks from the assistant message
/// at `idx`. The marker is the model's response when a turn arrives
/// without a user prompt (ScheduleWakeup, hook re-entry) and reads as
@@ -233,7 +78,7 @@ extension AppState {
// user's dropdown choice (e.g. `.auto`) should still drive the hook policy.
let registerMode = hookSessionMode ?? permissionMode
let streamStart = Date()
- logger.info("[Stream:UI] starting processStream (cli=\(cliSessionId ?? "new"), key=\(internalSessionKey))")
+ logger.info("[Stream:UI] starting processStream provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) cli=\(cliSessionId ?? "new", privacy: .public) key=\(internalSessionKey, privacy: .public)")
var sessionKey = internalSessionKey
@@ -273,16 +118,29 @@ extension AppState {
// "captured var" warning for `sessionKey`.
let memoryActive = memoryEnabled && memoryInjectEnabled
let memoryLimit = memoryMaxContextItems
+ let memoryMode = memoryRetrievalMode
+ let memoryMinScore = memoryInjectionScoreThreshold
let capturedSessionKey = sessionKey
let memoryService = self.memoryService
let marketplace = self.marketplace
let ideMCPServer = self.ideMCPServer
- async let memoryItemsAsync: [MemoryItem] = memoryActive
- ? await systemPromptMemoryItems(projectId: projectId, provider: agentProvider, model: model)
- : []
+ func logPreflight(_ label: String, detail: String = "") {
+ let elapsed = Date().timeIntervalSince(streamStart)
+ if detail.isEmpty {
+ logger.info("[Stream:UI] preflight \(label, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", elapsed), privacy: .public)s")
+ } else {
+ logger.info("[Stream:UI] preflight \(label, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", elapsed), privacy: .public)s \(detail, privacy: .public)")
+ }
+ }
+
async let memoryHitsAsync = memoryActive
- ? await memoryService.search(prompt, projectId: projectId, limit: memoryLimit)
+ ? await memoryService.search(
+ prompt,
+ projectId: projectId,
+ limit: memoryLimit,
+ minScore: memoryMinScore
+ )
: []
async let currentBranchAsync = GitHelper.currentBranch(at: cwd)
async let idePortAsync = ideMCPServer.allocate(
@@ -295,13 +153,19 @@ extension AppState {
: []
let resolvedMemoryContext: String
+ let memoryHitCount: Int
if memoryActive {
- let systemItems = await memoryItemsAsync
let hits = await memoryHitsAsync
- resolvedMemoryContext = memoryContextSystemPrompt(systemItems: systemItems, relatedHits: hits)
+ memoryHitCount = hits.count
+ resolvedMemoryContext = memoryContextSystemPrompt(relatedHits: hits)
} else {
+ memoryHitCount = 0
resolvedMemoryContext = ""
}
+ logPreflight(
+ "memory",
+ detail: "enabled=\(memoryActive) mode=\(memoryMode.title) minScore=\(String(format: "%.2f", memoryMinScore)) hits=\(memoryHitCount) contextChars=\(resolvedMemoryContext.count)"
+ )
let branchBriefingContext: String
if let branch = await currentBranchAsync,
@@ -310,8 +174,10 @@ extension AppState {
branch: branch,
briefing: briefing.briefing
)
+ logPreflight("branchBriefing", detail: "branch=\(branch) contextChars=\(branchBriefingContext.count)")
} else {
branchBriefingContext = ""
+ logPreflight("branchBriefing", detail: "contextChars=0")
}
// The IDE-MCP port is provider-agnostic at allocation time — the
@@ -320,26 +186,37 @@ extension AppState {
// bridge, but they no longer block memory/git/skill resolution.
let idePort = await idePortAsync
let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) }
+ logPreflight("ideMCP", detail: "port=\(idePort.map(String.init) ?? "")")
switch agentProvider {
case .claudeCode:
mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd, bridgeCommand: bridge)
+ logPreflight("claudeMCP", detail: "hasConfig=\(mcpClaudeConfigPath != nil)")
// Surface the accumulated briefing for the project's current branch
// to the agent as background context via `--append-system-prompt`.
appendExtraSystemPrompt(branchBriefingContext)
appendExtraSystemPrompt(resolvedMemoryContext)
if let skillContext = await skillContextAsync {
+ logPreflight("skillContext", detail: "chars=\(skillContext.count)")
appendExtraSystemPrompt(skillContext)
+ } else {
+ logPreflight("skillContext", detail: "chars=0")
}
case .codex:
mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd, bridgeCommand: bridge)
- mcpCodexOverrides += await codexSkillOverridesAsync
+ logPreflight("codexMCP", detail: "args=\(mcpCodexOverrides.count)")
+ let codexSkillOverrides = await codexSkillOverridesAsync
+ logPreflight("codexSkillOverrides", detail: "args=\(codexSkillOverrides.count)")
+ mcpCodexOverrides += codexSkillOverrides
resolvedPrompt = Self.promptWithBackgroundContext(
[branchBriefingContext, resolvedMemoryContext],
prompt: resolvedPrompt
)
if let skillContext = await skillContextAsync {
+ logPreflight("skillContext", detail: "chars=\(skillContext.count)")
resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)"
+ } else {
+ logPreflight("skillContext", detail: "chars=0")
}
resolvedSendMode = registerMode
case .acp:
@@ -347,12 +224,16 @@ extension AppState {
projectPath: cwd,
bridgeCommand: bridge
)
+ logPreflight("acpMCP", detail: "servers=\(acpMCPServers.count)")
resolvedPrompt = Self.promptWithBackgroundContext(
[branchBriefingContext, resolvedMemoryContext],
prompt: resolvedPrompt
)
if let skillContext = await skillContextAsync {
+ logPreflight("skillContext", detail: "chars=\(skillContext.count)")
resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)"
+ } else {
+ logPreflight("skillContext", detail: "chars=0")
}
// `model` may be a composite `::` key (from the picker)
// or a bare model id (from a per-session override).
@@ -386,6 +267,8 @@ extension AppState {
if let earlyStream {
stream = earlyStream
} else {
+ let preflightElapsed = Date().timeIntervalSince(streamStart)
+ logger.info("[Stream:UI] backend send starting provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", preflightElapsed), privacy: .public)s cwd=\(cwd, privacy: .public)")
let request = BackendSendRequest(
streamId: streamId,
prompt: resolvedPrompt,
@@ -404,6 +287,8 @@ extension AppState {
clientSessionKey: sessionKey
)
stream = await backend(for: agentProvider).send(request)
+ let backendReturnedElapsed = Date().timeIntervalSince(streamStart)
+ logger.info("[Stream:UI] backend send returned provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", backendReturnedElapsed), privacy: .public)s")
}
startFlushTimer(for: sessionKey)
@@ -429,7 +314,8 @@ extension AppState {
eventCount += 1
let gap = Date().timeIntervalSince(lastEventTime)
if eventCount == 1 {
- logger.info("[Stream:UI] first event arrived session=\(sessionKey, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", gap))s")
+ let totalElapsed = Date().timeIntervalSince(streamStart)
+ logger.info("[Stream:UI] first event arrived provider=\(agentProvider.rawValue, privacy: .public) session=\(sessionKey, privacy: .public) stream=\(streamId) total=\(String(format: "%.2f", totalElapsed), privacy: .public)s awaitGap=\(String(format: "%.2f", gap), privacy: .public)s event=\(Self.streamEventLogName(event), privacy: .public)")
}
lastEventTime = Date()
updateState(sessionKey) { $0.lastStreamEventDate = lastEventTime }
@@ -992,4 +878,25 @@ extension AppState {
}
}
+ nonisolated static func streamEventLogName(_ event: StreamEvent) -> String {
+ switch event {
+ case .system(let systemEvent):
+ return "system.\(systemEvent.subtype)"
+ case .assistant:
+ return "assistant"
+ case .user:
+ return "user"
+ case .result:
+ return "result"
+ case .rateLimitEvent:
+ return "rateLimitEvent"
+ case .todoSnapshot:
+ return "todoSnapshot"
+ case .acpModelsDiscovered:
+ return "acpModelsDiscovered"
+ case .unknown:
+ return "unknown"
+ }
+ }
+
}
diff --git a/RxCode/App/AppState+CrossProjectSend.swift b/RxCode/App/AppState+CrossProjectSend.swift
new file mode 100644
index 00000000..8450b4c0
--- /dev/null
+++ b/RxCode/App/AppState+CrossProjectSend.swift
@@ -0,0 +1,159 @@
+import Foundation
+import RxCodeCore
+
+extension AppState {
+ // MARK: - Cross-Project Send (used by ide__send_to_thread)
+
+ struct CrossProjectSendResult: Sendable {
+ let threadId: String
+ let projectId: UUID
+ let done: Bool
+ let assistantText: String
+ let error: String?
+ }
+
+ enum CrossProjectSendError: Error, LocalizedError {
+ case unknownProject(UUID)
+ case unknownThread(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .unknownProject(let id): return "No project with id \(id.uuidString)"
+ case .unknownThread(let id): return "No thread with id \(id)"
+ }
+ }
+ }
+
+ /// Send a prompt to a thread in any project. The send runs through the
+ /// normal `sendPrompt` pipeline via a synthetic `WindowState`, so all the
+ /// usual side-effects (title generation, briefing updates, persistence)
+ /// still fire and any UI windows currently bound to the same session see
+ /// the assistant tokens live via the shared `sessionStates` dictionary.
+ func sendCrossProject(
+ projectId: UUID?,
+ threadId: String?,
+ prompt: String,
+ agentProvider: AgentProvider? = nil,
+ model: String? = nil,
+ effort: String? = nil,
+ permissionMode: PermissionMode? = nil,
+ waitForResponse: Bool = true,
+ timeoutSeconds: TimeInterval = 120
+ ) async throws -> CrossProjectSendResult {
+ // Resolve target project + thread.
+ let resolvedProject: Project
+ let resolvedThreadId: String?
+
+ if let threadId {
+ guard let summary = allSessionSummaries.first(where: { $0.id == threadId })
+ ?? threadStore.fetch(id: threadId).map({ $0.toSummary() })
+ else {
+ throw CrossProjectSendError.unknownThread(threadId)
+ }
+ guard let proj = projects.first(where: { $0.id == summary.projectId }) else {
+ throw CrossProjectSendError.unknownProject(summary.projectId)
+ }
+ resolvedProject = proj
+ resolvedThreadId = threadId
+ } else if let projectId {
+ guard let proj = projects.first(where: { $0.id == projectId }) else {
+ throw CrossProjectSendError.unknownProject(projectId)
+ }
+ resolvedProject = proj
+ resolvedThreadId = nil
+ } else {
+ throw CrossProjectSendError.unknownProject(UUID())
+ }
+
+ // Build a synthetic WindowState. AppState.sessionStates is shared across
+ // windows, so the message + stream are visible to any real window that
+ // happens to also be viewing this session.
+ let window = WindowState()
+ window.selectedProject = resolvedProject
+ window.currentSessionId = resolvedThreadId
+
+ // Carry over per-session overrides for a new thread; for an existing
+ // thread we leave the session's own stored values alone (the resume
+ // path in sendPrompt reads from `sessionStates[sessionKey]`).
+ if resolvedThreadId == nil {
+ if let agentProvider {
+ window.sessionAgentProvider = agentProvider
+ }
+ if let model {
+ window.sessionModel = model
+ }
+ if let effort {
+ window.sessionEffort = effort
+ }
+ if let permissionMode {
+ window.sessionPermissionMode = permissionMode
+ }
+ }
+
+ guard let streamId = await sendPrompt(prompt, displayText: prompt, in: window) else {
+ return CrossProjectSendResult(
+ threadId: resolvedThreadId ?? "",
+ projectId: resolvedProject.id,
+ done: false,
+ assistantText: "",
+ error: "Send failed: no session could be allocated."
+ )
+ }
+
+ // After sendPrompt returns, window.currentSessionId is the (possibly
+ // pending-) key the stream is bound to. Resolve the real CLI session
+ // id before returning so the caller's agent never sees `pending-...`
+ // (which it can't use to follow up via `get_thread_messages` etc.).
+ let postSendKey = window.currentSessionId ?? resolvedThreadId ?? ""
+ let resolvedThreadIdForReturn: String
+ if postSendKey.hasPrefix("pending-") {
+ // Cap the rename wait at the request's timeout so we still honor
+ // the caller's deadline; 60s upper bound matches typical first-token
+ // latency under healthy conditions.
+ let renameTimeout = min(max(timeoutSeconds, 1), 60)
+ resolvedThreadIdForReturn = await awaitSessionRename(
+ pendingKey: postSendKey,
+ timeout: renameTimeout
+ ) ?? postSendKey
+ } else {
+ resolvedThreadIdForReturn = postSendKey
+ }
+
+ if !waitForResponse {
+ // Don't leak the result in the dictionary; the caller is
+ // fire-and-forget. Drop it once it lands.
+ Task { [weak self] in
+ _ = await self?.awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds)
+ }
+ return CrossProjectSendResult(
+ threadId: resolvedThreadIdForReturn,
+ projectId: resolvedProject.id,
+ done: false,
+ assistantText: "",
+ error: nil
+ )
+ }
+
+ let completion = await awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds)
+ if let completion {
+ return CrossProjectSendResult(
+ threadId: completion.sessionId,
+ projectId: resolvedProject.id,
+ done: completion.error == nil,
+ assistantText: completion.assistantText,
+ error: completion.error
+ )
+ } else {
+ // Timed out. Surface the partial assistant text we have so far so
+ // the caller can decide whether to poll back via get_thread_messages.
+ let partial = lastAssistantResponseText(in: stateForSession(window.currentSessionId ?? "").messages)
+ return CrossProjectSendResult(
+ threadId: resolvedThreadIdForReturn,
+ projectId: resolvedProject.id,
+ done: false,
+ assistantText: partial,
+ error: nil
+ )
+ }
+ }
+}
diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift
index a6350faa..1cbc2dae 100644
--- a/RxCode/App/AppState+Lifecycle.swift
+++ b/RxCode/App/AppState+Lifecycle.swift
@@ -197,6 +197,15 @@ extension AppState {
seedUITestBriefingIfRequested()
+ let prunedBriefingMetadata = threadStore.deleteBriefingMetadata(
+ excludingProjectIds: Set(projects.map(\.id))
+ )
+ if prunedBriefingMetadata.threadSummaries > 0 || prunedBriefingMetadata.branchBriefings > 0 {
+ threadSummaryRevision &+= 1
+ branchBriefingRevision &+= 1
+ logger.info("Pruned orphan briefing metadata summaries=\(prunedBriefingMetadata.threadSummaries) briefings=\(prunedBriefingMetadata.branchBriefings)")
+ }
+
// Sidebar threads are now sourced from the local SwiftData store.
// CLI session files are no longer surfaced in the sidebar list — the
// CLI is still the transcript backend (replay on thread open), but
@@ -208,11 +217,6 @@ extension AppState {
persistedQueues = threadStore.loadAllQueues()
- if claudeInstalled || codexInstalled, !onboardingCompleted {
- onboardingCompleted = true
- UserDefaults.standard.set(true, forKey: "onboardingCompleted")
- }
-
// Hydrate ACP state (clients + cached registry) early so the model picker
// and Settings tab don't flash empty on first open.
await loadACPClientsFromDisk()
diff --git a/RxCode/App/AppState+MemoryIntent.swift b/RxCode/App/AppState+MemoryIntent.swift
deleted file mode 100644
index 012ca3bb..00000000
--- a/RxCode/App/AppState+MemoryIntent.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-import Foundation
-import RxCodeCore
-
-extension AppState {
- static func fallbackShouldInjectMemoryIntoSystemPrompt(_ item: MemoryItem) -> Bool {
- if item.kind.lowercased() == "preference" { return true }
- let text = normalizedMemoryIntentText(item.content)
- return containsAny(text, phrases: systemPromptMemoryPhrases)
- }
-
- private static let systemPromptMemoryPhrases = [
- "always",
- "never",
- "from now on",
- "going forward",
- "in the future",
- "next time",
- "for future",
- "in future",
- "by default",
- "default to"
- ]
-
- private static func normalizedMemoryIntentText(_ value: String) -> String {
- value.lowercased()
- .components(separatedBy: .whitespacesAndNewlines)
- .filter { !$0.isEmpty }
- .joined(separator: " ")
- }
-
- private static func containsAny(_ text: String, phrases: [String]) -> Bool {
- phrases.contains { text.contains($0) }
- }
-}
diff --git a/RxCode/App/AppState+SessionLifecycle.swift b/RxCode/App/AppState+SessionLifecycle.swift
index 2a8324af..be825e5f 100644
--- a/RxCode/App/AppState+SessionLifecycle.swift
+++ b/RxCode/App/AppState+SessionLifecycle.swift
@@ -299,8 +299,7 @@ extension AppState {
func generateMemoryOperations(
existingMemories: [(id: String, content: String)],
- userMessage: String,
- finalResponse: String,
+ userMessages: [String],
summary: ChatSession.Summary
) async -> String? {
switch summarizationProvider {
@@ -309,8 +308,7 @@ extension AppState {
let model = summary.model ?? selectedSummarizationModel(for: provider)
return await generateMemoryOperations(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse,
+ userMessages: userMessages,
provider: provider,
model: model
)
@@ -318,8 +316,7 @@ extension AppState {
guard !openAISummarizationModel.isEmpty else { return nil }
return await openAISummarization.generateMemoryOperations(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse,
+ userMessages: userMessages,
endpoint: openAISummarizationEndpoint,
apiKey: openAISummarizationAPIKey,
model: openAISummarizationModel
@@ -327,8 +324,7 @@ extension AppState {
case .appleFoundationModel:
return await foundationModelSummarization.generateMemoryOperations(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse
+ userMessages: userMessages
)
}
}
@@ -564,8 +560,7 @@ extension AppState {
func generateMemoryOperations(
existingMemories: [(id: String, content: String)],
- userMessage: String,
- finalResponse: String,
+ userMessages: [String],
provider: AgentProvider,
model: String?
) async -> String? {
@@ -573,15 +568,13 @@ extension AppState {
case .claudeCode:
return await claude.generateMemoryOperations(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse,
+ userMessages: userMessages,
model: model ?? "haiku"
)
case .codex:
return await codex.generateMemoryOperations(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse,
+ userMessages: userMessages,
model: model
)
case .acp:
@@ -616,6 +609,13 @@ extension AppState {
.trimmingCharacters(in: .whitespacesAndNewlines)
}
+ func userMessageTexts(in messages: [ChatMessage]) -> [String] {
+ messages
+ .filter { $0.role == .user && !$0.isError }
+ .map { ChatSession.stripAttachmentMarkers(from: $0.content).trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+
func responseNotificationFallback(from responseText: String) -> String {
let text = responseText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return "" }
diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift
index 948e4295..49a3111a 100644
--- a/RxCode/App/AppState.swift
+++ b/RxCode/App/AppState.swift
@@ -150,6 +150,30 @@ enum SummarizationProvider: String, CaseIterable, Identifiable {
}
}
+enum MemoryRetrievalMode: String, CaseIterable, Identifiable {
+ case precise
+ case balanced
+ case aggressive
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .precise: return "Precise"
+ case .balanced: return "Balanced"
+ case .aggressive: return "Aggressive"
+ }
+ }
+
+ var scoreThreshold: Float {
+ switch self {
+ case .precise: return 0.65
+ case .balanced: return 0.50
+ case .aggressive: return 0.35
+ }
+ }
+}
+
@Observable
@MainActor
final class AppState {
@@ -363,6 +387,16 @@ final class AppState {
didSet { UserDefaults.standard.set(memoryInjectEnabled, forKey: "memoryInjectEnabled") }
}
+ var memoryRetrievalMode: MemoryRetrievalMode = {
+ MemoryRetrievalMode(rawValue: UserDefaults.standard.string(forKey: "memoryRetrievalMode") ?? "") ?? .balanced
+ }() {
+ didSet { UserDefaults.standard.set(memoryRetrievalMode.rawValue, forKey: "memoryRetrievalMode") }
+ }
+
+ var memoryInjectionScoreThreshold: Float {
+ memoryRetrievalMode.scoreThreshold
+ }
+
var memoryMaxContextItems: Int = (UserDefaults.standard.object(forKey: "memoryMaxContextItems") as? Int) ?? 5 {
didSet {
let clamped = max(1, min(12, memoryMaxContextItems))
@@ -375,7 +409,6 @@ final class AppState {
}
var memoryRevision = 0
- @ObservationIgnored var memoryInjectionIntentCache: [String: Bool] = [:]
// MARK: - Notifications
diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings
index 08d69d5e..012d5ca3 100644
--- a/RxCode/Resources/Localizable.xcstrings
+++ b/RxCode/Resources/Localizable.xcstrings
@@ -119,6 +119,7 @@
}
},
"%@ installed%@" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -145,6 +146,7 @@
}
},
"%@ not found" : {
+ "extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -549,6 +551,27 @@
}
}
},
+ "Add a relay server in Settings → Mobile before pairing." : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "配对前请先在 设置 → 移动端 中添加一个中继服务器。"
+ }
+ }
+ }
+ },
+ "Add a relay server to pair your phone." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "请添加一个中继服务器以配对你的手机。"
+ }
+ }
+ }
+ },
"Add a skill catalog from a GitHub repository" : {
"localizations" : {
"zh-Hans" : {
@@ -559,6 +582,16 @@
}
}
},
+ "Add an Agent Client Protocol client" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加一个 Agent Client Protocol 客户端"
+ }
+ }
+ }
+ },
"Add Git Skill Source" : {
"localizations" : {
"zh-Hans" : {
@@ -798,6 +831,16 @@
}
}
},
+ "Agent CLI Setup" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "代理 CLI 设置"
+ }
+ }
+ }
+ },
"Agent Runtimes" : {
"localizations" : {
"zh-Hans" : {
@@ -1018,6 +1061,16 @@
}
}
},
+ "Approve risky actions with context" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在了解上下文后审批高风险操作"
+ }
+ }
+ }
+ },
"Approve to run %@" : {
"comment" : "Notification body when Claude queues a tool approval. %@ is the tool name.",
"localizations" : {
@@ -1352,6 +1405,16 @@
}
}
},
+ "Binary found: %@, but version check failed" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "找到二进制 %@,但版本检查失败"
+ }
+ }
+ }
+ },
"Branch name" : {
"localizations" : {
"zh-Hans" : {
@@ -1571,6 +1634,7 @@
}
},
"Claude CLI Installation Check" : {
+ "extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1831,6 +1895,26 @@
}
}
},
+ "Connect any MCP server to give every agent extra tools. You can also skip this and add servers later in Settings → MCP." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "接入任意 MCP 服务器,让每个代理获得额外工具。也可跳过此步骤,稍后在 设置 → MCP 中添加。"
+ }
+ }
+ }
+ },
+ "Connect at least one agent CLI" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "至少连接一个代理 CLI"
+ }
+ }
+ }
+ },
"Connect GitHub" : {
"extractionState" : "stale",
"localizations" : {
@@ -1986,6 +2070,16 @@
}
}
},
+ "Continue" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "继续"
+ }
+ }
+ }
+ },
"Copied" : {
"localizations" : {
"en" : {
@@ -2063,6 +2157,16 @@
}
}
},
+ "Copy install command" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "复制安装命令"
+ }
+ }
+ }
+ },
"Could not load registry. Check your network connection." : {
"localizations" : {
"zh-Hans" : {
@@ -2073,6 +2177,16 @@
}
}
},
+ "Could not start pairing. Pick a different relay server or add a new one." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法开始配对,请选择其他中继服务器或新增一个。"
+ }
+ }
+ }
+ },
"Create and checkout" : {
"localizations" : {
"zh-Hans" : {
@@ -2598,6 +2712,36 @@
}
}
},
+ "Download APK" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "下载 APK"
+ }
+ }
+ }
+ },
+ "Download for iOS" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "下载 iOS 版"
+ }
+ }
+ }
+ },
+ "Download links unavailable. Visit rxlab.app to install the mobile app." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "暂无下载链接,请访问 rxlab.app 安装移动端。"
+ }
+ }
+ }
+ },
"Duplicate Profile" : {
"localizations" : {
"zh-Hans" : {
@@ -2784,6 +2928,16 @@
}
}
},
+ "Endpoint" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "接入点"
+ }
+ }
+ }
+ },
"Enter a new name for this device." : {
"localizations" : {
"zh-Hans" : {
@@ -2896,6 +3050,16 @@
}
}
},
+ "Expired" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已过期"
+ }
+ }
+ }
+ },
"Expired — generating a new QR code" : {
"localizations" : {
"zh-Hans" : {
@@ -2906,6 +3070,16 @@
}
}
},
+ "Expires in %d:%02d" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Expires in %1$d:%2$02d"
+ }
+ }
+ }
+ },
"Extra environment" : {
"localizations" : {
"zh-Hans" : {
@@ -3046,6 +3220,16 @@
},
"Finished installing? Tap Refresh." : {
+ },
+ "First MCP Server" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "第一个 MCP 服务器"
+ }
+ }
+ }
},
"Focus Mode" : {
"localizations" : {
@@ -3091,6 +3275,16 @@
}
}
},
+ "Follow active threads, approve permissions, and pick up work from your iPhone, iPad, or Android device." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在 iPhone、iPad 或 Android 设备上跟进会话、批准权限并继续工作。"
+ }
+ }
+ }
+ },
"Font Size" : {
"localizations" : {
"en" : {
@@ -3207,6 +3401,16 @@
}
}
},
+ "Get it on Google Play" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在 Google Play 获取"
+ }
+ }
+ }
+ },
"Get Started" : {
"localizations" : {
"en" : {
@@ -3406,6 +3610,16 @@
}
}
},
+ "I found the existing onboarding view and will keep the CLI check as setup." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "我找到了现有的引导视图,会保留 CLI 检查作为初始化步骤。"
+ }
+ }
+ }
+ },
"Images" : {
"localizations" : {
"en" : {
@@ -3509,14 +3723,64 @@
}
}
},
+ "Install additional ACP-compatible agents — RxCode downloads the right binary for macOS and probes the model list." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "安装更多兼容 ACP 的代理 —— RxCode 会下载适配 macOS 的二进制并探测模型列表。"
+ }
+ }
+ }
+ },
+ "Install additional coding agents from the ACP registry. You can skip this and add clients later." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "从 ACP 注册表安装更多编程代理,可稍后再添加。"
+ }
+ }
+ }
+ },
"Install GitHub App" : {
},
"Install GitHub App on another account" : {
+ },
+ "Install one CLI, then check again." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "先安装一个 CLI,然后再次检查。"
+ }
+ }
+ }
+ },
+ "Install the app" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "安装应用"
+ }
+ }
+ }
},
"Install the GitHub App" : {
+ },
+ "installed" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已安装"
+ }
+ }
+ }
},
"Installed" : {
"localizations" : {
@@ -4077,6 +4341,16 @@
}
}
},
+ "Model Context Protocol servers expose tools and resources to every agent. Optional — you can skip this step." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Model Context Protocol 服务器为所有代理提供工具和资源。可选 —— 可跳过此步骤。"
+ }
+ }
+ }
+ },
"Model Picker" : {
"localizations" : {
"en" : {
@@ -4485,6 +4759,16 @@
}
}
},
+ "No relay server configured" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "尚未配置中继服务器"
+ }
+ }
+ }
+ },
"No relay servers" : {
"localizations" : {
"zh-Hans" : {
@@ -4658,6 +4942,16 @@
}
}
},
+ "not found" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "未找到"
+ }
+ }
+ }
+ },
"Not found" : {
"localizations" : {
"zh-Hans" : {
@@ -4820,6 +5114,16 @@
}
}
},
+ "Open projects, switch threads, and keep agent conversations close to the files they change." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "打开项目、切换会话,让代理对话紧贴它修改的文件。"
+ }
+ }
+ }
+ },
"Open RxCode" : {
"localizations" : {
"zh-Hans" : {
@@ -4830,6 +5134,16 @@
}
}
},
+ "Open RxCode on your phone, then tap Pair Device and scan the QR code." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在手机上打开 RxCode,点击\"配对设备\"并扫描二维码。"
+ }
+ }
+ }
+ },
"Open Source" : {
"localizations" : {
"en" : {
@@ -4952,6 +5266,26 @@
}
}
},
+ "Pair via QR" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "通过二维码配对"
+ }
+ }
+ }
+ },
+ "Pair your phone" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "配对你的手机"
+ }
+ }
+ }
+ },
"Paired devices" : {
"localizations" : {
"zh-Hans" : {
@@ -5059,6 +5393,16 @@
}
}
},
+ "Pick a summarization model" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "选择摘要模型"
+ }
+ }
+ }
+ },
"Pin" : {
"localizations" : {
"en" : {
@@ -5096,6 +5440,9 @@
}
}
}
+ },
+ "Precise returns fewer, stronger matches. Balanced is the default. Aggressive includes more related memories." : {
+
},
"Preference" : {
"localizations" : {
@@ -5310,6 +5657,16 @@
}
}
},
+ "Ready to start." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "准备就绪。"
+ }
+ }
+ }
+ },
"Recent Briefings" : {
"localizations" : {
"zh-Hans" : {
@@ -5330,6 +5687,16 @@
}
}
},
+ "Refactor onboarding into slides" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "将引导流程重构为幻灯片"
+ }
+ }
+ }
+ },
"Refresh" : {
"localizations" : {
"en" : {
@@ -5442,6 +5809,16 @@
}
}
},
+ "Relay" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "中继"
+ }
+ }
+ }
+ },
"Relay server" : {
"localizations" : {
"zh-Hans" : {
@@ -5746,6 +6123,9 @@
}
}
}
+ },
+ "Retrieval" : {
+
},
"Retry" : {
"localizations" : {
@@ -5779,6 +6159,26 @@
}
}
},
+ "Review Command" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "审查命令"
+ }
+ }
+ }
+ },
+ "Review commands, diffs, and permission requests before an agent changes your workspace." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在代理修改工作区前,审查命令、差异和权限请求。"
+ }
+ }
+ }
+ },
"Review the CLI setup check" : {
"localizations" : {
"zh-Hans" : {
@@ -5812,6 +6212,16 @@
}
}
},
+ "Running swift build" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "正在运行 swift build"
+ }
+ }
+ }
+ },
"Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile)." : {
"localizations" : {
"zh-Hans" : {
@@ -5822,6 +6232,16 @@
}
}
},
+ "Runs on-device using Apple Intelligence. Free, private, and offline." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "通过 Apple Intelligence 在设备上运行,免费、私密、可离线。"
+ }
+ }
+ }
+ },
"Runs on-device with Apple Intelligence. Private, free, and offline." : {
"localizations" : {
"zh-Hans" : {
@@ -5844,6 +6264,16 @@
},
"RxCode needs the GitHub App to see your repositories. Install it for the account or organizations you want to import from." : {
+ },
+ "RxCode runs Claude Code or Codex locally. Install one of them to start your first project." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "RxCode 在本地运行 Claude Code 或 Codex,安装其一即可开始第一个项目。"
+ }
+ }
+ }
},
"RxCode.xcodeproj" : {
"localizations" : {
@@ -5875,6 +6305,26 @@
}
}
},
+ "Save Server" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保存服务器"
+ }
+ }
+ }
+ },
+ "Saved" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已保存"
+ }
+ }
+ }
+ },
"Saved memories are stored locally in SwiftData and embedded on-device." : {
"localizations" : {
"zh-Hans" : {
@@ -6315,6 +6765,16 @@
}
}
},
+ "Set up your first MCP server" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "设置第一个 MCP 服务器"
+ }
+ }
+ }
+ },
"Settings" : {
"localizations" : {
"zh-Hans" : {
@@ -6622,6 +7082,17 @@
}
}
},
+ "Skip" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "跳过"
+ }
+ }
+ }
+ },
"Skip All Questions" : {
"localizations" : {
"zh-Hans" : {
@@ -6818,6 +7289,16 @@
}
}
},
+ "The agent wants to run a command in this project." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "代理想要在此项目中运行命令。"
+ }
+ }
+ }
+ },
"The CLI sent an AskUserQuestion call this app could not parse." : {
"localizations" : {
"zh-Hans" : {
@@ -7201,6 +7682,16 @@
}
}
},
+ "Used for thread titles, branch briefings, and search. Defaults to your chat model." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "用于会话标题、分支简介和搜索,默认使用你的对话模型。"
+ }
+ }
+ }
+ },
"Used to generate short session titles. The default follows each thread's model." : {
"localizations" : {
"zh-Hans" : {
@@ -7233,6 +7724,16 @@
}
}
},
+ "Uses the model picked by the current thread. No extra configuration needed." : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用当前会话所选模型,无需额外配置。"
+ }
+ }
+ }
+ },
"Uses the model saved on the current thread." : {
"localizations" : {
"zh-Hans" : {
@@ -7349,6 +7850,16 @@
}
}
},
+ "Work with coding agents in one native app" : {
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在一个原生应用中协作编程代理"
+ }
+ }
+ }
+ },
"Working Directory" : {
"localizations" : {
"zh-Hans" : {
diff --git a/RxCode/Resources/user_manual_chat.md b/RxCode/Resources/user_manual_chat.md
new file mode 100644
index 00000000..fe9f6d9d
--- /dev/null
+++ b/RxCode/Resources/user_manual_chat.md
@@ -0,0 +1,26 @@
+# Chat and Attachments
+
+The chat view is where prompts, agent responses, tool calls, plans, questions, and queued messages appear.
+
+## Sending Messages
+
+Type a message and press **Return** to send. Use **Shift-Return** for a line break. If an agent is already responding, new messages are queued and sent after the active response finishes.
+
+## Session Controls
+
+Use the toolbar to choose:
+
+- **Agent**: Claude Code, Codex, or an ACP client.
+- **Model**: the model used by the current session.
+- **Effort**: how much reasoning the agent should apply.
+- **Permission mode**: how file edits and command execution are approved.
+
+## Attachments
+
+Attach files with the paperclip button or drag files onto the input area. Pasting is smart: URLs, file paths, images, and long text can become preview chips automatically.
+
+Configure attachment preview behavior in **Settings -> Message**.
+
+## Mentions
+
+Type **@** to find project files or saved shortcuts. File mentions insert context into the prompt, while shortcuts can insert reusable text or run terminal commands.
diff --git a/RxCode/Resources/user_manual_chat_zh_CN.md b/RxCode/Resources/user_manual_chat_zh_CN.md
new file mode 100644
index 00000000..ceebb24c
--- /dev/null
+++ b/RxCode/Resources/user_manual_chat_zh_CN.md
@@ -0,0 +1,26 @@
+# 聊天和附件
+
+聊天视图会显示提示词、代理回复、工具调用、计划、问题和排队消息。
+
+## 发送消息
+
+输入消息后按 **Return** 发送。按 **Shift-Return** 换行。如果代理正在回复,新消息会进入队列,并在当前回复结束后自动发送。
+
+## 会话控制
+
+工具栏可以选择:
+
+- **代理**:Claude Code、Codex 或 ACP 客户端。
+- **模型**:当前会话使用的模型。
+- **推理强度**:控制代理投入的推理量。
+- **权限模式**:控制文件编辑和命令执行如何审批。
+
+## 附件
+
+点击回形针按钮添加文件,也可以把文件拖到输入区。粘贴内容时,RxCode 可以自动识别 URL、文件路径、图片和长文本,并生成预览附件。
+
+附件预览行为可在 **设置 -> 消息** 中配置。
+
+## 提及
+
+输入 **@** 可以搜索项目文件或已保存的快捷项。文件提及会把上下文插入提示词,快捷项可以插入复用文本或运行终端命令。
diff --git a/RxCode/Resources/user_manual_commands.md b/RxCode/Resources/user_manual_commands.md
new file mode 100644
index 00000000..55bc2988
--- /dev/null
+++ b/RxCode/Resources/user_manual_commands.md
@@ -0,0 +1,26 @@
+# Commands and Shortcuts
+
+RxCode supports slash commands, saved shortcuts, and keyboard shortcuts for repeated workflows.
+
+## Slash Commands
+
+Type **/** in the composer to open command search. Continue typing to filter, then press **Return** to run the selected command.
+
+Some commands open an interactive terminal sheet, such as configuration or permission commands from a CLI.
+
+## Saved Shortcuts
+
+Saved shortcuts are reusable messages or terminal commands. Type **@** to open the mention picker and select a shortcut.
+
+Manage shortcuts in **Settings -> Commands**.
+
+## Keyboard Shortcuts
+
+- **Command-N**: start a new chat.
+- **Command-1**: show History.
+- **Command-2**: show Files.
+- **Command-3**: toggle the left sidebar.
+- **Command-4**: toggle the right inspector.
+- **Command-F**: open file search.
+- **Command-K**: open global thread search, or clear the terminal when the terminal inspector is active.
+- **Escape**: stop the active response or close the active popup.
diff --git a/RxCode/Resources/user_manual_commands_zh_CN.md b/RxCode/Resources/user_manual_commands_zh_CN.md
new file mode 100644
index 00000000..fe21eb83
--- /dev/null
+++ b/RxCode/Resources/user_manual_commands_zh_CN.md
@@ -0,0 +1,26 @@
+# 命令和快捷项
+
+RxCode 支持斜杠命令、保存的快捷项和键盘快捷键,用于重复工作流。
+
+## 斜杠命令
+
+在输入框中输入 **/** 打开命令搜索。继续输入可以筛选,按 **Return** 运行选中的命令。
+
+某些命令会打开交互式终端弹窗,例如 CLI 的配置或权限命令。
+
+## 保存的快捷项
+
+快捷项是可复用的消息或终端命令。输入 **@** 打开提及选择器并选择快捷项。
+
+可在 **设置 -> 命令** 中管理快捷项。
+
+## 键盘快捷键
+
+- **Command-N**:开始新聊天。
+- **Command-1**:显示历史记录。
+- **Command-2**:显示文件。
+- **Command-3**:切换左侧边栏。
+- **Command-4**:切换右侧检查器。
+- **Command-F**:打开文件搜索。
+- **Command-K**:打开全局对话搜索;当终端检查器处于激活状态时清空终端。
+- **Escape**:停止当前回复或关闭当前弹窗。
diff --git a/RxCode/Resources/user_manual_integrations.md b/RxCode/Resources/user_manual_integrations.md
new file mode 100644
index 00000000..ccb5c8df
--- /dev/null
+++ b/RxCode/Resources/user_manual_integrations.md
@@ -0,0 +1,64 @@
+# MCP and ACP Clients
+
+RxCode connects to external tooling through two protocols: the Model Context Protocol (MCP) for tool servers, and the Agent Client Protocol (ACP) for third-party coding agents.
+
+## MCP Servers
+
+Model Context Protocol servers expose tools, resources, and prompts that any installed agent can call. RxCode reads, writes, probes, and shares the same configuration that the underlying CLI agents read, so a server added in RxCode is also available to Claude Code and Codex.
+
+### Adding a Server
+
+Open **Settings -> MCP** and click **Add Server**. Fill in:
+
+- **Name** — a unique identifier used by the agent.
+- **Scope** — `user` makes the server available in every project, `project` scopes it to the current working directory.
+- **Transport** — choose `stdio` for local commands, `http` or `sse` for remote endpoints.
+
+For stdio transports, provide a **Command** (for example `npx`) and one **Arg** per line. For HTTP transports, provide the server **URL** and any **Headers** required for authentication.
+
+### Environment Variables
+
+Stdio servers run as child processes. Use the **Environment** editor to set per-server environment variables — for example `OPENAI_API_KEY` or `GITHUB_TOKEN`. Secrets stay on this machine.
+
+### Probing and Tool Lists
+
+After save, RxCode probes the server, pins the configuration, and lists available tools. The status indicator shows `connected`, `disconnected`, or an error. Toggle the row to enable or disable a server without removing it.
+
+### Project Overrides
+
+User-scope servers can be overridden per project from the same MCP tab when a project is selected. Use overrides to disable a global server in one repository or to swap credentials.
+
+## ACP Clients
+
+The Agent Client Protocol lets RxCode talk to additional coding agents besides Claude Code and Codex. Examples include OpenCode and Gemini CLI.
+
+### Installing From the Registry
+
+Open **Settings -> ACP Clients -> Registry**. RxCode fetches the official registry from `cdn.agentclientprotocol.com` and lists each agent with version, license, and a short description.
+
+Click **Add** to install. RxCode downloads the platform-specific binary distribution into:
+
+```
+~/Library/Application Support/RxCode/acp-binaries///
+```
+
+If a binary distribution is not available for macOS, RxCode falls back to `npx` or `uvx` packages declared in the registry.
+
+### Models
+
+After install, RxCode probes the client over ACP to discover the models it advertises. If the agent does not expose a model selector, the picker shows a single **Default** entry and the agent picks its own model at runtime. Click **Fetch** in the editor sheet to retry the probe.
+
+### Environment
+
+Edit a client to inject extra environment variables — for example API keys required by the agent.
+
+### Enable and Remove
+
+Toggle the row to enable or disable a client for new threads. Remove deletes the installed binary from disk along with the configuration.
+
+## When to Use Which
+
+- **MCP** adds new tools to every agent.
+- **ACP** adds new agents that can themselves use MCP tools.
+
+The two are complementary: install an ACP client to gain a new agent backend, then add an MCP server to expose extra tools to it.
diff --git a/RxCode/Resources/user_manual_integrations_zh_CN.md b/RxCode/Resources/user_manual_integrations_zh_CN.md
new file mode 100644
index 00000000..0a32896e
--- /dev/null
+++ b/RxCode/Resources/user_manual_integrations_zh_CN.md
@@ -0,0 +1,64 @@
+# MCP 与 ACP 客户端
+
+RxCode 通过两种协议接入外部工具:用于工具服务器的 Model Context Protocol(MCP)和用于第三方编码代理的 Agent Client Protocol(ACP)。
+
+## MCP 服务器
+
+MCP 服务器为已安装的代理提供工具、资源和提示词。RxCode 读取、写入、探测并复用底层 CLI 代理的配置,因此在 RxCode 中添加的服务器也可被 Claude Code 和 Codex 使用。
+
+### 添加服务器
+
+打开 **设置 -> MCP**,点击 **添加服务器**,填写:
+
+- **名称** — 代理使用的唯一标识。
+- **范围** — `用户` 全局可用,`项目` 仅在当前工作目录生效。
+- **传输方式** — 本地命令选 `stdio`,远程接入选 `http` 或 `sse`。
+
+stdio 需要提供 **命令**(例如 `npx`),每行一个 **参数**。HTTP 传输需提供 **URL** 以及鉴权所需的 **请求头**。
+
+### 环境变量
+
+stdio 服务器以子进程方式运行,可通过 **环境** 编辑器逐服务器设置环境变量,例如 `OPENAI_API_KEY` 或 `GITHUB_TOKEN`。这些密钥仅保存在本机。
+
+### 探测与工具列表
+
+保存后 RxCode 会探测服务器、固定配置并列出可用工具。状态指示器显示 `connected`、`disconnected` 或错误。可通过开关启用或停用某个服务器而无需删除。
+
+### 项目覆盖
+
+选择项目后可以在 MCP 标签页对用户级服务器进行覆盖。常见用法是在某个仓库中禁用全局服务器或替换凭据。
+
+## ACP 客户端
+
+Agent Client Protocol 让 RxCode 接入 Claude Code 和 Codex 之外的代理,例如 OpenCode、Gemini CLI 等。
+
+### 从注册表安装
+
+打开 **设置 -> ACP 客户端 -> 注册表**。RxCode 会从 `cdn.agentclientprotocol.com` 获取官方注册表,按版本、许可证和简介列出每个代理。
+
+点击 **添加** 即可安装。RxCode 会把对应平台的二进制分发包下载到:
+
+```
+~/Library/Application Support/RxCode/acp-binaries///
+```
+
+如果该代理没有 macOS 二进制分发,RxCode 会回退到注册表声明的 `npx` 或 `uvx` 包。
+
+### 模型
+
+安装后 RxCode 通过 ACP 探测代理对外公开的模型列表。若代理没有提供模型选择器,模型菜单只显示一个 **Default**,由代理在运行时自行决定模型。可在编辑器中点击 **Fetch** 重新探测。
+
+### 环境
+
+编辑客户端可以注入额外的环境变量,例如代理所需的 API 密钥。
+
+### 启停与移除
+
+通过开关启停某个客户端是否参与新对话。移除会从磁盘删除已安装的二进制及其配置。
+
+## 何时使用
+
+- **MCP** 为所有代理增加新工具。
+- **ACP** 增加新的代理后端,后端本身也能使用 MCP 工具。
+
+两者互补:先安装 ACP 客户端获得新代理,再用 MCP 服务器为其扩展工具能力。
diff --git a/RxCode/Resources/user_manual_overview.md b/RxCode/Resources/user_manual_overview.md
new file mode 100644
index 00000000..0e9e6dbb
--- /dev/null
+++ b/RxCode/Resources/user_manual_overview.md
@@ -0,0 +1,25 @@
+# Overview
+
+RxCode is a native macOS client for coding agents. It gives Claude Code, Codex, and ACP clients a project-based interface with chat, permissions, file browsing, run profiles, Git tools, mobile sync, and local settings.
+
+## What RxCode Does
+
+- Runs coding agents inside a desktop workspace.
+- Keeps chat history, files, Git state, and project context together.
+- Surfaces tool calls, approvals, diffs, and terminal output without requiring a separate terminal window.
+- Syncs selected desktop state to paired mobile devices.
+
+## Main Areas
+
+- **Sidebar**: projects, history, files, Git status, and branch briefing.
+- **Content**: the active chat, briefing, or project view.
+- **Detail / Inspector**: changes, this-thread diff, terminal, and memo.
+- **Settings**: app preferences, agents, commands, integrations, and sync.
+
+## Basic Flow
+
+1. Add or select a project.
+2. Start a chat or open an existing thread.
+3. Choose an agent, model, effort, and permission mode.
+4. Review tool calls, approvals, and file changes as the agent works.
+5. Use the inspector to verify diffs, run commands, or keep notes.
diff --git a/RxCode/Resources/user_manual_overview_zh_CN.md b/RxCode/Resources/user_manual_overview_zh_CN.md
new file mode 100644
index 00000000..6dbbbd49
--- /dev/null
+++ b/RxCode/Resources/user_manual_overview_zh_CN.md
@@ -0,0 +1,25 @@
+# 概览
+
+RxCode 是一个原生 macOS 编程代理客户端。它把 Claude Code、Codex 和 ACP 客户端放进以项目为中心的桌面工作区,支持聊天、权限审批、文件浏览、运行配置、Git 工具、移动端同步和本地设置。
+
+## RxCode 可以做什么
+
+- 在桌面工作区中运行编程代理。
+- 把聊天历史、文件、Git 状态和项目上下文放在一起。
+- 直接显示工具调用、审批、diff 和终端输出。
+- 将部分桌面状态同步到已配对的移动设备。
+
+## 主要区域
+
+- **侧边栏**:项目、历史记录、文件、Git 状态和分支简报。
+- **内容区**:当前聊天、简报或项目视图。
+- **详情 / 检查器**:改动、本对话 diff、终端和备忘。
+- **设置**:应用偏好、代理、命令、集成和同步。
+
+## 基本流程
+
+1. 添加或选择项目。
+2. 开始新聊天,或打开已有对话。
+3. 选择代理、模型、推理强度和权限模式。
+4. 在代理工作时查看工具调用、审批和文件改动。
+5. 用检查器确认 diff、运行命令或记录备忘。
diff --git a/RxCode/Resources/user_manual_permissions.md b/RxCode/Resources/user_manual_permissions.md
new file mode 100644
index 00000000..22a95390
--- /dev/null
+++ b/RxCode/Resources/user_manual_permissions.md
@@ -0,0 +1,25 @@
+# Permissions and Inspector
+
+Permissions and the inspector help you keep control while agents work.
+
+## Permission Requests
+
+When an agent wants to edit files or run a command, RxCode can pause and show a permission request. You can allow the single action, allow similar actions for the current session, or deny it.
+
+## Permission Modes
+
+- **Ask**: request approval before edits and commands.
+- **Accept Edits**: allow file edits in the workspace; commands still require approval.
+- **Plan**: read-only planning mode.
+- **Auto**: let the model approve safe operations and ask for risky ones.
+- **Bypass**: skip permission checks. Use only in isolated, trusted environments.
+
+## Inspector
+
+Open the right inspector with **Command-4** or the toolbar button. The inspector includes review panes, terminal access, and project memo notes.
+
+The terminal starts in the current project directory. The memo is saved per project.
+
+## Reviewing Changes
+
+Use the inspector to review generated edits, this-thread diffs, and command output before committing or pushing.
diff --git a/RxCode/Resources/user_manual_permissions_zh_CN.md b/RxCode/Resources/user_manual_permissions_zh_CN.md
new file mode 100644
index 00000000..61e3cf88
--- /dev/null
+++ b/RxCode/Resources/user_manual_permissions_zh_CN.md
@@ -0,0 +1,25 @@
+# 权限和检查器
+
+权限系统和检查器用于在代理工作时保持控制。
+
+## 权限请求
+
+当代理需要编辑文件或运行命令时,RxCode 可以暂停并显示权限请求。你可以只允许本次操作、允许当前会话中的同类操作,或拒绝操作。
+
+## 权限模式
+
+- **Ask**:编辑和命令执行前请求确认。
+- **Accept Edits**:允许工作区内的文件编辑;命令仍需确认。
+- **Plan**:只读规划模式。
+- **Auto**:让模型自动批准安全操作,遇到高风险操作再询问。
+- **Bypass**:跳过权限检查。只应在隔离且可信的环境中使用。
+
+## 检查器
+
+按 **Command-4** 或点击工具栏按钮打开右侧检查器。检查器包含审阅面板、终端和项目备忘。
+
+终端会从当前项目目录启动。备忘按项目自动保存。
+
+## 审阅改动
+
+提交或推送前,可以在检查器中查看生成的编辑、本对话 diff 和命令输出。
diff --git a/RxCode/Resources/user_manual_projects.md b/RxCode/Resources/user_manual_projects.md
new file mode 100644
index 00000000..3b6e2d05
--- /dev/null
+++ b/RxCode/Resources/user_manual_projects.md
@@ -0,0 +1,25 @@
+# Projects and Sidebar
+
+The sidebar is part of the main workflow. It is the first column of the app and is used for navigation, project state, files, and branch context.
+
+## Adding Projects
+
+Add a project with the **+** button in the sidebar or by dragging a folder into RxCode. Each project keeps its own sessions, branch context, run profiles, and memo.
+
+Double-click a project to open it in a dedicated project window. Dedicated windows are independent, so several projects can stay active at the same time.
+
+## Sidebar Sections
+
+- **Projects**: switch between workspaces.
+- **History**: resume, pin, rename, or delete threads.
+- **Files**: browse and preview project files.
+- **Git status**: inspect changed files and current branch.
+- **Briefing**: review branch-level progress and recent context.
+
+## Files
+
+Click a file in the Files tab to preview it. Large files may be skipped to keep the app responsive. Use file search to narrow the tree quickly.
+
+## Branches
+
+The Git status area shows the current branch and changed-file counts. Use branch controls to switch between local or remote branches when available.
diff --git a/RxCode/Resources/user_manual_projects_zh_CN.md b/RxCode/Resources/user_manual_projects_zh_CN.md
new file mode 100644
index 00000000..709fecc4
--- /dev/null
+++ b/RxCode/Resources/user_manual_projects_zh_CN.md
@@ -0,0 +1,25 @@
+# 项目和侧边栏
+
+侧边栏是主流程的一部分。它是应用的第一列,用来导航项目、查看项目状态、浏览文件和理解分支上下文。
+
+## 添加项目
+
+点击侧边栏的 **+** 按钮,或把文件夹拖进 RxCode,即可添加项目。每个项目都有自己的会话、分支上下文、运行配置和备忘。
+
+双击项目可以打开独立项目窗口。独立窗口互不影响,方便同时处理多个项目。
+
+## 侧边栏内容
+
+- **项目**:切换工作区。
+- **历史记录**:恢复、置顶、重命名或删除对话。
+- **文件**:浏览和预览项目文件。
+- **Git 状态**:查看改动文件和当前分支。
+- **简报**:查看分支级进度和最近上下文。
+
+## 文件
+
+点击 Files 标签中的文件可以预览。为了保持响应速度,过大的文件可能不会预览。使用文件搜索可以快速缩小文件树。
+
+## 分支
+
+Git 状态区域会显示当前分支和改动文件数量。可用时,可以通过分支控件在本地或远程分支之间切换。
diff --git a/RxCode/Resources/user_manual_settings.md b/RxCode/Resources/user_manual_settings.md
new file mode 100644
index 00000000..c7ea33d2
--- /dev/null
+++ b/RxCode/Resources/user_manual_settings.md
@@ -0,0 +1,27 @@
+# Integrations and Settings
+
+Settings collect app preferences, agent configuration, command management, integrations, and mobile sync.
+
+## General
+
+General settings include theme, font size, notifications, menu bar behavior, search indexing, and memory settings.
+
+## Message
+
+Message settings control chat display, focus mode, attachment previews, default model, default effort, and default permission mode.
+
+## Commands
+
+The Commands tab manages slash commands and saved shortcuts. Use JSON import and export to move custom entries between machines.
+
+## GitHub and Autopilot
+
+Use the import button in the sidebar to sign in with rxlab and import GitHub repositories. After sign-in, RxCode can list repositories available through the GitHub App and clone them as projects.
+
+## Mobile Sync
+
+Pair a mobile device from **Settings -> Mobile**. Paired devices can follow active sessions, view progress, receive notifications, and send remote actions supported by the desktop app.
+
+## MCP and ACP
+
+MCP settings manage Model Context Protocol servers. ACP settings manage installed ACP clients and their availability.
diff --git a/RxCode/Resources/user_manual_settings_zh_CN.md b/RxCode/Resources/user_manual_settings_zh_CN.md
new file mode 100644
index 00000000..f45f239f
--- /dev/null
+++ b/RxCode/Resources/user_manual_settings_zh_CN.md
@@ -0,0 +1,27 @@
+# 集成和设置
+
+设置集中管理应用偏好、代理配置、命令管理、集成和移动端同步。
+
+## 通用
+
+通用设置包括主题、字体大小、通知、菜单栏行为、搜索索引和记忆设置。
+
+## 消息
+
+消息设置控制聊天显示、专注模式、附件预览、默认模型、默认推理强度和默认权限模式。
+
+## 命令
+
+Commands 标签用于管理斜杠命令和保存的快捷项。可以通过 JSON 导入和导出在不同设备之间迁移自定义内容。
+
+## GitHub 和 Autopilot
+
+点击侧边栏的导入按钮,用 rxlab 登录并导入 GitHub 仓库。登录后,RxCode 可以列出 GitHub App 授权的仓库,并把它们克隆为项目。
+
+## 移动端同步
+
+在 **设置 -> 移动端** 中配对移动设备。配对设备可以跟随活跃会话、查看进度、接收通知,并发送桌面端支持的远程操作。
+
+## MCP 和 ACP
+
+MCP 设置用于管理 Model Context Protocol 服务器。ACP 设置用于管理已安装的 ACP 客户端及其可用状态。
diff --git a/RxCode/Resources/user_manual_worktrees.md b/RxCode/Resources/user_manual_worktrees.md
new file mode 100644
index 00000000..39deb521
--- /dev/null
+++ b/RxCode/Resources/user_manual_worktrees.md
@@ -0,0 +1,61 @@
+# Git Worktrees
+
+RxCode runs every thread inside a working directory. Git worktrees let one repository host several working directories at the same time, each on its own branch, so multiple agents can edit the same project without stepping on each other.
+
+## Why Use a Worktree
+
+- **Parallel threads** — run one agent on a refactor branch while another keeps your main branch ready for review.
+- **Safe experiments** — let an agent try a risky change in an isolated tree; discard it by removing the worktree, no `git reset` required.
+- **No stash juggling** — switch between in-progress work without committing, stashing, or shelving local edits.
+
+Worktrees share the same `.git` storage with the main checkout, so branches, tags, refs, and reflogs all stay in sync.
+
+## Creating a Worktree
+
+Open a project, then use **Project Menu -> Add Worktree** in the sidebar. Provide:
+
+- **Branch** — pick an existing branch or create a new one.
+- **Base** — when creating a new branch, the ref to branch off (defaults to the current HEAD).
+- **Path** — RxCode suggests a sibling directory next to the repository. Accept it or pick another empty location.
+
+Behind the scenes, RxCode invokes:
+
+```sh
+git worktree add
+# or, for a new branch:
+git worktree add -b
+```
+
+The new worktree appears in the project list with a small fork icon. Selecting it switches the agent's working directory to the worktree path.
+
+## Working Inside a Worktree
+
+A thread running in a worktree behaves like any other project:
+
+- File reads, diffs, terminal commands, and run profiles all operate against the worktree path.
+- Permission prompts, change tracking, and the inspector show changes scoped to that tree.
+- Mobile sync mirrors the worktree's path so the device shows the right project name.
+
+## Removing a Worktree
+
+Right-click the worktree in the sidebar and choose **Remove**. RxCode runs:
+
+```sh
+git worktree remove
+```
+
+If the working tree has uncommitted changes, RxCode warns first. Use **Force Remove** only when you intentionally want to discard the worktree's local changes — the operation cannot be undone.
+
+After removal, the underlying branch still exists. Delete the branch separately if it is no longer needed.
+
+## Tips
+
+- Keep worktrees in a sibling directory tree (for example `~/code//`) so VS Code, Xcode, and other tools open the right files.
+- Avoid running two agents simultaneously on worktrees that share build artifacts in the same path (for example a shared `node_modules`). Use separate worktrees with their own dependency installs.
+- `git worktree prune` cleans up stale worktree metadata if a directory was deleted outside RxCode. Open Terminal in the repository and run it manually if a worktree disappears from disk but lingers in `git worktree list`.
+
+## Limitations
+
+- A single branch can be checked out in only one worktree at a time. Switch worktrees or check out a different branch.
+- Submodules in additional worktrees may require an extra `git submodule update --init` after creation.
+- Some Git hosting providers' large-file storage (Git LFS) needs `git lfs install --worktree` inside each new worktree before LFS pointers resolve.
diff --git a/RxCode/Resources/user_manual_worktrees_zh_CN.md b/RxCode/Resources/user_manual_worktrees_zh_CN.md
new file mode 100644
index 00000000..3c2419a6
--- /dev/null
+++ b/RxCode/Resources/user_manual_worktrees_zh_CN.md
@@ -0,0 +1,61 @@
+# Git 工作树
+
+RxCode 中每个对话都运行在一个工作目录里。Git 工作树(worktree)允许同一个仓库同时存在多个工作目录,每个目录在一个分支上工作,从而让多个代理同时改动同一个项目而互不干扰。
+
+## 为什么使用工作树
+
+- **并行对话** — 一个代理在重构分支上工作,另一个代理保持主分支随时可评审。
+- **安全试验** — 把可能出问题的改动放进隔离的工作树,弃用时直接删除即可,无需 `git reset`。
+- **免去 stash** — 在多份进行中的工作之间切换,无需提交、暂存或搁置本地改动。
+
+工作树共享主检出的 `.git` 存储,分支、标签、引用和 reflog 都保持同步。
+
+## 创建工作树
+
+打开项目,在侧边栏选择 **项目菜单 -> 添加工作树**,填写:
+
+- **分支** — 选择现有分支或新建分支。
+- **基线** — 新建分支时使用的起点(默认是当前 HEAD)。
+- **路径** — RxCode 默认建议仓库同级目录,可以接受或选择其他空目录。
+
+底层执行:
+
+```sh
+git worktree add
+# 新建分支:
+git worktree add -b
+```
+
+新工作树会在项目列表中以小分叉图标显示,选中后代理的工作目录会切换到该路径。
+
+## 在工作树中工作
+
+工作树中的对话表现与普通项目一致:
+
+- 文件读取、差异、终端命令以及运行配置均针对工作树路径。
+- 权限提示、变更追踪与右侧检查器仅显示该工作树范围内的改动。
+- 移动端同步会映射工作树路径,因此设备上看到的项目名是对应的工作树名称。
+
+## 删除工作树
+
+在侧边栏右键工作树,选择 **删除**。RxCode 会执行:
+
+```sh
+git worktree remove
+```
+
+若工作树中存在未提交的改动,RxCode 会先警告。只有在确认要丢弃这些改动时才使用 **强制删除**,此操作不可撤销。
+
+删除工作树后,对应分支仍然保留。若不再需要可单独删除分支。
+
+## 小技巧
+
+- 把工作树放在仓库同级目录(如 `~/code//`),方便 VS Code、Xcode 等工具找到正确文件。
+- 避免在共享构建产物(如同一份 `node_modules`)的两个工作树上同时跑代理,建议各自独立安装依赖。
+- 如果工作树目录在 RxCode 外被删除而 `git worktree list` 仍然保留记录,可在仓库目录运行 `git worktree prune` 清理。
+
+## 限制
+
+- 同一分支同时只能被一个工作树检出。需要切换工作树或切换分支。
+- 在新工作树中使用子模块可能需要额外执行 `git submodule update --init`。
+- 使用 Git LFS 的仓库需要在每个新工作树中运行 `git lfs install --worktree`,否则 LFS 指针无法正确解析。
diff --git a/RxCode/Services/ACPService+Protocol.swift b/RxCode/Services/ACPService+Protocol.swift
index 72e1367b..9bac516f 100644
--- a/RxCode/Services/ACPService+Protocol.swift
+++ b/RxCode/Services/ACPService+Protocol.swift
@@ -139,6 +139,15 @@ extension ACPService {
// Resolve the latest canonical key in case the entry has been
// re-keyed (bootstrap key → agent sessionId).
let resolved = aliasToCanonical[key] ?? key
+ var lineCount = 0
+ mutateSession(resolved) { entry in
+ entry.stdoutLineCount += 1
+ lineCount = entry.stdoutLineCount
+ }
+ if lineCount == 1 {
+ let pid = sessions[resolved]?.process.processIdentifier ?? 0
+ logger.info("[ACP] first stdout line key=\(resolved, privacy: .public) pid=\(pid) line=\(line.prefix(240), privacy: .public)")
+ }
logger.info("[ACP][stdout] \(line.prefix(400), privacy: .public)")
await handleIncoming(key: resolved, data: data)
}
diff --git a/RxCode/Services/ACPService+Spawn.swift b/RxCode/Services/ACPService+Spawn.swift
index aa1c8ab5..d267be9d 100644
--- a/RxCode/Services/ACPService+Spawn.swift
+++ b/RxCode/Services/ACPService+Spawn.swift
@@ -9,9 +9,10 @@ extension ACPService {
func spawn(spec: ACPClientSpec, model: String?, cwd: String) async
throws -> (Process, FileHandle, FileHandle, FileHandle)
{
+ let launchStartedAt = Date()
let (executable, args, baseEnv) = try resolveLaunch(spec.launch)
let allArgs = args + spec.extraArgs
- logger.info("[ACP] spawn exec=\(executable, privacy: .public) args=[\(allArgs.joined(separator: " "), privacy: .public)] cwd=\(cwd, privacy: .public)")
+ logger.info("[ACP] launch start client=\(spec.displayName, privacy: .public) exec=\(executable, privacy: .public) args=[\(allArgs.joined(separator: " "), privacy: .public)] cwd=\(cwd, privacy: .public)")
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
@@ -37,7 +38,8 @@ extension ACPService {
do {
try process.run()
- logger.info("[ACP] spawn ok pid=\(process.processIdentifier) for \(spec.displayName, privacy: .public)")
+ let elapsed = Date().timeIntervalSince(launchStartedAt)
+ logger.info("[ACP] process launched pid=\(process.processIdentifier) client=\(spec.displayName, privacy: .public) after=\(String(format: "%.2f", elapsed), privacy: .public)s")
} catch {
logger.error("[ACP] spawn FAILED exec=\(executable, privacy: .public): \(error.localizedDescription, privacy: .public)")
throw error
diff --git a/RxCode/Services/ACPService.swift b/RxCode/Services/ACPService.swift
index 4933a154..652b2de5 100644
--- a/RxCode/Services/ACPService.swift
+++ b/RxCode/Services/ACPService.swift
@@ -39,6 +39,7 @@ actor ACPService {
var pending: [Int: CheckedContinuation] = [:]
var stderr: String = ""
var stdoutReaderTask: Task?
+ var stdoutLineCount: Int = 0
// Per-turn (reset before `session/prompt`)
var currentStreamId: UUID?
diff --git a/RxCode/Services/ClaudeService+Process.swift b/RxCode/Services/ClaudeService+Process.swift
index 6b64d7af..8da2fbbe 100644
--- a/RxCode/Services/ClaudeService+Process.swift
+++ b/RxCode/Services/ClaudeService+Process.swift
@@ -33,6 +33,7 @@ extension ClaudeCodeServer {
let log = self.logger
let currentStreamId = streamId
+ let sendStartedAt = Date()
readStderr(stderr, streamId: currentStreamId)
@@ -117,6 +118,10 @@ extension ClaudeCodeServer {
guard let data = line.data(using: .utf8) else { continue }
rawLineCount += 1
+ if rawLineCount == 1 {
+ let elapsed = Date().timeIntervalSince(sendStartedAt)
+ log.info("[Claude] first stdout line stream=\(streamId) after=\(String(format: "%.2f", elapsed), privacy: .public)s")
+ }
// Diagnostic logging of raw NDJSON — full content for first 30 lines, then type field only
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let type = (json["type"] as? String) ?? "?"
@@ -533,6 +538,9 @@ extension ClaudeCodeServer {
stderrPipe: Pipe,
onProcessExit: (@Sendable () -> Void)? = nil
) async throws {
+ let launchStartedAt = Date()
+ logger.info("[Claude] launch start stream=\(streamId) cwd=\(cwd, privacy: .public) session=\(sessionId ?? "", privacy: .public) model=\(model ?? "", privacy: .public) mode=\(String(describing: permissionMode), privacy: .public)")
+
guard let binary = await findClaudeBinary() else {
throw ClaudeError.binaryNotFound
}
@@ -567,6 +575,8 @@ extension ClaudeCodeServer {
logger.error("Failed to spawn claude: \(error, privacy: .public)")
throw ClaudeError.spawnFailed(error.localizedDescription)
}
+ let spawnElapsed = Date().timeIntervalSince(launchStartedAt)
+ logger.info("[Claude] process launched pid=\(pid) stream=\(streamId) after=\(String(format: "%.2f", spawnElapsed), privacy: .public)s binary=\(binary, privacy: .public)")
// Parent must release the child-owned pipe ends so EOF propagates correctly.
try? stdinPipe.fileHandleForReading.close()
@@ -591,6 +601,8 @@ extension ClaudeCodeServer {
]
]
try Self.writeJSONLine(userMessage, to: stdinHandle)
+ let promptElapsed = Date().timeIntervalSince(launchStartedAt)
+ logger.info("[Claude] initial prompt written stream=\(streamId) after=\(String(format: "%.2f", promptElapsed), privacy: .public)s promptLen=\(prompt.count)")
logger.info(
"Spawned claude process pid=\(pid) pgid=\(pid) cwd=\(cwd, privacy: .public) stream=\(streamId)"
diff --git a/RxCode/Services/ClaudeService+Summaries.swift b/RxCode/Services/ClaudeService+Summaries.swift
index 5c308661..f6b586a5 100644
--- a/RxCode/Services/ClaudeService+Summaries.swift
+++ b/RxCode/Services/ClaudeService+Summaries.swift
@@ -108,32 +108,16 @@ extension ClaudeCodeServer {
func generateMemoryOperations(
existingMemories: [(id: String, content: String)],
- userMessage: String,
- finalResponse: String,
+ userMessages: [String],
model: String = "claude-haiku-4-5-20251001"
) async -> String? {
let prompt = OpenAISummarizationService.memoryExtractionPrompt(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse
+ userMessages: userMessages
)
return await generatePlainSummary(prompt: prompt, model: model, limit: 3000)
}
- func determineMemoryInjectionIntent(
- content: String,
- kind: String,
- scope: String,
- model: String = "claude-haiku-4-5-20251001"
- ) async -> String? {
- let prompt = OpenAISummarizationService.memoryInjectionIntentPrompt(
- content: content,
- kind: kind,
- scope: scope
- )
- return await generatePlainSummary(prompt: prompt, model: model, limit: 16)
- }
-
func generateBranchBriefing(
threadSummaries: [(title: String, summary: String)],
model: String = "claude-haiku-4-5-20251001"
diff --git a/RxCode/Services/CodexAppServer+Process.swift b/RxCode/Services/CodexAppServer+Process.swift
index c594cb93..fc166571 100644
--- a/RxCode/Services/CodexAppServer+Process.swift
+++ b/RxCode/Services/CodexAppServer+Process.swift
@@ -60,6 +60,8 @@ extension CodexAppServer {
}
func spawnAppServer(binary: String, streamId: UUID, cwd: String?, configOverrides: [String] = []) async throws -> (process: Process, stdin: FileHandle, stdout: Pipe) {
+ let launchStartedAt = Date()
+ logger.info("[CodexAppServer] launch start stream=\(streamId) cwd=\(cwd ?? "", privacy: .public) overrides=\(configOverrides.count)")
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = ["app-server", "--listen", "stdio://"] + configOverrides
@@ -78,6 +80,8 @@ extension CodexAppServer {
} catch {
throw CodexError.spawnFailed(error.localizedDescription)
}
+ let elapsed = Date().timeIntervalSince(launchStartedAt)
+ logger.info("[CodexAppServer] process launched pid=\(process.processIdentifier) stream=\(streamId) after=\(String(format: "%.2f", elapsed), privacy: .public)s binary=\(binary, privacy: .public)")
let stdinHandle = stdin.fileHandleForWriting
running[streamId] = RunningProcess(process: process, stdin: stdinHandle)
diff --git a/RxCode/Services/CodexAppServer+Summaries.swift b/RxCode/Services/CodexAppServer+Summaries.swift
index 2dfb4a61..ea45be42 100644
--- a/RxCode/Services/CodexAppServer+Summaries.swift
+++ b/RxCode/Services/CodexAppServer+Summaries.swift
@@ -166,34 +166,17 @@ extension CodexAppServer {
func generateMemoryOperations(
existingMemories: [(id: String, content: String)],
- userMessage: String,
- finalResponse: String,
+ userMessages: [String],
model: String?
) async -> String? {
let prompt = OpenAISummarizationService.memoryExtractionPrompt(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse
+ userMessages: userMessages
)
guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil }
return cleanSummary(raw, limit: 3000)
}
- func determineMemoryInjectionIntent(
- content: String,
- kind: String,
- scope: String,
- model: String?
- ) async -> String? {
- let prompt = OpenAISummarizationService.memoryInjectionIntentPrompt(
- content: content,
- kind: kind,
- scope: scope
- )
- guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil }
- return cleanSummary(raw, limit: 16)
- }
-
func generateBranchBriefing(
threadSummaries: [(title: String, summary: String)],
model: String?
diff --git a/RxCode/Services/CodexAppServer+Turn.swift b/RxCode/Services/CodexAppServer+Turn.swift
index 48fc1f66..ad07bbbb 100644
--- a/RxCode/Services/CodexAppServer+Turn.swift
+++ b/RxCode/Services/CodexAppServer+Turn.swift
@@ -15,6 +15,7 @@ extension CodexAppServer {
permissionServer: PermissionServer,
continuation: AsyncStream.Continuation
) async {
+ let turnReceivedAt = Date()
do {
guard let binary = await findCodexBinary() else { throw CodexError.binaryNotFound }
let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd, configOverrides: mcpConfigOverrides)
@@ -34,9 +35,15 @@ extension CodexAppServer {
// message (concise summary). See PlanCardView for the rendering contract.
var planItems: [TodoItem] = []
var assistantTextBuffer = ""
+ var rawLineCount = 0
for try await line in handles.stdout.fileHandleForReading.bytes.lines {
guard !Task.isCancelled else { break }
+ rawLineCount += 1
+ if rawLineCount == 1 {
+ let elapsed = Date().timeIntervalSince(turnReceivedAt)
+ logger.info("[CodexAppServer] first stdout line stream=\(streamId) after=\(String(format: "%.2f", elapsed), privacy: .public)s")
+ }
guard let object = Self.decodeObject(line) else { continue }
if let id = Self.idString(object["id"]), object["method"] == nil {
diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift
index c356c2af..c51bf395 100644
--- a/RxCode/Services/FoundationModelSummarizationService.swift
+++ b/RxCode/Services/FoundationModelSummarizationService.swift
@@ -94,13 +94,11 @@ actor FoundationModelSummarizationService {
func generateMemoryOperations(
existingMemories: [(id: String, content: String)],
- userMessage: String,
- finalResponse: String
+ userMessages: [String]
) async -> String? {
let prompt = OpenAISummarizationService.memoryExtractionPrompt(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse
+ userMessages: userMessages
)
let raw = await respond(
instructions: "You extract concise durable memory as JSON operations. Output only JSON.",
@@ -109,19 +107,6 @@ actor FoundationModelSummarizationService {
return cleanSummary(raw, limit: 3000)
}
- func determineMemoryInjectionIntent(content: String, kind: String, scope: String) async -> String? {
- let prompt = OpenAISummarizationService.memoryInjectionIntentPrompt(
- content: content,
- kind: kind,
- scope: scope
- )
- let raw = await respond(
- instructions: "You classify durable IDE memories. Output only true or false.",
- prompt: prompt
- )
- return cleanSummary(raw, limit: 16)
- }
-
func generateBranchBriefing(
threadSummaries: [(title: String, summary: String)]
) async -> String? {
diff --git a/RxCode/Services/MemoryService.swift b/RxCode/Services/MemoryService.swift
index 25965c97..19d73059 100644
--- a/RxCode/Services/MemoryService.swift
+++ b/RxCode/Services/MemoryService.swift
@@ -45,9 +45,14 @@ actor MemoryService {
.sorted { $0.updatedAt > $1.updatedAt }
}
- func search(_ query: String, projectId: UUID?, limit: Int = 20) async -> [Hit] {
+ func search(_ query: String, projectId: UUID?, limit: Int = 20, minScore: Float? = nil) async -> [Hit] {
+ let startedAt = Date()
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty, let qvec = embed(trimmed) else { return [] }
+ guard !trimmed.isEmpty, let qvec = embed(trimmed) else {
+ let elapsed = Date().timeIntervalSince(startedAt)
+ logger.info("[Memory] search skipped queryChars=\(trimmed.count) elapsed=\(String(format: "%.2f", elapsed), privacy: .public)s")
+ return []
+ }
let ranked = entries.values.compactMap { entry -> Hit? in
if let memoryProject = entry.item.projectId,
@@ -56,6 +61,9 @@ actor MemoryService {
return nil
}
let score = dot(qvec, entry.vector)
+ if let minScore, score <= minScore {
+ return nil
+ }
return Hit(item: entry.item, score: score)
}
.sorted { $0.score > $1.score }
@@ -66,6 +74,8 @@ actor MemoryService {
let ids = hits.map(\.item.id)
await MainActor.run { store.touchMemories(ids: ids) }
}
+ let elapsed = Date().timeIntervalSince(startedAt)
+ logger.info("[Memory] search queryChars=\(trimmed.count) entries=\(self.entries.count) hits=\(hits.count) minScore=\(minScore.map { String(format: "%.2f", $0) } ?? "", privacy: .public) elapsed=\(String(format: "%.2f", elapsed), privacy: .public)s")
return hits
}
diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift
index 271050fc..51aa9ba5 100644
--- a/RxCode/Services/OpenAISummarizationService.swift
+++ b/RxCode/Services/OpenAISummarizationService.swift
@@ -155,32 +155,18 @@ actor OpenAISummarizationService {
func generateMemoryOperations(
existingMemories: [(id: String, content: String)],
- userMessage: String,
- finalResponse: String,
+ userMessages: [String],
endpoint: String,
apiKey: String,
model: String
) async -> String? {
let prompt = Self.memoryExtractionPrompt(
existingMemories: existingMemories,
- userMessage: userMessage,
- finalResponse: finalResponse
+ userMessages: userMessages
)
return await generateSummary(prompt: prompt, endpoint: endpoint, apiKey: apiKey, model: model, maxTokens: 512)
}
- func determineMemoryInjectionIntent(
- content: String,
- kind: String,
- scope: String,
- endpoint: String,
- apiKey: String,
- model: String
- ) async -> String? {
- let prompt = Self.memoryInjectionIntentPrompt(content: content, kind: kind, scope: scope)
- return await generateSummary(prompt: prompt, endpoint: endpoint, apiKey: apiKey, model: model, maxTokens: 8)
- }
-
func generateBranchBriefing(
threadSummaries: [(title: String, summary: String)],
endpoint: String,
@@ -279,22 +265,25 @@ actor OpenAISummarizationService {
static func memoryExtractionPrompt(
existingMemories: [(id: String, content: String)],
- userMessage: String,
- finalResponse: String
+ userMessages: [String]
) -> String {
let existing = existingMemories.isEmpty
? "None"
: existingMemories.map { "- id: \($0.id)\n content: \($0.content)" }.joined(separator: "\n")
+ let messages = userMessages.enumerated().map { idx, message in
+ "### User message \(idx + 1)\n\(message)"
+ }.joined(separator: "\n\n")
return """
- Decide whether the latest chat turn contains explicit durable user memory for a local coding IDE.
+ Decide whether the user-authored messages at the end of this chat contain durable user memory for a local coding IDE.
+ The user messages below are the only source for new memory. Do not infer memory from assistant responses, summaries, tool output, files changed, build/test results, or completed implementation notes.
Return [] unless the user explicitly asks to remember something, states a stable preference, or gives a recurring instruction for future/next-time agent runs.
Store only the user's reusable preference, recurring workflow instruction, naming convention, or explicitly requested remember-this note.
For recurring instructions, preserve the recurrence marker in the memory text, such as "Always...", "Never...", "From now on...", "By default...", or "Next time...".
- Use the assistant response only to confirm the exact wording of an explicit user-requested memory.
Do not save routine requests, one-off tasks, bug reports, temporary debugging details, implementation steps, command requests, build/test results, files changed, tool availability, secrets, API keys, credentials, or vague observations.
- Do not add memory simply because the user sent a message. Most user messages should produce [].
+ Do not save greetings or assistant-style conversation text such as "Hi", "Hi! How can I help you today?", "Updated it.", "Implemented debug timing logs", "Build succeeded", or "What changed:".
+ Do not add memory simply because the user sent messages. Most chats should produce [].
Add at most 1-2 memories, and only when each memory is clearly reusable beyond the current conversation.
If an existing memory should be refined, return an update operation with its id. If a memory is no longer valid because the user corrected it, return delete.
@@ -306,28 +295,8 @@ actor OpenAISummarizationService {
Existing related memories:
\(existing)
- Latest user message:
- \(String(userMessage.prefix(3000)))
-
- Final assistant response:
- \(String(finalResponse.prefix(3000)))
- """
- }
-
- static func memoryInjectionIntentPrompt(content: String, kind: String, scope: String) -> String {
- """
- Decide whether this saved memory should be injected into every future agent system prompt.
-
- Reply with ONLY true or false.
-
- Return true only when the memory captures the user's durable intent: a reusable preference, recurring workflow instruction, naming/style convention, default behavior, "always/never" rule, or explicitly requested remember-this note.
-
- Return false for ordinary facts, completed work, build/test results, files changed, temporary task state, one-off decisions, tool availability, debugging details, or any memory that is useful only when retrieved by related search.
-
- Memory kind: \(kind)
- Memory scope: \(scope)
- Memory content:
- \(String(content.prefix(1200)))
+ User-authored messages:
+ \(messages)
"""
}
diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift
index 17155bc4..3914189f 100644
--- a/RxCode/Services/ThreadStore.swift
+++ b/RxCode/Services/ThreadStore.swift
@@ -113,6 +113,25 @@ final class ThreadStore {
return ((try? context.fetch(descriptor)) ?? []).map { $0.toItem() }
}
+ @discardableResult
+ func deleteBriefingMetadata(excludingProjectIds knownProjectIds: Set) -> (threadSummaries: Int, branchBriefings: Int) {
+ let summaryRows = (try? context.fetch(FetchDescriptor())) ?? []
+ let orphanedSummaries = summaryRows.filter { !knownProjectIds.contains($0.projectId) }
+
+ let briefingRows = (try? context.fetch(FetchDescriptor())) ?? []
+ let orphanedBriefings = briefingRows.filter { !knownProjectIds.contains($0.projectId) }
+
+ guard !orphanedSummaries.isEmpty || !orphanedBriefings.isEmpty else {
+ return (0, 0)
+ }
+
+ for row in orphanedSummaries { context.delete(row) }
+ for row in orphanedBriefings { context.delete(row) }
+ save()
+
+ return (orphanedSummaries.count, orphanedBriefings.count)
+ }
+
// MARK: - Writes
/// Insert or update a thread row from a summary.
diff --git a/RxCode/Views/Onboarding/OnboardingACPPreview.swift b/RxCode/Views/Onboarding/OnboardingACPPreview.swift
new file mode 100644
index 00000000..1a5fc8a3
--- /dev/null
+++ b/RxCode/Views/Onboarding/OnboardingACPPreview.swift
@@ -0,0 +1,138 @@
+import RxCodeCore
+import SwiftUI
+
+struct ACPSetupPreview: View {
+ let appState: AppState
+ @Binding var installingAgentId: String?
+ @Binding var errorMessage: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 10) {
+ Image(systemName: "puzzlepiece.extension.fill")
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.accent)
+ Text("ACP Clients")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(.white)
+ Spacer()
+ if appState.acpRegistryLoading {
+ ProgressView().controlSize(.small)
+ } else {
+ Button {
+ Task { await appState.refreshACPRegistry(forceRefresh: true) }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ .foregroundStyle(.white.opacity(0.7))
+ }
+ .buttonStyle(.plain)
+ .help(LocalizedStringKey("Refresh registry"))
+ }
+ }
+
+ Text("Install additional ACP-compatible agents — RxCode downloads the right binary for macOS and probes the model list.")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+
+ ScrollView {
+ VStack(spacing: 8) {
+ if let agents = appState.acpRegistry?.agents, !agents.isEmpty {
+ ForEach(agents.prefix(6)) { agent in
+ registryRow(agent)
+ }
+ } else if appState.acpRegistryLoading {
+ Text("Loading registry…")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.54))
+ } else {
+ Text("Could not load registry. Check your network connection.")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.54))
+ }
+ }
+ }
+ .frame(maxHeight: 170)
+
+ if let errorMessage {
+ Text(verbatim: errorMessage)
+ .font(.system(size: 11))
+ .foregroundStyle(ClaudeTheme.statusError)
+ .lineLimit(2)
+ }
+ }
+ .padding(20)
+ .frame(maxWidth: 620)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color.black.opacity(0.34))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ .task {
+ if appState.acpRegistry == nil && !appState.acpRegistryLoading {
+ await appState.refreshACPRegistry()
+ }
+ }
+ }
+
+ private func registryRow(_ agent: ACPRegistryAgent) -> some View {
+ let alreadyInstalled = appState.acpClients.contains { $0.registryId == agent.id }
+ let isInstalling = installingAgentId == agent.id
+ return HStack(spacing: 10) {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Text(verbatim: agent.name)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.white)
+ Text(verbatim: "v\(agent.version)")
+ .font(.system(size: 10))
+ .foregroundStyle(.white.opacity(0.5))
+ }
+ Text(verbatim: agent.description)
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.6))
+ .lineLimit(2)
+ }
+ Spacer(minLength: 8)
+ Group {
+ if alreadyInstalled {
+ Text("Installed")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(ClaudeTheme.statusSuccess)
+ } else if isInstalling {
+ ProgressView().controlSize(.small)
+ } else {
+ Button {
+ install(agent)
+ } label: {
+ Text("Add")
+ .font(.system(size: 11, weight: .semibold))
+ .frame(width: 56, height: 24)
+ .foregroundStyle(.white)
+ .background(ClaudeTheme.accent.opacity(0.85), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ .disabled(installingAgentId != nil)
+ }
+ }
+ }
+ .padding(10)
+ .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ }
+
+ private func install(_ agent: ACPRegistryAgent) {
+ installingAgentId = agent.id
+ errorMessage = nil
+ Task {
+ defer { installingAgentId = nil }
+ do {
+ let spec = try await appState.installACPClient(from: agent)
+ appState.addACPClient(spec)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
diff --git a/RxCode/Views/Onboarding/OnboardingCLIPreview.swift b/RxCode/Views/Onboarding/OnboardingCLIPreview.swift
new file mode 100644
index 00000000..eb41fdfd
--- /dev/null
+++ b/RxCode/Views/Onboarding/OnboardingCLIPreview.swift
@@ -0,0 +1,135 @@
+import RxCodeCore
+import SwiftUI
+
+struct CLISetupPreview: View {
+ let isCheckingCLI: Bool
+ let claudeInstalled: Bool
+ let claudeVersion: String?
+ let claudeError: String?
+ let codexInstalled: Bool
+ let codexVersion: String?
+ let codexError: String?
+ let onCheckAgain: () -> Void
+
+ var body: some View {
+ VStack(spacing: 14) {
+ HStack(spacing: 10) {
+ Image(systemName: "terminal.fill")
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.accent)
+ Text("Agent CLI Setup")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(.white)
+ Spacer()
+ if isCheckingCLI {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ 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(claudeInstalled || codexInstalled ? LocalizedStringKey("Ready to start.") : LocalizedStringKey("Install one CLI, then check again."))
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.white.opacity(0.64))
+ Spacer()
+ Button {
+ onCheckAgain()
+ } label: {
+ Text("Check Again")
+ }
+ .buttonStyle(.plain)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.accent)
+ .disabled(isCheckingCLI)
+ }
+ .padding(.top, 2)
+ }
+ .padding(20)
+ .frame(maxWidth: 620)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color.black.opacity(0.34))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ }
+}
+
+struct CLIStatusRow: View {
+ let title: String
+ let installed: Bool
+ let version: String?
+ let error: String?
+ let installCommand: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 9) {
+ Image(systemName: installed ? "checkmark.circle.fill" : "xmark.circle.fill")
+ .foregroundStyle(installed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError)
+ Text(verbatim: installed
+ ? "\(title) \(String(localized: "installed"))\(version.map { " - \($0)" } ?? "")"
+ : "\(title) \(String(localized: "not found"))")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.white.opacity(0.9))
+ Spacer()
+ }
+
+ if !installed {
+ if let error {
+ Text(verbatim: error)
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.54))
+ .lineLimit(2)
+ }
+
+ HStack(spacing: 8) {
+ Text(verbatim: installCommand)
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundStyle(.white.opacity(0.86))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
+ .textSelection(.enabled)
+
+ Button {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(installCommand, forType: .string)
+ } label: {
+ Image(systemName: "doc.on.doc")
+ .font(.system(size: 12, weight: .semibold))
+ .frame(width: 30, height: 30)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.white.opacity(0.72))
+ .background(Color.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
+ .help(LocalizedStringKey("Copy install command"))
+ }
+ }
+ }
+ .padding(12)
+ .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ }
+}
diff --git a/RxCode/Views/Onboarding/OnboardingMCPPreview.swift b/RxCode/Views/Onboarding/OnboardingMCPPreview.swift
new file mode 100644
index 00000000..75509014
--- /dev/null
+++ b/RxCode/Views/Onboarding/OnboardingMCPPreview.swift
@@ -0,0 +1,136 @@
+import RxCodeCore
+import SwiftUI
+
+struct MCPSetupPreview: View {
+ @Binding var spec: MCPServerSpec
+ @Binding var argsText: String
+ @Binding var isSaving: Bool
+ @Binding var added: Bool
+ @Binding var errorMessage: String?
+ let onSave: () async -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 10) {
+ Image(systemName: "server.rack")
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.accent)
+ Text("First MCP Server")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(.white)
+ Spacer()
+ if added {
+ Label(LocalizedStringKey("Added"), systemImage: "checkmark.circle.fill")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.statusSuccess)
+ }
+ }
+
+ Text("Connect any MCP server to give every agent extra tools. You can also skip this and add servers later in Settings → MCP.")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+
+ field(label: LocalizedStringKey("Name"), placeholder: "my-server", text: $spec.name)
+
+ HStack(spacing: 8) {
+ Text("Transport")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+ .frame(width: 78, alignment: .leading)
+ Picker("", selection: $spec.transport) {
+ ForEach(MCPTransport.allCases) { t in
+ Text(verbatim: t.displayNameText).tag(t)
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.segmented)
+ }
+
+ if spec.transport == .stdio {
+ field(label: LocalizedStringKey("Command"), placeholder: "npx", text: $spec.command)
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Args (one per line)")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.62))
+ TextEditor(text: $argsText)
+ .font(.system(size: 12, design: .monospaced))
+ .frame(minHeight: 48, maxHeight: 80)
+ .scrollContentBackground(.hidden)
+ .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 6))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ .onChange(of: argsText) { _, newValue in
+ spec.args = newValue
+ .split(whereSeparator: \.isNewline)
+ .map { String($0).trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ }
+ }
+ } else {
+ field(label: LocalizedStringKey("URL"), placeholder: "https://example.com/mcp", text: $spec.url)
+ }
+
+ if let errorMessage {
+ Text(verbatim: errorMessage)
+ .font(.system(size: 11))
+ .foregroundStyle(ClaudeTheme.statusError)
+ .lineLimit(2)
+ }
+
+ HStack {
+ Spacer()
+ Button {
+ Task { await onSave() }
+ } label: {
+ HStack(spacing: 6) {
+ if isSaving {
+ ProgressView().controlSize(.small)
+ }
+ Text(added ? LocalizedStringKey("Saved") : LocalizedStringKey("Save Server"))
+ .font(.system(size: 12, weight: .semibold))
+ }
+ .foregroundStyle(.white)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 7)
+ .background((canSave ? ClaudeTheme.accent : ClaudeTheme.textTertiary).opacity(0.85), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ .disabled(!canSave || isSaving || added)
+ }
+ }
+ .padding(20)
+ .frame(maxWidth: 620)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color.black.opacity(0.34))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ }
+
+ private var canSave: Bool {
+ guard !spec.name.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
+ switch spec.transport {
+ case .stdio:
+ return !spec.command.trimmingCharacters(in: .whitespaces).isEmpty
+ case .http, .sse:
+ return URL(string: spec.url) != nil && !spec.url.isEmpty
+ }
+ }
+
+ private func field(label: LocalizedStringKey, placeholder: String, text: Binding) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(label)
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+ .frame(width: 78, alignment: .leading)
+ TextField(placeholder, text: text)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 12, design: .monospaced))
+ }
+ }
+}
diff --git a/RxCode/Views/Onboarding/OnboardingMobilePairingPreview.swift b/RxCode/Views/Onboarding/OnboardingMobilePairingPreview.swift
new file mode 100644
index 00000000..3baf81d1
--- /dev/null
+++ b/RxCode/Views/Onboarding/OnboardingMobilePairingPreview.swift
@@ -0,0 +1,310 @@
+import CoreImage.CIFilterBuiltins
+import RxCodeCore
+import RxCodeSync
+import SwiftUI
+
+struct MobilePairingPreview: View {
+ let token: PairingToken?
+ let qrImage: NSImage?
+ let downloadLinks: DownloadLinks?
+ let isLoadingDownloads: Bool
+ let errorMessage: String?
+ let savedRelayServers: [SavedRelayServer]
+ @Binding var selectedRelayServerID: UUID?
+ let onRegenerate: () -> Void
+ let onAddRelayServer: () -> Void
+
+ private var hasDownloadOptions: Bool {
+ guard let links = downloadLinks else { return false }
+ let urls = [
+ links.ios.appStoreURL,
+ links.android.playStoreURL,
+ links.android.apkURL
+ ]
+ return urls.contains { !$0.isEmpty }
+ }
+
+ private var showDownloadPanel: Bool {
+ isLoadingDownloads || hasDownloadOptions
+ }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 20) {
+ pairingPanel
+ if showDownloadPanel {
+ downloadPanel
+ }
+ }
+ .frame(maxWidth: 640)
+ }
+
+ private var pairingPanel: some View {
+ VStack(spacing: 12) {
+ HStack(spacing: 8) {
+ Image(systemName: "qrcode")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.accent)
+ Text("Pair via QR")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(.white)
+ Spacer()
+ }
+
+ relaySelector
+
+ if let qrImage {
+ Image(nsImage: qrImage)
+ .interpolation(.none)
+ .resizable()
+ .frame(width: 180, height: 180)
+ .background(Color.white.opacity(0.96))
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ } else if let errorMessage {
+ VStack(spacing: 6) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(ClaudeTheme.statusWarning)
+ Text(verbatim: errorMessage)
+ .font(.system(size: 11))
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.white.opacity(0.7))
+ }
+ .frame(width: 180, height: 180)
+ .background(Color.black.opacity(0.32), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ } else {
+ ProgressView()
+ .frame(width: 180, height: 180)
+ }
+
+ if let token {
+ TimelineView(.periodic(from: .now, by: 1)) { context in
+ let remaining = Int(token.expiresAt.timeIntervalSince(context.date).rounded(.down))
+ Text(remaining > 0
+ ? String(format: NSLocalizedString("Expires in %d:%02d", comment: ""), remaining / 60, remaining % 60)
+ : String(localized: "Expired"))
+ .font(.system(size: 10))
+ .foregroundStyle(.white.opacity(0.55))
+ }
+ }
+
+ Button {
+ onRegenerate()
+ } label: {
+ Label(LocalizedStringKey("Regenerate"), systemImage: "arrow.clockwise")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color.white.opacity(0.14), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(18)
+ .frame(maxWidth: .infinity)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color.black.opacity(0.34))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ }
+
+ @ViewBuilder
+ private var relaySelector: some View {
+ if savedRelayServers.isEmpty {
+ VStack(spacing: 8) {
+ Text("No relay server configured")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.white.opacity(0.7))
+ .multilineTextAlignment(.center)
+ Button {
+ onAddRelayServer()
+ } label: {
+ Label(LocalizedStringKey("Add Relay Server"), systemImage: "plus")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(ClaudeTheme.accent.opacity(0.85), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 6)
+ } else {
+ HStack(spacing: 6) {
+ Text("Relay")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.white.opacity(0.6))
+ Picker("", selection: $selectedRelayServerID) {
+ ForEach(savedRelayServers) { server in
+ Text(verbatim: server.name).tag(Optional(server.id))
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .controlSize(.small)
+ .frame(maxWidth: .infinity)
+
+ Button {
+ onAddRelayServer()
+ } label: {
+ Image(systemName: "plus")
+ .font(.system(size: 11, weight: .semibold))
+ .frame(width: 22, height: 22)
+ .foregroundStyle(.white)
+ .background(Color.white.opacity(0.14), in: Circle())
+ }
+ .buttonStyle(.plain)
+ .help(LocalizedStringKey("Add Relay Server"))
+ }
+ }
+ }
+
+ private var downloadPanel: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 8) {
+ Image(systemName: "iphone.gen3")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.accent)
+ Text("Install the app")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(.white)
+ Spacer()
+ }
+
+ Text("Open RxCode on your phone, then tap Pair Device and scan the QR code.")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+
+ if isLoadingDownloads {
+ ProgressView()
+ .controlSize(.small)
+ .padding(.vertical, 8)
+ } else if let links = downloadLinks {
+ downloadButtons(links: links)
+ } else {
+ Text("Download links unavailable. Visit rxlab.app to install the mobile app.")
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.54))
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(18)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color.black.opacity(0.34))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ }
+
+ @ViewBuilder
+ private func downloadButtons(links: DownloadLinks) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if let url = URL(string: links.ios.appStoreURL), !links.ios.appStoreURL.isEmpty {
+ downloadLinkButton(
+ icon: "applelogo",
+ label: LocalizedStringKey("Download for iOS"),
+ sublabel: links.ios.version,
+ url: url
+ )
+ }
+ if let url = URL(string: links.android.playStoreURL), !links.android.playStoreURL.isEmpty {
+ downloadLinkButton(
+ icon: "smartphone",
+ label: LocalizedStringKey("Get it on Google Play"),
+ sublabel: links.android.version,
+ url: url
+ )
+ }
+ if let url = URL(string: links.android.apkURL), !links.android.apkURL.isEmpty {
+ downloadLinkButton(
+ icon: "arrow.down.circle",
+ label: LocalizedStringKey("Download APK"),
+ sublabel: links.android.version,
+ url: url
+ )
+ }
+ }
+ }
+
+ private func downloadLinkButton(icon: String, label: LocalizedStringKey, sublabel: String, url: URL) -> some View {
+ Link(destination: url) {
+ HStack(spacing: 10) {
+ Image(systemName: icon)
+ .font(.system(size: 14, weight: .semibold))
+ .frame(width: 22)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(label)
+ .font(.system(size: 12, weight: .semibold))
+ if !sublabel.isEmpty {
+ Text(verbatim: sublabel)
+ .font(.system(size: 10))
+ .foregroundStyle(.white.opacity(0.55))
+ }
+ }
+ Spacer()
+ Image(systemName: "arrow.up.right.square")
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.5))
+ }
+ .foregroundStyle(.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 9)
+ .background(Color.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+// MARK: - Download links model
+
+struct DownloadLinks: Decodable, Sendable {
+ struct IOS: Decodable, Sendable {
+ let appStoreURL: String
+ let testflightURL: String
+ let version: String
+ }
+ struct Android: Decodable, Sendable {
+ let playStoreURL: String
+ let apkURL: String
+ let version: String
+ }
+ let ios: IOS
+ let android: Android
+
+ private static let endpoint = URL(string: "https://rxlab.app/api/download")!
+
+ static func fetch() async -> DownloadLinks? {
+ var request = URLRequest(url: endpoint)
+ request.timeoutInterval = 6
+ do {
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ return nil
+ }
+ return try JSONDecoder().decode(DownloadLinks.self, from: data)
+ } catch {
+ return nil
+ }
+ }
+}
+
+// MARK: - QR code helper
+
+func makeOnboardingQRCode(from string: String) -> NSImage? {
+ let context = CIContext()
+ let filter = CIFilter.qrCodeGenerator()
+ filter.setValue(Data(string.utf8), forKey: "inputMessage")
+ filter.correctionLevel = "M"
+ guard let outputImage = filter.outputImage else { return nil }
+ let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: 8, y: 8))
+ guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
+ return NSImage(cgImage: cgImage, size: NSSize(width: 180, height: 180))
+}
diff --git a/RxCode/Views/Onboarding/OnboardingMockComponents.swift b/RxCode/Views/Onboarding/OnboardingMockComponents.swift
new file mode 100644
index 00000000..556da311
--- /dev/null
+++ b/RxCode/Views/Onboarding/OnboardingMockComponents.swift
@@ -0,0 +1,195 @@
+import RxCodeCore
+import SwiftUI
+
+// MARK: - Workspace / approval previews
+
+struct WorkspacePreview: View {
+ var body: some View {
+ OnboardingMockWindow(title: "RxCode") {
+ HStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 12) {
+ PreviewSidebarRow(icon: "folder", title: LocalizedStringKey("Projects"), isActive: true)
+ PreviewSidebarRow(icon: "clock", title: LocalizedStringKey("Threads"), isActive: false)
+ PreviewSidebarRow(icon: "doc.text", title: LocalizedStringKey("Briefing"), isActive: false)
+ Spacer()
+ }
+ .frame(width: 148)
+ .padding(14)
+ .background(Color.white.opacity(0.06))
+
+ VStack(alignment: .leading, spacing: 12) {
+ PreviewBubble(title: LocalizedStringKey("Refactor onboarding into slides"), isUser: true)
+ PreviewToolRow(icon: "terminal", title: LocalizedStringKey("Running swift build"), accent: ClaudeTheme.statusRunning)
+ PreviewBubble(title: LocalizedStringKey("I found the existing onboarding view and will keep the CLI check as setup."), isUser: false)
+ Spacer()
+ }
+ .padding(18)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .frame(maxWidth: 620)
+ }
+}
+
+struct ApprovalPreview: View {
+ var body: some View {
+ OnboardingMockWindow(title: LocalizedStringKey("Permission Request")) {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(spacing: 12) {
+ Image(systemName: "exclamationmark.shield.fill")
+ .font(.system(size: 24, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.statusWarning)
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Review Command")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(.white)
+ Text("The agent wants to run a command in this project.")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+ }
+ }
+
+ Text(verbatim: "xcodebuild -project RxCode.xcodeproj -scheme RxCode build")
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundStyle(.white.opacity(0.9))
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+
+ HStack(spacing: 10) {
+ PreviewActionButton(title: LocalizedStringKey("Deny"), fill: Color.white.opacity(0.12))
+ PreviewActionButton(title: LocalizedStringKey("Allow"), fill: ClaudeTheme.accent)
+ }
+ }
+ .padding(22)
+ }
+ .frame(maxWidth: 540)
+ }
+}
+
+// MARK: - Shared layout primitives
+
+struct OnboardingMockWindow: View {
+ let title: LocalizedStringKey
+ @ViewBuilder let content: Content
+
+ init(title: LocalizedStringKey, @ViewBuilder content: () -> Content) {
+ self.title = title
+ self.content = content()
+ }
+
+ init(title: String, @ViewBuilder content: () -> Content) {
+ self.title = LocalizedStringKey(title)
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 8) {
+ Circle().fill(Color.red.opacity(0.82)).frame(width: 10, height: 10)
+ Circle().fill(Color.yellow.opacity(0.82)).frame(width: 10, height: 10)
+ Circle().fill(Color.green.opacity(0.82)).frame(width: 10, height: 10)
+ Spacer()
+ Text(title)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.white.opacity(0.58))
+ Spacer()
+ }
+ .padding(.horizontal, 14)
+ .frame(height: 38)
+ .background(Color.black.opacity(0.32))
+
+ content
+ .frame(height: 268)
+ .background(
+ LinearGradient(
+ colors: [
+ Color(hex: 0x171A23),
+ Color(hex: 0x202536),
+ Color(hex: 0x11131A)
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ .shadow(color: .black.opacity(0.28), radius: 18, y: 14)
+ }
+}
+
+struct PreviewSidebarRow: View {
+ let icon: String
+ let title: LocalizedStringKey
+ let isActive: Bool
+
+ var body: some View {
+ HStack(spacing: 9) {
+ Image(systemName: icon)
+ .font(.system(size: 12, weight: .semibold))
+ Text(title)
+ .font(.system(size: 12, weight: .medium))
+ Spacer()
+ }
+ .foregroundStyle(isActive ? .white : .white.opacity(0.58))
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(isActive ? ClaudeTheme.accent.opacity(0.82) : Color.clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+}
+
+struct PreviewBubble: View {
+ let title: LocalizedStringKey
+ let isUser: Bool
+
+ var body: some View {
+ Text(title)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.white.opacity(0.88))
+ .lineLimit(2)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .frame(maxWidth: isUser ? 250 : 340, alignment: .leading)
+ .background(isUser ? ClaudeTheme.accent.opacity(0.72) : Color.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
+ .frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
+ }
+}
+
+struct PreviewToolRow: View {
+ let icon: String
+ let title: LocalizedStringKey
+ let accent: Color
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(accent)
+ Text(title)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.white.opacity(0.72))
+ Spacer()
+ }
+ .padding(.horizontal, 11)
+ .padding(.vertical, 8)
+ .background(Color.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+}
+
+struct PreviewActionButton: View {
+ let title: LocalizedStringKey
+ let fill: Color
+
+ var body: some View {
+ Text(title)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(fill, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+}
diff --git a/RxCode/Views/Onboarding/OnboardingSummarizationPreview.swift b/RxCode/Views/Onboarding/OnboardingSummarizationPreview.swift
new file mode 100644
index 00000000..172b360a
--- /dev/null
+++ b/RxCode/Views/Onboarding/OnboardingSummarizationPreview.swift
@@ -0,0 +1,163 @@
+import RxCodeCore
+import SwiftUI
+
+struct SummarizationSetupPreview: View {
+ let appState: AppState
+ @Binding var endpointDraft: String
+ @Binding var apiKeyDraft: String
+ @Binding var hasLoadedModels: Bool
+
+ var body: some View {
+ @Bindable var appState = appState
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(spacing: 10) {
+ Image(systemName: "text.alignleft")
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(ClaudeTheme.accent)
+ Text("Summarization Model")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(.white)
+ Spacer()
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Provider")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.62))
+ Picker("", selection: $appState.summarizationProvider) {
+ ForEach(SummarizationProvider.allCases) { provider in
+ Text(provider.displayName).tag(provider)
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.segmented)
+ }
+
+ switch appState.summarizationProvider {
+ case .selectedClient:
+ Text("Uses the model picked by the current thread. No extra configuration needed.")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+ case .appleFoundationModel:
+ Text("Runs on-device using Apple Intelligence. Free, private, and offline.")
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+ case .openAI:
+ openAISection
+ }
+ }
+ .padding(20)
+ .frame(maxWidth: 620)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color.black.opacity(0.34))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 1)
+ )
+ }
+
+ private var openAISection: some View {
+ @Bindable var appState = appState
+ return VStack(alignment: .leading, spacing: 10) {
+ labeledField(
+ label: LocalizedStringKey("Endpoint"),
+ placeholder: AppState.defaultOpenAISummarizationEndpoint,
+ text: $endpointDraft
+ )
+ labeledSecureField(
+ label: LocalizedStringKey("API Key"),
+ placeholder: "sk-...",
+ text: $apiKeyDraft
+ )
+
+ HStack(spacing: 8) {
+ Picker("", selection: $appState.openAISummarizationModel) {
+ if !appState.openAISummarizationModel.isEmpty,
+ !appState.openAISummarizationModels.contains(appState.openAISummarizationModel) {
+ Text(verbatim: appState.openAISummarizationModel)
+ .tag(appState.openAISummarizationModel)
+ }
+ ForEach(appState.openAISummarizationModels, id: \.self) { model in
+ Text(verbatim: model).tag(model)
+ }
+ if appState.openAISummarizationModels.isEmpty && appState.openAISummarizationModel.isEmpty {
+ Text("Fetch models first").tag("")
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .frame(maxWidth: 280)
+
+ Button {
+ persistDrafts()
+ Task { await appState.refreshOpenAISummarizationModels() }
+ } label: {
+ HStack(spacing: 4) {
+ if appState.isLoadingOpenAISummarizationModels {
+ ProgressView().controlSize(.small)
+ } else {
+ Image(systemName: "arrow.clockwise")
+ }
+ Text("Fetch Models")
+ }
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 7)
+ .background(ClaudeTheme.accent.opacity(0.85), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ .disabled(appState.isLoadingOpenAISummarizationModels)
+ }
+
+ if let error = appState.openAISummarizationModelsError {
+ Text(verbatim: error)
+ .font(.system(size: 11))
+ .foregroundStyle(ClaudeTheme.statusError)
+ }
+ }
+ .onAppear {
+ guard !hasLoadedModels else { return }
+ hasLoadedModels = true
+ persistDrafts()
+ if appState.openAISummarizationModels.isEmpty {
+ Task { await appState.refreshOpenAISummarizationModels() }
+ }
+ }
+ }
+
+ private func persistDrafts() {
+ if endpointDraft != appState.openAISummarizationEndpoint {
+ appState.openAISummarizationEndpoint = endpointDraft
+ }
+ if apiKeyDraft != appState.openAISummarizationAPIKey {
+ appState.openAISummarizationAPIKey = apiKeyDraft
+ }
+ }
+
+ private func labeledField(label: LocalizedStringKey, placeholder: String, text: Binding) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 10) {
+ Text(label)
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+ .frame(width: 78, alignment: .leading)
+ TextField(placeholder, text: text)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 12, design: .monospaced))
+ }
+ }
+
+ private func labeledSecureField(label: LocalizedStringKey, placeholder: String, text: Binding) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 10) {
+ Text(label)
+ .font(.system(size: 12))
+ .foregroundStyle(.white.opacity(0.62))
+ .frame(width: 78, alignment: .leading)
+ SecureField(placeholder, text: text)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 12, design: .monospaced))
+ }
+ }
+}
diff --git a/RxCode/Views/Onboarding/OnboardingView.swift b/RxCode/Views/Onboarding/OnboardingView.swift
index df80fe55..72c972ad 100644
--- a/RxCode/Views/Onboarding/OnboardingView.swift
+++ b/RxCode/Views/Onboarding/OnboardingView.swift
@@ -1,9 +1,14 @@
-import SwiftUI
import RxCodeCore
+import RxCodeSync
+import SwiftUI
struct OnboardingView: View {
@Environment(AppState.self) private var appState
var onCompletion: (() -> Void)? = nil
+
+ @State private var selectedIndex = 0
+
+ // CLI detection
@State private var isCheckingCLI = false
@State private var claudeInstalled = false
@State private var claudeVersion: String?
@@ -12,136 +17,352 @@ struct OnboardingView: View {
@State private var codexVersion: String?
@State private var codexError: String?
- private var canContinue: Bool { claudeInstalled || codexInstalled }
+ // ACP install
+ @State private var installingAgentId: String?
+ @State private var acpError: String?
- var body: some View {
- VStack(spacing: 0) {
- Spacer()
+ // Summarization
+ @State private var summarizationEndpointDraft: String = ""
+ @State private var summarizationAPIKeyDraft: String = ""
+ @State private var hasLoadedSummarizationModels = false
- cliCheckStep
- .frame(maxWidth: 460)
+ // Mobile pairing
+ @State private var pairingToken: PairingToken?
+ @State private var pairingQRImage: NSImage?
+ @State private var pairingError: String?
+ @State private var downloadLinks: DownloadLinks?
+ @State private var isLoadingDownloads = false
+ @State private var pairingReloadTask: Task?
+ @State private var selectedRelayServerID: UUID?
+ @State private var showAddRelayServerSheet = false
+ @State private var relayPresetCatalog = RelayPresetCatalog.shared
+ @StateObject private var mobileSync = MobileSyncService.shared
- Spacer()
+ // MCP setup
+ @State private var mcpSpec = MCPServerSpec()
+ @State private var mcpArgsText = ""
+ @State private var mcpError: String?
+ @State private var mcpSaving = false
+ @State private var mcpAdded = false
- navigationButtons
- .padding(.bottom, 24)
+ private let slides = OnboardingSlide.all
+
+ private var isFirstSlide: Bool {
+ selectedIndex == 0
+ }
+
+ private var isLastSlide: Bool {
+ selectedIndex == slides.count - 1
+ }
+
+ private var currentSlide: OnboardingSlide {
+ slides[selectedIndex]
+ }
+
+ private var canContinue: Bool {
+ switch currentSlide.visual {
+ case .cliSetup:
+ return claudeInstalled || codexInstalled
+ default:
+ return true
+ }
+ }
+
+ var body: some View {
+ GeometryReader { proxy in
+ let contentWidth = min(proxy.size.width - 72, 820)
+
+ ZStack {
+ onboardingBackdrop
+
+ slideCard
+ .frame(width: contentWidth, height: min(proxy.size.height - 68, 660))
+ .padding(.vertical, 34)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
- .padding(.horizontal, 40)
- .frame(width: 600, height: 560)
+ .frame(minWidth: 760, idealWidth: 860, minHeight: 700, idealHeight: 760)
.background(ClaudeTheme.background)
.task {
await checkCLI()
+ await loadDownloadLinks()
+ await relayPresetCatalog.refresh()
+ summarizationEndpointDraft = appState.openAISummarizationEndpoint
+ summarizationAPIKeyDraft = appState.openAISummarizationAPIKey
+ if selectedRelayServerID == nil {
+ selectedRelayServerID = mobileSync.savedRelayServers.first?.id
+ }
+ }
+ .sheet(isPresented: $showAddRelayServerSheet, onDismiss: {
+ if selectedRelayServerID == nil || !mobileSync.savedRelayServers.contains(where: { $0.id == selectedRelayServerID }) {
+ selectedRelayServerID = mobileSync.savedRelayServers.last?.id
+ }
+ startPairing()
+ }) {
+ RelayServerEditorSheet(sync: mobileSync, catalog: relayPresetCatalog, existingServer: nil)
+ }
+ .onDisappear {
+ pairingReloadTask?.cancel()
+ pairingReloadTask = nil
+ // Don't leave a partial pairing token alive once the user leaves
+ // onboarding without finishing.
+ if MobileSyncService.shared.pendingPairing == nil {
+ MobileSyncService.shared.cancelPairing()
+ }
}
}
- // MARK: - CLI Check
-
- private var cliCheckStep: some View {
- VStack(spacing: 20) {
- Image(systemName: "terminal")
- .font(.system(size: ClaudeTheme.size(48)))
- .foregroundStyle(ClaudeTheme.accent)
+ private var onboardingBackdrop: some View {
+ ZStack {
+ LinearGradient(
+ colors: [
+ Color(nsColor: .windowBackgroundColor),
+ ClaudeTheme.background,
+ ClaudeTheme.surfaceSecondary.opacity(0.72)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
- Text("Claude CLI Installation Check")
- .font(.title2)
- .fontWeight(.semibold)
- .foregroundStyle(ClaudeTheme.textPrimary)
-
- if isCheckingCLI {
- ProgressView("Checking...")
+ VStack(spacing: 0) {
+ Rectangle()
+ .fill(
+ LinearGradient(
+ colors: [
+ ClaudeTheme.accent.opacity(0.22),
+ ClaudeTheme.surfacePrimary.opacity(0.08),
+ .clear
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .frame(height: 260)
+ Spacer()
}
+ }
+ .ignoresSafeArea()
+ }
- 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"
+ private var slideCard: some View {
+ ZStack {
+ RoundedRectangle(cornerRadius: 28, style: .continuous)
+ .fill(
+ LinearGradient(
+ colors: [
+ ClaudeTheme.surfaceElevated,
+ ClaudeTheme.surfacePrimary,
+ ClaudeTheme.background.opacity(0.96)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
)
- }
+ .shadow(color: .black.opacity(0.18), radius: 28, y: 18)
- 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()
- }
+ RoundedRectangle(cornerRadius: 28, style: .continuous)
+ .strokeBorder(.white.opacity(0.14), lineWidth: 1)
- if !installed {
- if let error {
- Text(error)
- .font(.caption)
- .foregroundStyle(ClaudeTheme.textSecondary)
+ ZStack {
+ ForEach(Array(slides.enumerated()), id: \.element.id) { index, slide in
+ slideContent(slide)
+ .opacity(selectedIndex == index ? 1 : 0)
+ .scaleEffect(selectedIndex == index ? 1 : 0.96)
+ .offset(x: CGFloat(index - selectedIndex) * 72)
+ .allowsHitTesting(selectedIndex == index)
}
+ }
+ .animation(.snappy(duration: 0.46), value: selectedIndex)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
+ }
+
+ private func slideContent(_ slide: OnboardingSlide) -> some View {
+ VStack(spacing: 0) {
+ ZStack(alignment: .top) {
+ slideVisual(slide.visual)
+ .padding(.horizontal, 42)
+ .padding(.top, 36)
+ .padding(.bottom, 22)
+
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)
+ circularNavigationButton(systemImage: "chevron.left") {
+ goBack()
+ }
+ .opacity(isFirstSlide ? 0.26 : 1)
+ .disabled(isFirstSlide)
+
+ Spacer()
+
+ circularNavigationButton(systemImage: isLastSlide ? "checkmark" : "chevron.right") {
+ advance()
}
- .buttonStyle(.borderless)
- .help("Copy")
+ .disabled(!canContinue)
+ .opacity(canContinue ? 1 : 0.38)
}
+ .padding(20)
+ }
+
+ pageIndicator
+ .padding(.bottom, 24)
+
+ VStack(spacing: 9) {
+ Text(slide.title)
+ .font(.system(size: ClaudeTheme.size(28), weight: .semibold))
+ .foregroundStyle(ClaudeTheme.textPrimary)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Text(slide.subtitle)
+ .font(.system(size: ClaudeTheme.size(15)))
+ .foregroundStyle(ClaudeTheme.textSecondary)
+ .multilineTextAlignment(.center)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: 520)
+ }
+ .padding(.horizontal, 48)
+
+ Spacer(minLength: 18)
+
+ Button {
+ advance()
+ } label: {
+ Text(isLastSlide ? LocalizedStringKey("Get Started") : LocalizedStringKey("Continue"))
+ .font(.system(size: ClaudeTheme.size(15), weight: .semibold))
+ .frame(width: 230, height: 44)
}
+ .buttonStyle(.plain)
+ .foregroundStyle(.white)
+ .background(canContinue ? ClaudeTheme.accent : ClaudeTheme.textTertiary, in: Capsule())
+ .shadow(color: ClaudeTheme.accent.opacity(canContinue ? 0.24 : 0), radius: 12, y: 6)
+ .disabled(!canContinue)
+ .padding(.bottom, 34)
}
- .padding(12)
- .background(ClaudeTheme.surfacePrimary)
- .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall))
}
- // MARK: - Navigation
+ @ViewBuilder
+ private func slideVisual(_ visual: OnboardingSlide.Visual) -> some View {
+ switch visual {
+ case .workspace:
+ WorkspacePreview()
+ case .approvals:
+ ApprovalPreview()
+ case .cliSetup:
+ CLISetupPreview(
+ isCheckingCLI: isCheckingCLI,
+ claudeInstalled: claudeInstalled,
+ claudeVersion: claudeVersion,
+ claudeError: claudeError,
+ codexInstalled: codexInstalled,
+ codexVersion: codexVersion,
+ codexError: codexError,
+ onCheckAgain: { Task { await checkCLI() } }
+ )
+ case .acpSetup:
+ ACPSetupPreview(
+ appState: appState,
+ installingAgentId: $installingAgentId,
+ errorMessage: $acpError
+ )
+ case .summarizationModel:
+ SummarizationSetupPreview(
+ appState: appState,
+ endpointDraft: $summarizationEndpointDraft,
+ apiKeyDraft: $summarizationAPIKeyDraft,
+ hasLoadedModels: $hasLoadedSummarizationModels
+ )
+ case .mobilePairing:
+ MobilePairingPreview(
+ token: pairingToken,
+ qrImage: pairingQRImage,
+ downloadLinks: downloadLinks,
+ isLoadingDownloads: isLoadingDownloads,
+ errorMessage: pairingError,
+ savedRelayServers: mobileSync.savedRelayServers,
+ selectedRelayServerID: $selectedRelayServerID,
+ onRegenerate: { startPairing() },
+ onAddRelayServer: { showAddRelayServerSheet = true }
+ )
+ .onAppear { startPairing() }
+ .onChange(of: selectedRelayServerID) { _, _ in
+ startPairing()
+ }
+ .onDisappear {
+ pairingReloadTask?.cancel()
+ pairingReloadTask = nil
+ }
+ case .mcpSetup:
+ MCPSetupPreview(
+ spec: $mcpSpec,
+ argsText: $mcpArgsText,
+ isSaving: $mcpSaving,
+ added: $mcpAdded,
+ errorMessage: $mcpError,
+ onSave: { await saveMCPServer() }
+ )
+ }
+ }
- private var navigationButtons: some View {
- HStack {
- Spacer()
- Button("Get Started") {
- appState.onboardingCompleted = true
- UserDefaults.standard.set(true, forKey: "onboardingCompleted")
- onCompletion?()
+ private var pageIndicator: some View {
+ HStack(spacing: 8) {
+ ForEach(slides.indices, id: \.self) { index in
+ Capsule()
+ .fill(index == selectedIndex ? ClaudeTheme.textPrimary : ClaudeTheme.textTertiary.opacity(0.36))
+ .frame(width: index == selectedIndex ? 24 : 8, height: 8)
+ .animation(.snappy(duration: 0.28), value: selectedIndex)
}
- .buttonStyle(ClaudeAccentButtonStyle())
- .disabled(!canContinue)
}
+ .accessibilityHidden(true)
+ }
+
+ private func circularNavigationButton(systemImage: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Image(systemName: systemImage)
+ .font(.system(size: ClaudeTheme.size(17), weight: .semibold))
+ .frame(width: 42, height: 42)
+ .foregroundStyle(.white)
+ .background(.white.opacity(0.18), in: Circle())
+ .overlay(Circle().strokeBorder(.white.opacity(0.14), lineWidth: 1))
+ }
+ .buttonStyle(.plain)
+ .help(systemImage == "chevron.left" ? LocalizedStringKey("Back") : LocalizedStringKey("Continue"))
+ }
+
+ private func goBack() {
+ guard selectedIndex > 0 else { return }
+ withAnimation(.snappy(duration: 0.42)) {
+ selectedIndex -= 1
+ }
+ }
+
+ private func advance(skipping: Bool = false) {
+ guard skipping || canContinue else { return }
+
+ if isLastSlide {
+ completeOnboarding()
+ } else {
+ withAnimation(.snappy(duration: 0.42)) {
+ selectedIndex += 1
+ }
+ }
+ }
+
+ private func completeOnboarding() {
+ // Persist any draft fields the user typed on summarization slide.
+ if summarizationEndpointDraft != appState.openAISummarizationEndpoint {
+ appState.openAISummarizationEndpoint = summarizationEndpointDraft
+ }
+ if summarizationAPIKeyDraft != appState.openAISummarizationAPIKey {
+ appState.openAISummarizationAPIKey = summarizationAPIKeyDraft
+ }
+ appState.onboardingCompleted = true
+ UserDefaults.standard.set(true, forKey: "onboardingCompleted")
+ onCompletion?()
}
- // MARK: - Helpers
+ // MARK: - CLI
private func checkCLI() async {
isCheckingCLI = true
@@ -157,12 +378,13 @@ struct OnboardingView: View {
appState.claudeBinaryPath = await appState.claude.findClaudeBinary()
} catch {
claudeInstalled = false
+ claudeVersion = nil
claudeError = error.localizedDescription
let binary = await appState.claude.findClaudeBinary()
appState.claudeBinaryPath = binary
if let binary {
- claudeError = "Binary found: \(binary), but version check failed"
+ claudeError = String(localized: "Binary found: \(binary), but version check failed")
claudeInstalled = true
appState.claudeInstalled = true
appState.claudeVersion = nil
@@ -181,12 +403,13 @@ struct OnboardingView: View {
appState.codexBinaryPath = await appState.codex.findCodexBinary()
} catch {
codexInstalled = false
+ codexVersion = nil
codexError = error.localizedDescription
let binary = await appState.codex.findCodexBinary()
appState.codexBinaryPath = binary
if let binary {
- codexError = "Binary found: \(binary), but version check failed"
+ codexError = String(localized: "Binary found: \(binary), but version check failed")
codexInstalled = true
appState.codexInstalled = true
appState.codexVersion = nil
@@ -199,6 +422,136 @@ struct OnboardingView: View {
isCheckingCLI = false
}
+ // MARK: - Mobile pairing
+
+ private func startPairing() {
+ pairingReloadTask?.cancel()
+ let sync = MobileSyncService.shared
+ guard !sync.savedRelayServers.isEmpty else {
+ pairingToken = nil
+ pairingQRImage = nil
+ pairingError = String(localized: "Add a relay server to pair your phone.")
+ return
+ }
+ let server = selectedRelayServerID.flatMap { id in
+ sync.savedRelayServers.first { $0.id == id }
+ }
+ guard let fresh = sync.beginPairing(viaServer: server) else {
+ pairingToken = nil
+ pairingQRImage = nil
+ pairingError = String(localized: "Could not start pairing. Pick a different relay server or add a new one.")
+ return
+ }
+ pairingToken = fresh
+ pairingQRImage = nil
+ pairingError = nil
+ if let qrString = try? fresh.qrString() {
+ pairingQRImage = makeOnboardingQRCode(from: qrString)
+ }
+ scheduleAutoReload(for: fresh)
+ }
+
+ private func scheduleAutoReload(for token: PairingToken) {
+ pairingReloadTask = Task { @MainActor in
+ let delay = max(0, token.expiresAt.timeIntervalSinceNow)
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ guard !Task.isCancelled else { return }
+ guard MobileSyncService.shared.pendingPairing == nil else { return }
+ startPairing()
+ }
+ }
+
+ private func loadDownloadLinks() async {
+ isLoadingDownloads = true
+ defer { isLoadingDownloads = false }
+ downloadLinks = await DownloadLinks.fetch()
+ }
+
+ // MARK: - MCP
+
+ private func saveMCPServer() async {
+ mcpError = nil
+ mcpSaving = true
+ defer { mcpSaving = false }
+ let spec = mcpSpec
+ if let err = await appState.addMCPServer(spec: spec, scope: .user) {
+ mcpError = err
+ return
+ }
+ mcpAdded = true
+ }
+}
+
+// MARK: - Slide model
+
+struct OnboardingSlide: Identifiable {
+ enum Visual {
+ case workspace
+ case approvals
+ case cliSetup
+ case acpSetup
+ case summarizationModel
+ case mobilePairing
+ case mcpSetup
+ }
+
+ let id: String
+ let title: LocalizedStringKey
+ let subtitle: LocalizedStringKey
+ let visual: Visual
+ let canSkip: Bool
+
+ static let all: [OnboardingSlide] = [
+ OnboardingSlide(
+ id: "workspace",
+ title: "Work with coding agents in one native app",
+ subtitle: "Open projects, switch threads, and keep agent conversations close to the files they change.",
+ visual: .workspace,
+ canSkip: false
+ ),
+ OnboardingSlide(
+ id: "approvals",
+ title: "Approve risky actions with context",
+ subtitle: "Review commands, diffs, and permission requests before an agent changes your workspace.",
+ visual: .approvals,
+ canSkip: false
+ ),
+ OnboardingSlide(
+ id: "cli",
+ title: "Connect at least one agent CLI",
+ subtitle: "RxCode runs Claude Code or Codex locally. Install one of them to start your first project.",
+ visual: .cliSetup,
+ canSkip: false
+ ),
+ OnboardingSlide(
+ id: "acp",
+ title: "Add an Agent Client Protocol client",
+ subtitle: "Install additional coding agents from the ACP registry. You can skip this and add clients later.",
+ visual: .acpSetup,
+ canSkip: true
+ ),
+ OnboardingSlide(
+ id: "summarization",
+ title: "Pick a summarization model",
+ subtitle: "Used for thread titles, branch briefings, and search. Defaults to your chat model.",
+ visual: .summarizationModel,
+ canSkip: true
+ ),
+ OnboardingSlide(
+ id: "mobile",
+ title: "Pair your phone",
+ subtitle: "Follow active threads, approve permissions, and pick up work from your iPhone, iPad, or Android device.",
+ visual: .mobilePairing,
+ canSkip: true
+ ),
+ OnboardingSlide(
+ id: "mcp",
+ title: "Set up your first MCP server",
+ subtitle: "Model Context Protocol servers expose tools and resources to every agent. Optional — you can skip this step.",
+ visual: .mcpSetup,
+ canSkip: true
+ )
+ ]
}
#Preview {
diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift
index d9618e65..e428c1e0 100644
--- a/RxCode/Views/Settings/MobileSettingsTab.swift
+++ b/RxCode/Views/Settings/MobileSettingsTab.swift
@@ -423,7 +423,7 @@ private struct TestNotificationAlert: Identifiable {
// MARK: - Relay Server Editor Sheet
-private struct RelayServerEditorSheet: View {
+struct RelayServerEditorSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var sync: MobileSyncService
@State var catalog: RelayPresetCatalog
diff --git a/RxCode/Views/SettingsMemoryViews.swift b/RxCode/Views/SettingsMemoryViews.swift
index 4a491b18..25a7c78e 100644
--- a/RxCode/Views/SettingsMemoryViews.swift
+++ b/RxCode/Views/SettingsMemoryViews.swift
@@ -66,6 +66,21 @@ struct MemorySettingsSection: View {
.fixedSize()
.disabled(!appState.memoryEnabled)
+ Picker("Retrieval", selection: $appState.memoryRetrievalMode) {
+ ForEach(MemoryRetrievalMode.allCases) { mode in
+ Text(mode.title).tag(mode)
+ }
+ }
+ .pickerStyle(.menu)
+ .frame(width: 220, alignment: .leading)
+ .disabled(!appState.memoryEnabled || !appState.memoryInjectEnabled)
+
+ Text("Precise returns fewer, stronger matches. Balanced is the default. Aggressive includes more related memories.")
+ .font(.system(size: ClaudeTheme.size(11)))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .disabled(!appState.memoryEnabled || !appState.memoryInjectEnabled)
+
Stepper(value: $appState.memoryMaxContextItems, in: 1...12) {
Text("Context memories: \(appState.memoryMaxContextItems)")
.font(.system(size: ClaudeTheme.size(12)))
diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift
index 321eff42..00b914c2 100644
--- a/RxCode/Views/Sidebar/BriefingView.swift
+++ b/RxCode/Views/Sidebar/BriefingView.swift
@@ -36,12 +36,19 @@ struct BriefingView: View {
Dictionary(uniqueKeysWithValues: appState.projects.map { ($0.id, $0) })
}
+ private var knownProjectIds: Set {
+ Set(appState.projects.map(\.id))
+ }
+
private var groups: [BriefingGroup] {
_ = appState.branchBriefingRevision
_ = appState.threadSummaryRevision
+ let knownIds = knownProjectIds
let briefings = appState.threadStore.allBranchBriefingItems()
+ .filter { knownIds.contains($0.projectId) }
let summaries = appState.threadStore.allThreadSummaryItems()
+ .filter { knownIds.contains($0.projectId) }
struct Bucket {
var projectId: UUID
@@ -103,9 +110,14 @@ struct BriefingView: View {
private var projectsWithData: [Project] {
_ = appState.branchBriefingRevision
_ = appState.threadSummaryRevision
+ let knownIds = knownProjectIds
let ids = Set(
- appState.threadStore.allBranchBriefingItems().map(\.projectId)
- + appState.threadStore.allThreadSummaryItems().map(\.projectId)
+ appState.threadStore.allBranchBriefingItems()
+ .filter { knownIds.contains($0.projectId) }
+ .map(\.projectId)
+ + appState.threadStore.allThreadSummaryItems()
+ .filter { knownIds.contains($0.projectId) }
+ .map(\.projectId)
)
return appState.projects.filter { ids.contains($0.id) }
}
@@ -137,8 +149,9 @@ struct BriefingView: View {
private var hasAnyData: Bool {
_ = appState.branchBriefingRevision
_ = appState.threadSummaryRevision
- return !appState.threadStore.allBranchBriefingItems().isEmpty
- || !appState.threadStore.allThreadSummaryItems().isEmpty
+ let knownIds = knownProjectIds
+ return appState.threadStore.allBranchBriefingItems().contains { knownIds.contains($0.projectId) }
+ || appState.threadStore.allThreadSummaryItems().contains { knownIds.contains($0.projectId) }
}
private var projectPathsKey: String {
diff --git a/RxCode/Views/Sidebar/FileInspectorView.swift b/RxCode/Views/Sidebar/FileInspectorView.swift
index 7f6b83e8..6a79db0e 100644
--- a/RxCode/Views/Sidebar/FileInspectorView.swift
+++ b/RxCode/Views/Sidebar/FileInspectorView.swift
@@ -36,8 +36,11 @@ struct FileInspectorView: View {
editingView
} else if let content {
if fileExtension == "md" || fileExtension == "markdown" {
- MarkdownPreviewView(content: content)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ MarkdownPreviewView(
+ content: content,
+ baseURL: URL(fileURLWithPath: filePath).deletingLastPathComponent()
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
codeContentView(content)
}
diff --git a/RxCode/Views/Sidebar/MarkdownPreviewView.swift b/RxCode/Views/Sidebar/MarkdownPreviewView.swift
index 135da839..c3989dda 100644
--- a/RxCode/Views/Sidebar/MarkdownPreviewView.swift
+++ b/RxCode/Views/Sidebar/MarkdownPreviewView.swift
@@ -4,10 +4,16 @@ import RxCodeCore
struct MarkdownPreviewView: View {
let content: String
+ let baseURL: URL?
+
+ init(content: String, baseURL: URL? = nil) {
+ self.content = content
+ self.baseURL = baseURL
+ }
var body: some View {
ScrollView {
- MarkdownContentView(text: content)
+ MarkdownContentView(text: content, baseURL: baseURL)
.padding(.horizontal, 28)
.padding(.vertical, 24)
}
diff --git a/RxCode/Views/UserManualView.swift b/RxCode/Views/UserManualView.swift
index 2bda3bdd..5572eaee 100644
--- a/RxCode/Views/UserManualView.swift
+++ b/RxCode/Views/UserManualView.swift
@@ -1,676 +1,237 @@
-import SwiftUI
+import RxCodeChatKit
import RxCodeCore
+import SwiftUI
struct UserManualView: View {
@Environment(\.dismiss) private var dismiss
- @State private var selectedTopic: ManualTopic = .overview
+ @State private var selectedMenu: UserManualMenu? = .overview
+
+ private let documents: [UserManualMenu: BundledMarkdownDocument]
+
+ init() {
+ self.documents = Dictionary(uniqueKeysWithValues: UserManualMenu.allCases.map { menu in
+ (
+ menu,
+ BundledMarkdownDocument.load(
+ baseName: menu.resourceBaseName,
+ fallbackTitle: menu.fallbackTitle
+ )
+ )
+ })
+ }
var body: some View {
NavigationSplitView {
- topicList
- .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 280)
+ sidebar
+ .navigationSplitViewColumnWidth(min: 190, ideal: 230, max: 280)
} detail: {
- ScrollView {
- topicDetail(selectedTopic)
- .padding(24)
- .frame(maxWidth: 640, alignment: .leading)
- }
- .overlay(alignment: .topTrailing) {
- Button { dismiss() } label: {
- Image(systemName: "xmark")
- .font(.system(size: ClaudeTheme.size(12), weight: .semibold))
- .foregroundStyle(.secondary)
- .frame(width: 24, height: 24)
- .background(Color(NSColor.controlBackgroundColor))
- .clipShape(Circle())
- }
- .buttonStyle(.plain)
- .focusable(false)
- .padding(12)
+ detailView
+ }
+ .overlay(alignment: .topTrailing) {
+ Button { dismiss() } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: ClaudeTheme.size(12), weight: .semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 24, height: 24)
+ .background(Color(NSColor.controlBackgroundColor))
+ .clipShape(Circle())
}
+ .buttonStyle(.plain)
+ .focusable(false)
+ .padding(12)
}
.frame(width: 900, height: 680)
}
- // MARK: - Topic List
-
- private var topicList: some View {
- List(ManualTopic.allCases, selection: $selectedTopic) { topic in
- Label(LocalizedStringKey(topic.title), systemImage: topic.icon)
- .tag(topic)
+ private var sidebar: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(UserManualMenu.allCases) { menu in
+ Button {
+ selectedMenu = menu
+ } label: {
+ UserManualSidebarRow(
+ title: documents[menu]?.titleText ?? menu.fallbackTitle,
+ icon: menu.icon,
+ isSelected: selectedMenu == menu
+ )
+ }
+ .buttonStyle(.plain)
+ .focusable(false)
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- .listStyle(.sidebar)
+ .background(ClaudeTheme.sidebarBackground)
.navigationTitle("User Guide")
}
- // MARK: - Topic Detail
-
- @ViewBuilder
- private func topicDetail(_ topic: ManualTopic) -> some View {
- VStack(alignment: .leading, spacing: 20) {
- HStack(spacing: 12) {
- Image(systemName: topic.icon)
- .font(.title)
- .foregroundStyle(Color.accentColor)
- Text(LocalizedStringKey(topic.title))
- .font(.title2)
- .fontWeight(.bold)
- }
-
- Divider()
+ private var detailView: some View {
+ let document = documents[selectedMenu ?? .overview] ?? .missing(title: UserManualMenu.overview.fallbackTitle)
- ForEach(Array(topic.sections.enumerated()), id: \.offset) { _, section in
- sectionView(section)
- }
+ return ScrollView {
+ MarkdownContentView(text: document.content)
+ .padding(.horizontal, 32)
+ .padding(.vertical, 28)
+ .frame(maxWidth: 760, alignment: .leading)
+ .frame(maxWidth: .infinity, alignment: .center)
}
+ .background(ClaudeTheme.background)
+ .navigationTitle(document.titleText)
}
+}
- private func sectionView(_ section: ManualSection) -> some View {
- VStack(alignment: .leading, spacing: 8) {
- if let title = section.title {
- Text(LocalizedStringKey(title))
- .font(.headline)
- }
+private struct UserManualSidebarRow: View {
+ let title: String
+ let icon: String
+ let isSelected: Bool
- Text(LocalizedStringKey(section.body))
- .font(.body)
- .foregroundStyle(.secondary)
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .font(.system(size: ClaudeTheme.size(14), weight: .medium))
+ .frame(width: 22)
- if !section.items.isEmpty {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(section.items, id: \.key) { item in
- ManualKeyValueRow(key: item.key, value: item.value, symbolName: item.symbolName, symbolColor: item.symbolColor)
- }
- }
- .padding(12)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(Color(NSColor.controlBackgroundColor))
- .clipShape(RoundedRectangle(cornerRadius: 8))
- }
+ Text(title)
+ .font(.system(size: ClaudeTheme.size(13), weight: .semibold))
+ .lineLimit(2)
+ .multilineTextAlignment(.leading)
- if let note = section.note {
- HStack(alignment: .top, spacing: 8) {
- Image(systemName: "info.circle.fill")
- .foregroundStyle(Color.accentColor)
- .font(.callout)
- Text(LocalizedStringKey(note))
- .font(.callout)
- }
- .padding(12)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(Color.accentColor.opacity(0.08))
- .clipShape(RoundedRectangle(cornerRadius: 12))
- .overlay(
- RoundedRectangle(cornerRadius: 12)
- .strokeBorder(Color.accentColor.opacity(0.25), lineWidth: 0.5)
- )
- }
+ Spacer(minLength: 0)
}
- }
-}
-
-// MARK: - Key-Value Row
-
-private struct ManualKeyValueRow: View {
- let key: String
- let value: String
- var symbolName: String? = nil
- var symbolColor: Color? = nil
-
- var body: some View {
- HStack(alignment: .center, spacing: 12) {
- if let symbolName {
- Image(systemName: symbolName)
- .font(.system(size: ClaudeTheme.size(14), weight: .medium))
- .foregroundStyle(symbolColor ?? .primary)
- .frame(width: 28, height: 20)
- } else {
- Text(key)
- .font(.system(size: ClaudeTheme.size(12), weight: .medium, design: .monospaced))
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(Color(NSColor.controlBackgroundColor))
- .clipShape(RoundedRectangle(cornerRadius: 4))
- .fixedSize()
+ .foregroundStyle(isSelected ? ClaudeTheme.textPrimary : ClaudeTheme.textSecondary)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 9)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background {
+ if isSelected {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(ClaudeTheme.surfaceSecondary)
}
- Text(LocalizedStringKey(value))
- .font(.system(size: ClaudeTheme.size(13)))
- .foregroundStyle(.secondary)
}
+ .contentShape(RoundedRectangle(cornerRadius: 8))
}
}
-// MARK: - Data Models
-
-struct ManualSection {
- let title: String?
- let body: String
- let items: [KeyValueItem]
- let note: String?
-
- init(title: String? = nil, body: String = "", items: [KeyValueItem] = [], note: String? = nil) {
- self.title = title
- self.body = body
- self.items = items
- self.note = note
- }
-}
-
-struct KeyValueItem {
- let key: String
- let value: String
- var symbolName: String? = nil
- var symbolColor: Color? = nil
-}
-
-// MARK: - Topics
-
-enum ManualTopic: String, CaseIterable, Identifiable {
+private enum UserManualMenu: String, CaseIterable, Identifiable {
case overview
case projects
case chat
- case shortcuts
- case slashCommands
- case attachments
- case customShortcuts
- case inspectorPanel
- case github
- case marketplace
+ case commands
case permissions
- case statusLine
case settings
+ case integrations
+ case worktrees
var id: String { rawValue }
- var title: String {
+ var resourceBaseName: String {
+ "user_manual_\(rawValue)"
+ }
+
+ var fallbackTitle: String {
switch self {
- case .overview: "About RxCode"
- case .projects: "Project Management"
- case .chat: "Chat Basics"
- case .shortcuts: "Keyboard Shortcuts"
- case .slashCommands: "Slash Commands"
- case .attachments: "File & Image Attachments"
- case .customShortcuts: "Shortcuts"
- case .inspectorPanel: "Inspector Panel"
- case .github: "GitHub Integration"
- case .marketplace: "Skill Marketplace"
- case .permissions: "Permission Requests"
- case .statusLine: "Status Line"
- case .settings: "Settings & Themes"
+ case .overview: "Overview"
+ case .projects: "Projects and Sidebar"
+ case .chat: "Chat and Attachments"
+ case .commands: "Commands and Shortcuts"
+ case .permissions: "Permissions and Inspector"
+ case .settings: "Integrations and Settings"
+ case .integrations: "MCP and ACP Clients"
+ case .worktrees: "Git Worktrees"
}
}
var icon: String {
switch self {
- case .overview: "sparkle"
- case .projects: "folder.fill"
- case .chat: "bubble.left.and.bubble.right"
- case .shortcuts: "keyboard"
- case .slashCommands: "terminal.fill"
- case .attachments: "paperclip"
- case .customShortcuts: "bolt.fill"
- case .inspectorPanel: "sidebar.trailing"
- case .github: "building.columns"
- case .marketplace: "brain.head.profile"
- case .permissions: "checkmark.shield"
- case .statusLine: "chart.bar.fill"
- case .settings: "gear"
+ case .overview: "sparkle"
+ case .projects: "sidebar.left"
+ case .chat: "bubble.left.and.bubble.right"
+ case .commands: "terminal.fill"
+ case .permissions: "checkmark.shield"
+ case .settings: "gearshape"
+ case .integrations: "puzzlepiece.extension"
+ case .worktrees: "arrow.triangle.branch"
}
}
+}
- var sections: [ManualSection] {
- switch self {
- case .overview:
- [
- ManualSection(
- title: "What is RxCode?",
- body: "A native macOS desktop client for the Claude Code CLI. Use all Claude Code features via a polished GUI without needing a terminal."
- ),
- ManualSection(
- title: "Main Layout",
- body: "The left sidebar has History and Files tabs. Project tabs appear at the top of the chat area — click to switch projects. The right inspector panel contains the Terminal and Memo tabs.",
- note: "Chat is disabled when no project is selected."
- ),
- ManualSection(
- title: "Top Toolbar",
- body: "The toolbar contains: New Chat, Manage Commands, Skip Permissions toggle, Model picker, Inspector panel toggle, Settings, and the GitHub integration button."
- ),
- ]
-
- case .projects:
- [
- ManualSection(
- title: "Adding a Project",
- body: "Click the + button at the top of the sidebar or drag a folder from Finder. Claude Code uses that folder as its working directory."
- ),
- ManualSection(
- title: "Project Tabs",
- body: "Project tabs appear at the top of the chat area. Click a tab to switch projects. You can switch even while Claude is streaming — the active stream continues in the background."
- ),
- ManualSection(
- title: "Dedicated Project Window",
- body: "Double-click a project tab to open it in its own independent window. Each window manages its own sessions independently, allowing you to work on multiple projects at once.",
- note: "In a dedicated project window, only the project name is shown — there are no project tabs."
- ),
- ManualSection(
- title: "Sidebar Tabs",
- body: "History tab: shows previous conversations\nFiles tab: browse the project file tree",
- items: [
- KeyValueItem(key: "⌘1", value: "Go to History tab"),
- KeyValueItem(key: "⌘2", value: "Go to Files tab"),
- KeyValueItem(key: "⌘F", value: "Files tab + activate search"),
- ]
- ),
- ManualSection(
- title: "History Management",
- body: "Right-click a session in the History tab for context menu options.",
- items: [
- KeyValueItem(key: "pin", value: "Pin / Unpin — pinned sessions stay at the top of the list", symbolName: "pin.fill", symbolColor: .orange),
- KeyValueItem(key: "rename", value: "Rename — change the session title", symbolName: "pencil", symbolColor: .secondary),
- KeyValueItem(key: "delete", value: "Delete — remove the session", symbolName: "trash", symbolColor: .red),
- ],
- note: "Use the trash icon in the History header to delete all sessions at once."
- ),
- ManualSection(
- title: "File Inspector",
- body: "Click any file in the Files tab to preview it with syntax highlighting. Press the pencil button to enter edit mode and modify the file directly.",
- items: [
- KeyValueItem(key: "⌘S", value: "Save file changes"),
- KeyValueItem(key: "Escape", value: "Exit edit mode"),
- ],
- note: "Files larger than 1 MB cannot be previewed."
- ),
- ManualSection(
- title: "Hidden Files",
- body: "Toggle the eye icon in the Files tab header to show or hide dotfiles (files starting with a period, such as .env or .gitignore)."
- ),
- ManualSection(
- title: "Git Status",
- body: "The Git status panel at the bottom of the sidebar shows changed-file counts and the current branch. Click the branch name to switch between local or remote branches."
- ),
- ]
+private struct BundledMarkdownDocument {
+ let titleText: String
+ let content: String
- case .chat:
- [
- ManualSection(
- title: "Sending Messages",
- body: "Type a message in the input field and press Return or the send button.",
- items: [
- KeyValueItem(key: "Return", value: "Send message"),
- KeyValueItem(key: "⌘Return", value: "Send message (alternative)"),
- KeyValueItem(key: "⇧Return", value: "Insert line break"),
- KeyValueItem(key: "Escape", value: "Stop streaming (cancel response generation)"),
- ]
- ),
- ManualSection(
- title: "Message Queue",
- body: "You can send messages even while Claude is responding. New messages are queued automatically and sent once the current response finishes. Queued messages appear as a badge above the input field — click the × on each item to remove it from the queue."
- ),
- ManualSection(
- title: "Recalling Previous Messages",
- body: "When the input field is empty, use ↑/↓ to navigate your message history.",
- items: [
- KeyValueItem(key: "↑", value: "Recall previous message"),
- KeyValueItem(key: "↓", value: "Next message / clear input"),
- ]
- ),
- ManualSection(
- title: "Changing the Model",
- body: "Use the model dropdown at the top of the chat area to switch Claude models."
- ),
- ManualSection(
- title: "Effort Level",
- body: "Use the effort dropdown next to the model picker to control how much reasoning Claude applies. The selection is per-session.",
- items: [
- KeyValueItem(key: "Auto Effort", value: "effort.desc.auto"),
- KeyValueItem(key: "Low", value: "effort.desc.low"),
- KeyValueItem(key: "Medium", value: "effort.desc.medium"),
- KeyValueItem(key: "High", value: "effort.desc.high"),
- KeyValueItem(key: "XHigh", value: "effort.desc.xhigh"),
- KeyValueItem(key: "Max", value: "effort.desc.max"),
- ]
- ),
- ManualSection(
- title: "Permission Mode",
- body: "Use the permission mode dropdown at the top of the chat to control how Claude requests approval before running actions.",
- items: [
- KeyValueItem(key: "Ask", value: "Default — prompts for approval before file edits and commands"),
- KeyValueItem(key: "Accept Edits", value: "Auto-accepts file edits in the working directory (commands still require approval)"),
- KeyValueItem(key: "Plan", value: "Read-only — analyzes and plans without making edits"),
- KeyValueItem(key: "Auto", value: "AI auto-approves safe operations, prompts only for risky ones (requires Max/Team/Enterprise/API plan + Sonnet/Opus 4.6+)"),
- KeyValueItem(key: "Bypass", value: "Skips all permission checks — use only in isolated environments"),
- ],
- note: "Only enable Skip Permissions on projects you fully trust."
- ),
- ]
+ static func load(baseName: String, fallbackTitle: String, bundle: Bundle = .main) -> Self {
+ let candidates = localizedResourceNames(for: baseName)
- case .shortcuts:
- [
- ManualSection(
- title: "Global Shortcuts",
- body: "",
- items: [
- KeyValueItem(key: "⌘N", value: "Start new chat"),
- KeyValueItem(key: "⌘W", value: "Close current window"),
- KeyValueItem(key: "⌘1", value: "Sidebar — History tab (expands sidebar if hidden)"),
- KeyValueItem(key: "⌘2", value: "Sidebar — Files tab (expands sidebar if hidden)"),
- KeyValueItem(key: "⌘3", value: "Toggle left sidebar"),
- KeyValueItem(key: "⌘4", value: "Toggle right inspector panel"),
- KeyValueItem(key: "⌘F", value: "Sidebar — Files tab + search"),
- KeyValueItem(key: "Double-click", value: "Project tab — open in dedicated window"),
- ]
- ),
- ManualSection(
- title: "Input Field Shortcuts",
- body: "",
- items: [
- KeyValueItem(key: "Return", value: "Send message"),
- KeyValueItem(key: "⌘Return", value: "Send message (alternative)"),
- KeyValueItem(key: "⇧Return", value: "Line break"),
- KeyValueItem(key: "Escape", value: "Close popup / stop streaming"),
- KeyValueItem(key: "↑ / ↓", value: "Select popup item or navigate message history"),
- KeyValueItem(key: "Tab", value: "Autocomplete slash command / @ file"),
- ]
- ),
- ManualSection(
- title: "Slash Command Popup",
- body: "",
- items: [
- KeyValueItem(key: "↑ / ↓", value: "Select item"),
- KeyValueItem(key: "Return", value: "Execute command"),
- KeyValueItem(key: "⌘Return", value: "View command details"),
- KeyValueItem(key: "Tab", value: "Autocomplete command"),
- KeyValueItem(key: "Escape", value: "Close popup"),
- ]
- ),
- ManualSection(
- title: "@ Mention Popup",
- body: "",
- items: [
- KeyValueItem(key: "↑ / ↓", value: "Select shortcut or file"),
- KeyValueItem(key: "Return / Tab", value: "Run shortcut or insert file path"),
- KeyValueItem(key: "Escape", value: "Close popup"),
- ]
- ),
- ]
+ for name in candidates {
+ if let content = readMarkdown(named: name, bundle: bundle) {
+ return Self(titleText: title(in: content) ?? fallbackTitle, content: content)
+ }
+ }
- case .slashCommands:
- [
- ManualSection(
- title: "What are Slash Commands?",
- body: "Type / in the input field to open a popup list of available commands. Slash commands let you quickly trigger Claude Code CLI operations without typing them manually."
- ),
- ManualSection(
- title: "How to Use",
- body: "Type / to open the popup, then continue typing to filter results. Use ↑/↓ to navigate.",
- items: [
- KeyValueItem(key: "Return", value: "Execute selected command"),
- KeyValueItem(key: "⌘Return", value: "View command details"),
- KeyValueItem(key: "Tab", value: "Autocomplete command"),
- KeyValueItem(key: "Escape", value: "Close popup"),
- ]
- ),
- ManualSection(
- title: "Interactive Commands",
- body: "Some commands (such as /config, /permissions, /model) open in a full interactive terminal popup sheet, where you can use the interactive CLI interface. The popup closes automatically when the command finishes."
- ),
- ManualSection(
- title: "Agent-Specific Commands",
- body: "Each command targets an agent: built-in commands are Claude Code only, while custom commands can target Claude Code, Codex, ACP, or all agents. The popup only lists commands available to the current session's agent.",
- note: "Set a command's agent in its editor under Settings → Commands."
- ),
- ManualSection(
- title: "Managing Commands",
- body: "Click the / button in the toolbar, or open Settings → Commands to add, edit, delete, or disable custom commands. Built-in commands can be edited and enabled or disabled in the app.",
- note: "JSON import/export backs up or shares custom commands only. Built-in commands in imported files are ignored."
- ),
- ]
+ return missing(title: fallbackTitle)
+ }
- case .attachments:
- [
- ManualSection(
- title: "Attaching Files",
- body: "Click the clip icon to the left of the input field, or drag and drop files onto the input field. When dragging, the input field border highlights in the accent color to show the drop zone."
- ),
- ManualSection(
- title: "Clipboard Detection",
- body: "Pasting (⌘V) is smart — RxCode detects what's in the clipboard and handles it automatically.",
- items: [
- KeyValueItem(key: "image", value: "Image data (PNG/TIFF) → attached as an image", symbolName: "photo", symbolColor: .blue),
- KeyValueItem(key: "file", value: "File path → attached as a file", symbolName: "doc", symbolColor: .secondary),
- KeyValueItem(key: "url", value: "URL → attached as a URL reference", symbolName: "link", symbolColor: .accentColor),
- KeyValueItem(key: "text", value: "Long text (>2 KB) → converted to a text attachment", symbolName: "text.alignleft", symbolColor: .secondary),
- ],
- note: "Screenshots can be pasted directly — they are automatically attached as images."
- ),
- ManualSection(
- title: "@ Mentions",
- body: "Type @ in the input field to open the mention popup. It lists your shortcuts on top and project files below, both filtered in real time as you type.",
- items: [
- KeyValueItem(key: "↑ / ↓", value: "Select shortcut or file"),
- KeyValueItem(key: "Return / Tab", value: "Run shortcut or insert file path"),
- KeyValueItem(key: "Escape", value: "Close popup"),
- ]
- ),
- ManualSection(
- title: "Auto-Preview Settings",
- body: "By default, pasting certain content automatically creates a preview chip. You can toggle each category independently in Settings → Message.",
- items: [
- KeyValueItem(key: "URL links", value: "Show a preview chip when a URL is detected", symbolName: "link", symbolColor: .accentColor),
- KeyValueItem(key: "File paths", value: "Show a preview chip when a file path is detected", symbolName: "doc", symbolColor: .secondary),
- KeyValueItem(key: "Images", value: "Show a preview chip when image data is detected", symbolName: "photo", symbolColor: .blue),
- KeyValueItem(key: "Long text", value: "Convert long text (200+ characters) into an attachment chip", symbolName: "text.alignleft", symbolColor: .secondary),
- ]
- ),
- ]
+ static func missing(title: String) -> Self {
+ Self(
+ titleText: title,
+ content: "# \(title)\n\nThe bundled user guide section could not be loaded."
+ )
+ }
- case .customShortcuts:
- [
- ManualSection(
- title: "What are Shortcuts?",
- body: "Saved snippets for frequently used messages or shell commands. Type @ in the input field to open the mention popup and pick a shortcut from the list."
- ),
- ManualSection(
- title: "Using a Shortcut",
- body: "Type @ to open the popup, continue typing to filter, then press Return or Tab. A message shortcut is inserted into the input field for you to review and send; a terminal-command shortcut runs immediately."
- ),
- ManualSection(
- title: "Terminal Command Mode",
- body: "Enable the \"Run as terminal command\" option to execute the shortcut as a shell command in the inspector terminal instead of inserting it as a chat message. The project directory is used as the working directory."
- ),
- ManualSection(
- title: "Managing Shortcuts",
- body: "Open Settings → Commands and switch to the Shortcuts segment to add, edit, or delete shortcuts.",
- note: "JSON import/export is supported for backing up or sharing your shortcut set."
- ),
- ]
+ private static func title(in content: String) -> String? {
+ content
+ .split(whereSeparator: \.isNewline)
+ .lazy
+ .compactMap { line -> String? in
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard trimmed.hasPrefix("# ") else { return nil }
+ let title = trimmed.dropFirst(2).trimmingCharacters(in: .whitespaces)
+ return title.isEmpty ? nil : title
+ }
+ .first
+ }
- case .inspectorPanel:
- [
- ManualSection(
- title: "Opening the Inspector Panel",
- body: "Click the sidebar.trailing (⊟) button at the top right of the toolbar to toggle the inspector panel. The panel docks to the right side of the window and contains two tabs: Terminal and Memo."
- ),
- ManualSection(
- title: "Terminal Tab",
- body: "An embedded zsh terminal that opens at the current project's directory. Use it to run shell commands, inspect files, or manage git — all without leaving the app."
- ),
- ManualSection(
- title: "Memo Tab",
- body: "A per-project rich text memo editor. Notes are auto-saved after a short pause and persist across sessions. Use the toolbar at the bottom for formatting, or the keyboard shortcuts below.",
- items: [
- KeyValueItem(key: "⌘B", value: "Bold"),
- KeyValueItem(key: "⌘I", value: "Italic"),
- KeyValueItem(key: "⌘U", value: "Underline"),
- KeyValueItem(key: "H1 / H2 / H3", value: "Heading levels (# / ## / ###)"),
- KeyValueItem(key: "- ", value: "Unordered list (auto-continues on Return)"),
- KeyValueItem(key: "⌘⇧L", value: "Toggle checkbox"),
- KeyValueItem(key: "⌘K", value: "Insert link"),
- ]
- ),
- ManualSection(
- title: "Reset Buttons",
- body: "Each inspector tab has a reset button (the circular arrow at the top right of the panel).",
- items: [
- KeyValueItem(key: "Reset Terminal", value: "Terminate the current shell and start a fresh zsh session"),
- KeyValueItem(key: "Clear Memo", value: "Erase the memo for the current project (cannot be undone)"),
- ]
- ),
- ManualSection(
- title: "Interactive Terminal Popup",
- body: "Some slash commands (such as /config, /permissions, /model) open in a separate interactive terminal sheet. The popup runs the command automatically and closes when it exits.",
- note: "The exit code is shown at the bottom — \"exit 0\" means the command completed successfully."
- ),
- ]
+ private static func localizedResourceNames(for baseName: String) -> [String] {
+ var names: [String] = []
- case .github:
- [
- ManualSection(
- title: "GitHub Integration",
- body: "Click the GitHub Mark button at the top of the sidebar to open the GitHub panel. After connecting your GitHub account, your repositories are listed and can be added to RxCode with one click."
- ),
- ManualSection(
- title: "Adding a Repository",
- body: "Search for a repository by name, then click Add. RxCode clones it automatically and opens it as a new project.",
- items: [
- KeyValueItem(key: "lock", value: "Private repository", symbolName: "lock", symbolColor: .secondary),
- KeyValueItem(key: "globe", value: "Public repository", symbolName: "globe", symbolColor: .secondary),
- KeyValueItem(key: "checkmark", value: "Already added to RxCode", symbolName: "checkmark.circle.fill", symbolColor: .green),
- ]
- ),
- ]
+ for identifier in Locale.preferredLanguages {
+ let normalized = identifier.replacingOccurrences(of: "-", with: "_")
+ let locale = Locale(identifier: identifier)
+ let languageCode = locale.language.languageCode?.identifier
+ let scriptCode = locale.language.script?.identifier
+ let regionCode = locale.region?.identifier
- case .marketplace:
- [
- ManualSection(
- title: "Skill Marketplace",
- body: "Open Settings and select Skill Marketplace to browse OpenAI Agent Skills and compatible skill catalogs. Skills can be filtered by category or searched by name, description, or author."
- ),
- ManualSection(
- title: "Installing Skills",
- body: "Click a skill to view its details, then press Install. RxCode stores installed skills in its own settings and enables them for supported coding agents.",
- items: [
- KeyValueItem(key: "clock", value: "Not installed", symbolName: "clock", symbolColor: .secondary),
- KeyValueItem(key: "arrow.down", value: "Installing…", symbolName: "arrow.down.circle", symbolColor: .accentColor),
- KeyValueItem(key: "checkmark", value: "Installed", symbolName: "checkmark.circle.fill", symbolColor: .green),
- ],
- note: "The catalog refreshes automatically every 5 minutes."
- ),
- ]
+ appendUnique("\(baseName)_\(normalized)", to: &names)
- case .statusLine:
- [
- ManualSection(
- title: "What is the Status Line?",
- body: "A fixed information bar at the bottom of the chat area. It shows the current project path, model, rate limit usage, context window usage, and total response time at a glance."
- ),
- ManualSection(
- title: "Displayed Items",
- body: "Items are shown left to right in the status line.",
- items: [
- KeyValueItem(key: "folder", value: "Project path — folder path of the currently selected project (home directory abbreviated as ~)", symbolName: "folder.fill", symbolColor: .orange),
- KeyValueItem(key: "cpu", value: "Model — name of the Claude model in use for this session", symbolName: "cpu", symbolColor: .green),
- KeyValueItem(key: "clock", value: "5h limit — API usage over the last 5 hours (bar + % + time until reset)", symbolName: "clock", symbolColor: .secondary),
- KeyValueItem(key: "calendar", value: "7d limit — API usage over the last 7 days (bar + % + time until reset)", symbolName: "calendar", symbolColor: .secondary),
- KeyValueItem(key: "memorychip", value: "context — context window usage for the current session (bar + %)", symbolName: "memorychip", symbolColor: .secondary),
- KeyValueItem(key: "stopwatch", value: "Total response time — cumulative time Claude spent generating responses in this session", symbolName: "stopwatch", symbolColor: .secondary),
- ]
- ),
- ManualSection(
- title: "Usage Colors",
- body: "The bar graph and percentage change color based on usage level.",
- items: [
- KeyValueItem(key: "green", value: "Below 70% — normal", symbolName: "circle.fill", symbolColor: .green),
- KeyValueItem(key: "yellow", value: "70–89% — caution", symbolName: "circle.fill", symbolColor: .orange),
- KeyValueItem(key: "red", value: "90% or above — critical", symbolName: "circle.fill", symbolColor: .red),
- ],
- note: "Usage data refreshes automatically after each response. If the initial fetch fails on launch, it retries once after 5 seconds."
- ),
- ]
+ if languageCode == "zh" {
+ if regionCode == "HK" || regionCode == "MO" || regionCode == "TW" || scriptCode == "Hant" {
+ appendUnique("\(baseName)_zh_HK", to: &names)
+ } else {
+ appendUnique("\(baseName)_zh_CN", to: &names)
+ }
+ } else if let languageCode {
+ appendUnique("\(baseName)_\(languageCode)", to: &names)
+ }
+ }
- case .permissions:
- [
- ManualSection(
- title: "What are Permission Requests?",
- body: "Before Claude edits files or runs commands, it pauses and asks for your approval. A modal appears showing the tool name and its arguments."
- ),
- ManualSection(
- title: "Approval Options",
- body: "Each permission request offers three choices.",
- items: [
- KeyValueItem(key: "Allow", value: "Approve this single action"),
- KeyValueItem(key: "Allow Session", value: "Approve all future requests of this type for the current session"),
- KeyValueItem(key: "Deny", value: "Reject the action"),
- ],
- note: "If no action is taken, the request is automatically denied after 5 minutes. Press Return to Allow, or Escape to Deny."
- ),
- ManualSection(
- title: "Permission Mode",
- body: "Use the permission mode dropdown at the top of the chat to switch how Claude handles permissions.",
- items: [
- KeyValueItem(key: "Ask", value: "Default — prompts for approval before file edits and commands"),
- KeyValueItem(key: "Accept Edits", value: "Auto-accepts file edits in the working directory (commands still require approval)"),
- KeyValueItem(key: "Plan", value: "Read-only — analyzes and plans without making edits"),
- KeyValueItem(key: "Auto", value: "AI auto-approves safe operations, prompts only for risky ones (requires Max/Team/Enterprise/API plan + Sonnet/Opus 4.6+)"),
- KeyValueItem(key: "Bypass", value: "Skips all permission checks — writes to .git/.vscode/.claude directories still require approval"),
- ],
- note: "Only enable Skip Permissions on projects you fully trust. Mode changes take effect from the next message."
- ),
- ]
+ appendUnique(baseName, to: &names)
+ return names
+ }
- case .settings:
- [
- ManualSection(
- title: "Opening Settings",
- body: "Choose RxCode → Settings from the menu bar or press ⌘, to open the Settings window. Settings are organized into tabs including General, Message, and Commands."
- ),
- ManualSection(
- title: "General Tab",
- body: "The General tab configures session defaults. Changes apply to newly created sessions — existing sessions keep their current values.",
- items: [
- KeyValueItem(key: "Theme", value: "Accent color palette (Terracotta, Ocean, Forest, Lavender, Midnight, Amber)"),
- KeyValueItem(key: "Interface Font Size", value: "Adjust the font size for the app UI (sidebar, toolbars, labels)"),
- KeyValueItem(key: "Messages Font Size", value: "Adjust the font size in the chat message area"),
- KeyValueItem(key: "Default Model", value: "Claude model used when starting a new session"),
- KeyValueItem(key: "Default Permission Mode", value: "Permission mode applied to new sessions"),
- KeyValueItem(key: "Default Effort Level", value: "Reasoning effort applied to new sessions"),
- KeyValueItem(key: "Notifications", value: "Show a system notification when a response completes while RxCode is in the background"),
- ],
- note: "Model, permission mode, and effort can also be overridden per session from the toolbar — the chosen value sticks to that session."
- ),
- ManualSection(
- title: "Message Tab",
- body: "The Message tab controls chat display and attachment behavior.",
- items: [
- KeyValueItem(key: "Focus Mode", value: "Enable a focused chat layout that hides the project tab bar and sidebar controls — useful when you want to concentrate on a single conversation"),
- KeyValueItem(key: "Auto-Preview URLs", value: "Automatically show a preview chip when a URL is pasted"),
- KeyValueItem(key: "Auto-Preview File Paths", value: "Automatically show a preview chip when a file path is pasted"),
- KeyValueItem(key: "Auto-Preview Images", value: "Automatically show a preview chip when image data is pasted"),
- KeyValueItem(key: "Auto-Preview Long Text", value: "Automatically convert long text (200+ characters) into an attachment chip when pasted"),
- ]
- ),
- ManualSection(
- title: "Themes",
- body: "RxCode ships with six accent color themes. Preview each one directly from the theme picker — the whole app recolors live as you choose.",
- items: [
- KeyValueItem(key: "Terracotta", value: "Claude default — warm orange-red"),
- KeyValueItem(key: "Ocean", value: "Cool blue"),
- KeyValueItem(key: "Forest", value: "Deep green"),
- KeyValueItem(key: "Lavender", value: "Soft purple"),
- KeyValueItem(key: "Midnight", value: "Dark indigo"),
- KeyValueItem(key: "Amber", value: "Warm yellow"),
- ]
- ),
- ManualSection(
- title: "Commands Tab",
- body: "The Commands tab has two segments. Slash Commands manages built-in and custom commands — edit, enable/disable, add, delete, or set an agent scope; JSON import/export covers custom commands only. Shortcuts manages your @-triggered shortcuts with JSON import/export support."
- ),
- ManualSection(
- title: "Checking for Updates",
- body: "Open RxCode → Check for Updates… to run Sparkle's update check manually. Updates are also checked automatically on launch."
- ),
- ]
+ private static func readMarkdown(named name: String, bundle: Bundle) -> String? {
+ guard let url = bundle.url(forResource: name, withExtension: "md"),
+ let content = try? String(contentsOf: url, encoding: .utf8) else {
+ return nil
}
+ return content
+ }
+
+ private static func appendUnique(_ name: String, to names: inout [String]) {
+ guard !names.contains(name) else { return }
+ names.append(name)
}
}
diff --git a/RxCodeTests/MemoryIntentTests.swift b/RxCodeTests/MemoryIntentTests.swift
index 46a1c658..daac2c6d 100644
--- a/RxCodeTests/MemoryIntentTests.swift
+++ b/RxCodeTests/MemoryIntentTests.swift
@@ -1,45 +1,73 @@
import XCTest
-import RxCodeCore
@testable import RxCode
@MainActor
final class MemoryIntentTests: XCTestCase {
- func testPreferenceMemoryInjectsIntoSystemPrompt() {
- XCTAssertTrue(
- AppState.fallbackShouldInjectMemoryIntoSystemPrompt(memory(content: "Use English for project text.", kind: "preference"))
+ func testMemoryExtractionPromptUsesAllUserMessagesAsSource() {
+ let prompt = OpenAISummarizationService.memoryExtractionPrompt(
+ existingMemories: [],
+ userMessages: [
+ "For future RxCode changes, always run the focused memory tests.",
+ "Now implement the feature."
+ ]
)
+
+ XCTAssertTrue(prompt.contains("### User message 1\nFor future RxCode changes, always run the focused memory tests."))
+ XCTAssertTrue(prompt.contains("### User message 2\nNow implement the feature."))
}
- func testAlwaysFactMemoryInjectsIntoSystemPrompt() {
- XCTAssertTrue(
- AppState.fallbackShouldInjectMemoryIntoSystemPrompt(memory(content: "Always run make lint before finishing.", kind: "fact"))
+ func testMemoryExtractionPromptDoesNotUseAssistantResponseAsSource() {
+ let prompt = OpenAISummarizationService.memoryExtractionPrompt(
+ existingMemories: [],
+ userMessages: ["Remember to keep the MCP memory tool unchanged."]
)
+
+ XCTAssertTrue(prompt.contains("The user messages below are the only source for new memory."))
+ XCTAssertFalse(prompt.contains("Final assistant response:"))
+ XCTAssertFalse(prompt.contains("Latest user message:"))
}
- func testRoutineFactMemoryDoesNotInjectIntoSystemPrompt() {
- XCTAssertFalse(
- AppState.fallbackShouldInjectMemoryIntoSystemPrompt(memory(content: "Added Makefile lint commands.", kind: "fact"))
+ func testMemoryExtractionPromptRejectsNoisyConversationArtifacts() {
+ let prompt = OpenAISummarizationService.memoryExtractionPrompt(
+ existingMemories: [],
+ userMessages: ["Hi"]
)
+
+ XCTAssertTrue(prompt.contains("Do not save greetings"))
+ XCTAssertTrue(prompt.contains("Updated it."))
+ XCTAssertTrue(prompt.contains("Implemented debug timing logs"))
+ XCTAssertTrue(prompt.contains("Build succeeded"))
+ XCTAssertTrue(prompt.contains("What changed:"))
}
- func testParsesMemoryInjectionDecision() {
- XCTAssertEqual(AppState.parseMemoryInjectionDecision("true"), true)
- XCTAssertEqual(AppState.parseMemoryInjectionDecision("false"), false)
- XCTAssertNil(AppState.parseMemoryInjectionDecision("maybe"))
+ func testParsesMemoryOperationsFromFencedJSON() {
+ let raw = """
+ ```json
+ [
+ {"action":"add","content":"Always use vector search for memory injection.","kind":"preference","scope":"project"},
+ {"action":"delete","id":"old-id"}
+ ]
+ ```
+ """
+
+ let operations = AppState.parseMemoryOperations(raw)
+
+ XCTAssertEqual(operations.count, 2)
+ XCTAssertEqual(operations[0].action, "add")
+ XCTAssertEqual(operations[0].content, "Always use vector search for memory injection.")
+ XCTAssertEqual(operations[0].kind, "preference")
+ XCTAssertEqual(operations[0].scope, "project")
+ XCTAssertEqual(operations[1].action, "delete")
+ XCTAssertEqual(operations[1].id, "old-id")
}
- private func memory(content: String, kind: String) -> MemoryItem {
- MemoryItem(
- id: UUID().uuidString,
- content: content,
- projectId: nil,
- sessionId: nil,
- sourceMessageId: nil,
- createdAt: Date(),
- updatedAt: Date(),
- lastUsedAt: nil,
- kind: kind,
- scope: "global"
+ func testMemoryExtractionPromptIncludesExistingMemoriesForUpdateOrDelete() {
+ let prompt = OpenAISummarizationService.memoryExtractionPrompt(
+ existingMemories: [(id: "memory-1", content: "Always run make lint.")],
+ userMessages: ["Do not run make lint by default anymore."]
)
+
+ XCTAssertTrue(prompt.contains("- id: memory-1\n content: Always run make lint."))
+ XCTAssertTrue(prompt.contains("If an existing memory should be refined, return an update operation with its id."))
}
}