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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions RxCode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; };
E6821AC12F7CEE7200829FC9 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = E6A001012F8A000100000001 /* SwiftTerm */; };
E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; };
DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */; };
E6D001032FA0000100000001 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001012FA0000100000001 /* RxCodeCore */; };
E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001022FA0000100000001 /* RxCodeChatKit */; };
/* End PBXBuildFile section */
Expand Down Expand Up @@ -91,6 +92,7 @@
E6C001022F9B000100000001 /* Sparkle in Frameworks */,
E6D001032FA0000100000001 /* RxCodeCore in Frameworks */,
E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */,
DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -223,6 +225,7 @@
E6C001012F9B000100000001 /* Sparkle */,
E6D001012FA0000100000001 /* RxCodeCore */,
E6D001022FA0000100000001 /* RxCodeChatKit */,
DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */,
);
productName = RxCode;
productReference = E67335382F7356F600FD26C7 /* RxCode.app */;
Expand Down Expand Up @@ -258,6 +261,7 @@
E6C001002F9B000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */,
E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */,
DF06CCCF2FB4CAB5005991E1 /* XCRemoteSwiftPackageReference "ViewInspector" */,
DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = E67335392F7356F600FD26C7 /* Products */;
Expand Down Expand Up @@ -659,6 +663,14 @@
minimumVersion = 2.0.0;
};
};
DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/paololeonardi/WaterfallGrid";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.0;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand All @@ -685,6 +697,11 @@
package = E6C001002F9B000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */ = {
isa = XCSwiftPackageProductDependency;
package = DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */;
productName = WaterfallGrid;
};
E6D001012FA0000100000001 /* RxCodeCore */ = {
isa = XCSwiftPackageProductDependency;
package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 37 additions & 1 deletion RxCode/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,30 @@ struct SessionStreamState {
enum SummarizationProvider: String, CaseIterable, Identifiable {
case selectedClient
case openAI
case appleFoundationModel

var id: String { rawValue }

var displayName: String {
switch self {
case .selectedClient: return "Thread Model"
case .openAI: return "OpenAI-Compatible Endpoint"
case .appleFoundationModel: return "Apple Foundation Model"
}
}

/// Returns the providers that should be offered to the user right now.
/// Apple Foundation Model is hidden when the device doesn't support it
/// (non-Apple-Silicon Mac, Apple Intelligence disabled, etc.).
@MainActor
static var availableCases: [SummarizationProvider] {
allCases.filter { provider in
switch provider {
case .appleFoundationModel:
return FoundationModelSummarizationService.isAvailable
case .selectedClient, .openAI:
return true
}
}
Comment on lines +112 to 124
}
}
Expand Down Expand Up @@ -430,7 +447,13 @@ final class AppState {

// MARK: - Summarization

var summarizationProvider: SummarizationProvider = .init(rawValue: UserDefaults.standard.string(forKey: "summarizationProvider") ?? "") ?? .selectedClient {
var summarizationProvider: SummarizationProvider = {
let stored = SummarizationProvider(rawValue: UserDefaults.standard.string(forKey: "summarizationProvider") ?? "") ?? .selectedClient
if stored == .appleFoundationModel, !FoundationModelSummarizationService.isAvailable {
return .selectedClient
}
return stored
}() {
didSet { UserDefaults.standard.set(summarizationProvider.rawValue, forKey: "summarizationProvider") }
}

Expand Down Expand Up @@ -891,6 +914,7 @@ final class AppState {
let acp: ACPService
let acpRegistryService = ACPRegistryService()
let openAISummarization = OpenAISummarizationService()
let foundationModelSummarization = FoundationModelSummarizationService()
let persistence: PersistenceService
let marketplace = MarketplaceService()
let mcp: MCPService
Expand Down Expand Up @@ -3776,6 +3800,8 @@ final class AppState {
apiKey: openAISummarizationAPIKey,
model: openAISummarizationModel
)
case .appleFoundationModel:
return await foundationModelSummarization.generateSessionTitle(firstUserMessage: firstUserMessage)
}
}

Expand All @@ -3796,6 +3822,8 @@ final class AppState {
apiKey: openAISummarizationAPIKey,
model: openAISummarizationModel
)
case .appleFoundationModel:
return await foundationModelSummarization.generateResponseNotificationSummary(responseText: trimmedResponse)
}
}

Expand Down Expand Up @@ -3894,6 +3922,12 @@ final class AppState {
apiKey: openAISummarizationAPIKey,
model: openAISummarizationModel
)
case .appleFoundationModel:
return await foundationModelSummarization.generateThreadSummary(
previousSummary: previousSummary,
userMessage: userMessage,
finalResponse: finalResponse
)
}
}

Expand All @@ -3919,6 +3953,8 @@ final class AppState {
apiKey: openAISummarizationAPIKey,
model: openAISummarizationModel
)
case .appleFoundationModel:
return await foundationModelSummarization.generateBranchBriefing(threadSummaries: threadSummaries)
}
}

Expand Down
150 changes: 150 additions & 0 deletions RxCode/Services/FoundationModelSummarizationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import Foundation
import FoundationModels
import os

/// On-device summarization powered by Apple's Foundation Models framework
/// (Apple Intelligence). Free, private, and runs locally — but only available
/// on Apple Silicon Macs with Apple Intelligence enabled.
actor FoundationModelSummarizationService {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.claudework",
category: "FoundationModelSummarizationService"
)

/// Whether the on-device model is currently usable. Re-evaluated on each
/// call so toggling Apple Intelligence in System Settings is picked up
/// without a relaunch.
nonisolated static var isAvailable: Bool {
if case .available = SystemLanguageModel.default.availability {
return true
}
return false
}

/// Human-readable reason the model is unavailable, or `nil` if available.
nonisolated static var unavailabilityReason: String? {
switch SystemLanguageModel.default.availability {
case .available:
return nil
case .unavailable(.deviceNotEligible):
return "This Mac doesn't support Apple Intelligence."
case .unavailable(.appleIntelligenceNotEnabled):
return "Apple Intelligence isn't enabled. Turn it on in System Settings."
case .unavailable(.modelNotReady):
return "The on-device model is still downloading. Try again later."
case .unavailable:
return "Apple Intelligence is unavailable on this device."
}
}

func generateSessionTitle(firstUserMessage: String) async -> String? {
let trimmed = String(firstUserMessage.prefix(500))
let prompt = """
Summarize the following user message as a 3-6 word chat title. Reply with ONLY the title, no quotes, no markdown, no punctuation at the end.

\(trimmed)
"""
let raw = await respond(
instructions: "You generate concise chat titles.",
prompt: prompt
)
return cleanTitle(raw)
}

func generateResponseNotificationSummary(responseText: String) async -> String? {
let trimmed = String(responseText.prefix(4000))
let prompt = """
Summarize the following assistant response for a macOS notification. Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. No markdown.

\(trimmed)
"""
let raw = await respond(
instructions: "You write concise notification summaries.",
prompt: prompt
)
return cleanSummary(raw, limit: 180)
}

func generateThreadSummary(
previousSummary: String?,
userMessage: String,
finalResponse: String
) async -> String? {
let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines)
let prompt = """
Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response.
Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups.
Reply with only the updated summary.

Previous summary:
\((previous?.isEmpty == false) ? previous! : "None")

Latest user request:
\(String(userMessage.prefix(2000)))

Final assistant response:
\(String(finalResponse.prefix(4000)))
"""
let raw = await respond(
instructions: "You maintain concise local project summaries.",
prompt: prompt
)
return cleanSummary(raw, limit: 1800)
}

func generateBranchBriefing(
threadSummaries: [(title: String, summary: String)]
) async -> String? {
guard !threadSummaries.isEmpty else { return nil }
let joined = threadSummaries.map { item -> String in
let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
let summary = String(item.summary.prefix(1500)).trimmingCharacters(in: .whitespacesAndNewlines)
return "### \(title.isEmpty ? "Untitled thread" : title)\n\(summary)"
}.joined(separator: "\n\n")

let prompt = """
Write a concise overall briefing for one git branch by synthesizing the per-thread summaries below into a single coherent overview.
Cover the main themes, completed work, important decisions, files or areas touched, and unresolved follow-ups across the whole branch.
Do not list threads individually — produce a unified summary. Use 4-8 short bullet points. Reply with only the briefing.

Thread summaries (newest first):

\(joined)
"""
let raw = await respond(
instructions: "You maintain concise local project summaries.",
prompt: prompt
)
return cleanSummary(raw, limit: 1800)
}

private func respond(instructions: String, prompt: String) async -> String? {
guard Self.isAvailable else { return nil }
do {
let session = LanguageModelSession(instructions: instructions)
let response = try await session.respond(to: prompt)
return response.content
} catch {
logger.warning("Foundation Models summarization failed: \(error.localizedDescription)")
return nil
}
}

private func cleanTitle(_ raw: String?) -> String? {
guard let raw else { return nil }
let cleaned = raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
guard !cleaned.isEmpty else { return nil }
return String(cleaned.prefix(80))
}

private func cleanSummary(_ raw: String?, limit: Int) -> String? {
guard let raw else { return nil }
let cleaned = raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
guard !cleaned.isEmpty else { return nil }
return String(cleaned.prefix(limit))
}
}
33 changes: 18 additions & 15 deletions RxCode/Views/Chat/ThreadTitlePopoverButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,29 @@ struct ThreadTitlePopoverButton: View {

var body: some View {
let hasSummary = !trimmedSummary.isEmpty
Button {
if hasSummary { showingPopover.toggle() }
} label: {
HStack(spacing: 4) {
Text(title)
.foregroundStyle(ClaudeTheme.textPrimary)
if hasSummary {
if hasSummary {
Button {
showingPopover.toggle()
} label: {
HStack(spacing: 4) {
Text(title)
.foregroundStyle(ClaudeTheme.textPrimary)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(ClaudeTheme.textTertiary)
}
.padding(.trailing)
.contentShape(Rectangle())
}
.padding(.trailing)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!hasSummary)
.help(hasSummary ? "Show thread summary" : "")
.popover(isPresented: $showingPopover, arrowEdge: .bottom) {
ThreadSummaryPopoverContent(item: summaryItem)
.buttonStyle(.plain)
.help("Show thread summary")
.popover(isPresented: $showingPopover, arrowEdge: .bottom) {
ThreadSummaryPopoverContent(item: summaryItem)
}
} else {
Text(title)
.foregroundStyle(ClaudeTheme.textPrimary)
.padding(.trailing)
}
}
}
Expand Down
Loading