From e8c8d01551f4f0914df153739171d05df7adeb86 Mon Sep 17 00:00:00 2001 From: M3NT1 Date: Sun, 19 Jul 2026 21:51:36 +0200 Subject: [PATCH 1/4] fix: surface screen-recording permission notice on the timeline The toast that warns the user about missing screen-recording access was gated on 'getSavedPreference() == true || appState.isRecording', but the recorder forces isRecording off the moment it detects the permission is missing, and getSavedPreference() is nil for users who have never explicitly toggled it. Net effect: the notice never surfaced for the exact case it was meant to alert on, and the user had no UI signal that recording was broken (the Resume button appeared to do nothing). Switch the guard to the 'didOnboard' UserDefaults flag so the notice appears whenever the user has completed onboarding and the permission is missing. Also re-evaluate the notice on every tab change to .timeline so a user who lands on the timeline after dismissing elsewhere still sees it (the session-dismiss flag prevents spam). --- Dayflow/Dayflow/Views/UI/MainView/Layout.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Dayflow/Dayflow/Views/UI/MainView/Layout.swift b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift index e61f5b071..1879254ef 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Layout.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift @@ -222,6 +222,11 @@ extension MainView { } updateCardsToReviewCount() loadWeeklyTrackedMinutes() + // Re-evaluate the screen-recording permission notice whenever the user + // lands on the timeline. The notice is a session-dismissible toast and + // won't re-show once `didDismissScreenRecordingPermissionNoticeThisSession` + // is true, so this is safe to call on every timeline entry. + showScreenRecordingNoticeIfNeeded() } else { showTimelineReview = false } @@ -315,7 +320,16 @@ extension MainView { showScreenRecordingPermissionNotice = false return } - guard AppState.shared.getSavedPreference() == true || appState.isRecording else { return } + + // Show the notice once onboarding is finished and the user has lost + // (or never granted) screen-recording access. The previous guard also + // required `getSavedPreference() == true || appState.isRecording`, but + // `isRecording` is forced off the moment the recorder detects the + // permission is missing, and `getSavedPreference()` is `nil` for users + // who have never explicitly toggled it — so the notice never surfaced + // for exactly the case we need to alert on. + let didOnboard = UserDefaults.standard.bool(forKey: "didOnboard") + guard didOnboard else { return } withAnimation(.spring(response: 0.28, dampingFraction: 0.9)) { showScreenRecordingPermissionNotice = true From aa7b1a78a29cfa193a9f73cfc165beb4aa160162 Mon Sep 17 00:00:00 2001 From: M3NT1 Date: Mon, 20 Jul 2026 00:06:54 +0200 Subject: [PATCH 2/4] feat: surface provider and model on each generated card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards in the timeline now carry a small 'Provider · Model' badge so the user can see at a glance which model produced them. Older cards saved before this change render without the badge (no placeholder), because the new fields are optional in the JSON metadata column — no DB migration required. Three places show the badge: - Canvas timeline card (CanvasActivityCard) — a small text line below the time range - Right-side detail card (MainView/ActivityCard) — a chip next to the category badge with a sparkles icon - Review-mode swipe card (TimelineReviewCard) — a chip next to the category pill A new 'TimelineActivity.providerBadge' computed property centralises the provider-id → human-label mapping (e.g. 'gemini' → 'Gemini', 'chatgpt_claude' → 'Claude' or 'ChatGPT' based on the model name), and looks up the model alias through ClaudeModel / CodexModel enums so 'sonnet' displays as 'Claude Sonnet' rather than the raw alias. Storage: TimelineCard, TimelineCardShell, TimelineActivity, and the TimelineMetadata JSON envelope gained optional 'providerId' and 'modelId' fields. LLMService stamps these onto every card it writes, using 'activeContext.id.providerLabel' for the provider and a new 'providerModelId(for:)' helper for the model. --- Dayflow/Dayflow/Core/AI/LLMService.swift | 68 ++++++++- .../StorageManager+TimelineCards.swift | 135 +++++++++++------- .../Core/Recording/StorageModels.swift | 37 ++++- .../Dayflow/Views/UI/CanvasActivityCard.swift | 39 +++-- .../Views/UI/CanvasTimelineDataView.swift | 2 + .../Views/UI/MainView/ActivityCard.swift | 29 ++++ .../UI/MainView/TimelineActivityLoader.swift | 4 +- .../UI/MainView/WeekTimelineGridPreview.swift | 4 +- .../Dayflow/Views/UI/TimelineDataModels.swift | 101 ++++++++++++- .../Dayflow/Views/UI/TimelineReviewCard.swift | 20 +++ .../Views/UI/TimelineReviewTypes.swift | 4 +- .../TimelineActivityLoaderTests.swift | 4 +- 12 files changed, 367 insertions(+), 80 deletions(-) diff --git a/Dayflow/Dayflow/Core/AI/LLMService.swift b/Dayflow/Dayflow/Core/AI/LLMService.swift index 00e274c38..5166cc287 100644 --- a/Dayflow/Dayflow/Core/AI/LLMService.swift +++ b/Dayflow/Dayflow/Core/AI/LLMService.swift @@ -158,6 +158,47 @@ final class LLMService: LLMServicing { providerID.providerLabel } + /// Returns the model id the provider should be stamped with on + /// generated cards. Mirrors how `providerLabel` is sourced from the + /// `LLMProviderID`, but goes one level deeper to read the actual model + /// the user has configured (Gemini primary preference, Ollama model id + /// in `UserDefaults`, ChatGPT/Claude CLI model id, etc.). Returns + /// `nil` for providers that don't expose a model concept (Dayflow Pro) + /// or where the user hasn't picked one yet, so the UI badge can fall + /// back to a provider-only label. + private func providerModelId(for providerID: LLMProviderID) -> String? { + switch providerID { + case .gemini: + // `GeminiModelPreference` always carries a primary; reading the + // raw value gives us the user-facing id without touching the + // provider's internal fallback chain. + return GeminiModelPreference.load().primary.rawValue + case .local: + let trimmed = + UserDefaults.standard.string(forKey: "llmLocalModelId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + case .openAICompatible: + let trimmed = + OpenAICompatiblePreferences.load()?.modelID + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + case .chatGPT: + // Read the user's pick from `CodexModelPreference` (the + // Settings → Providers tab writes this). We pass the raw + // alias through to the picker badge so the user sees the + // model id they selected, not a stale display name. + return CodexModelPreference.load().primary.rawValue + case .claude: + // Read the user's pick from `ClaudeModelPreference` (the + // Settings → Providers tab writes this). The CLI accepts + // these aliases natively — `sonnet` → latest Sonnet release. + return ClaudeModelPreference.load().primary.rawValue + case .dayflow: + return nil + } + } + private func noProviderError() -> NSError { NSError( domain: "LLMService", @@ -838,6 +879,13 @@ final class LLMService: LLMServicing { // Note: card generation log is not persisted per-batch yet // Replace old cards with new ones in the time range + // `activeContext.id` reflects the provider that actually + // produced these cards (primary or fallback), and + // `providerModelId(for:)` reads the user-configured model. We + // stamp both onto every card so the UI can render a + // "Provider · Model" badge without re-running the analysis. + let activeProviderId = activeContext.id.providerLabel + let activeModelId = providerModelId(for: activeContext.id) let (insertedCardIds, deletedVideoPaths) = StorageManager.shared .replaceTimelineCardsInRange( from: windowStartTime, @@ -853,7 +901,9 @@ final class LLMService: LLMServicing { detailedSummary: card.detailedSummary, distractions: card.distractions, appSites: card.appSites, - isBackupGenerated: isBackupGenerated ? true : nil + isBackupGenerated: isBackupGenerated ? true : nil, + providerId: activeProviderId, + modelId: activeModelId ) }, batchId: batchId @@ -937,11 +987,18 @@ final class LLMService: LLMServicing { let batchStartDate = Date(timeIntervalSince1970: TimeInterval(batchStartTs)) let batchEndDate = Date(timeIntervalSince1970: TimeInterval(batchEndTs)) + // Stamp the error card with whichever provider the user has + // configured. We don't have an `activeContext` here because the + // failure could have happened during initialization, so fall + // back to the primary provider's identity — that's the one the + // user is going to want to retry against anyway. let errorCard = createErrorCard( batchId: batchId, batchStartTime: batchStartDate, batchEndTime: batchEndDate, - error: error + error: error, + providerId: primaryProviderID.providerLabel, + modelId: providerModelId(for: primaryProviderID) ) // Replace any existing cards in this time range with the error card @@ -978,7 +1035,8 @@ final class LLMService: LLMServicing { } private func createErrorCard( - batchId: Int64, batchStartTime: Date, batchEndTime: Date, error: Error + batchId: Int64, batchStartTime: Date, batchEndTime: Date, error: Error, + providerId: String?, modelId: String? ) -> TimelineCardShell { let formatter = DateFormatter() formatter.dateFormat = "h:mm a" @@ -1006,7 +1064,9 @@ final class LLMService: LLMServicing { detailedSummary: "Error details: \(error.localizedDescription)\n\nThis recording batch (ID: \(batchId)) failed during AI processing. The original video files are preserved and can be reprocessed by retrying from Settings. Common causes include network issues, API rate limits, or temporary service outages.", distractions: nil, - appSites: nil + appSites: nil, + providerId: providerId, + modelId: modelId ) } diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift index 4a3816b2d..a24c3cac7 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift @@ -3,6 +3,52 @@ import GRDB import Sentry extension StorageManager { + /// Parsed view of a `timeline_cards.metadata` JSON column. Centralized + /// here so every reader (`fetchTimelineCards(forBatch:)`, + /// `fetchTimelineCards(forDay:)`, `fetchTimelineCardsByTimeRange`, + /// etc.) pulls the same field set — important because earlier rows may + /// carry either the new envelope or a legacy bare `[Distraction]` + /// array. + fileprivate struct ParsedTimelineMetadata { + let distractions: [Distraction]? + let appSites: AppSites? + let isBackupGenerated: Bool? + let providerId: String? + let modelId: String? + } + + fileprivate static func parseMetadata( + _ metadataString: String?, using decoder: JSONDecoder + ) -> ParsedTimelineMetadata { + guard + let metadataString, + let jsonData = metadataString.data(using: .utf8) + else { + return ParsedTimelineMetadata( + distractions: nil, appSites: nil, isBackupGenerated: nil, + providerId: nil, modelId: nil) + } + if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { + return ParsedTimelineMetadata( + distractions: meta.distractions, + appSites: meta.appSites, + isBackupGenerated: meta.isBackupGenerated, + providerId: meta.providerId, + modelId: meta.modelId) + } + // Legacy format: the column was a bare [Distraction] array before + // the metadata envelope existed. Keep the distractions, leave + // everything else nil so the UI can render the card correctly. + if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { + return ParsedTimelineMetadata( + distractions: legacy, appSites: nil, isBackupGenerated: nil, + providerId: nil, modelId: nil) + } + return ParsedTimelineMetadata( + distractions: nil, appSites: nil, isBackupGenerated: nil, + providerId: nil, modelId: nil) + } + func saveTimelineCardShell(batchId: Int64, card: TimelineCardShell) -> Int64? { let encoder = JSONEncoder() var lastId: Int64? = nil @@ -84,7 +130,9 @@ extension StorageManager { distractions: card.distractions, appSites: card.appSites, isBackupGenerated: card.isBackupGenerated, - idle: card.idleMetadata + idle: card.idleMetadata, + providerId: card.providerId, + modelId: card.modelId ) let metadataString: String? = (try? encoder.encode(meta)).flatMap { String(data: $0, encoding: .utf8) @@ -248,7 +296,13 @@ extension StorageManager { distractions: nil, appSites: AppSites(primary: "dayflow.so", secondary: nil), isBackupGenerated: nil, - idle: nil + idle: nil, + // Onboarding cards are static — they're written by the app to + // give the user a sample card on first launch, not produced by + // any LLM. Leaving provider/model nil keeps the UI badge hidden + // so the user doesn't see a misleading "Powered by …" label. + providerId: nil, + modelId: nil ) let metadataString: String? = (try? encoder.encode(meta)).flatMap { String(data: $0, encoding: .utf8) @@ -334,20 +388,7 @@ extension StorageManager { ORDER BY start ASC """, arguments: [batchId] ).map { row in - var distractions: [Distraction]? = nil - var appSites: AppSites? = nil - var isBackupGenerated: Bool? = nil - if let metadataString: String = row["metadata"], - let jsonData = metadataString.data(using: .utf8) - { - if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { - distractions = meta.distractions - appSites = meta.appSites - isBackupGenerated = meta.isBackupGenerated - } else if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { - distractions = legacy - } - } + let meta = Self.parseMetadata(row["metadata"], using: decoder) return TimelineCard( recordId: row["id"], batchId: batchId, @@ -359,11 +400,13 @@ extension StorageManager { summary: row["summary"], detailedSummary: row["detailed_summary"], day: row["day"], - distractions: distractions, + distractions: meta.distractions, videoSummaryURL: row["video_summary_url"], otherVideoSummaryURLs: nil, - appSites: appSites, - isBackupGenerated: isBackupGenerated + appSites: meta.appSites, + isBackupGenerated: meta.isBackupGenerated, + providerId: meta.providerId, + modelId: meta.modelId ) } }) ?? [] @@ -447,20 +490,7 @@ extension StorageManager { ) .map { row in // Decode metadata JSON (supports object or legacy array) - var distractions: [Distraction]? = nil - var appSites: AppSites? = nil - var isBackupGenerated: Bool? = nil - if let metadataString: String = row["metadata"], - let jsonData = metadataString.data(using: .utf8) - { - if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { - distractions = meta.distractions - appSites = meta.appSites - isBackupGenerated = meta.isBackupGenerated - } else if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { - distractions = legacy - } - } + let meta = Self.parseMetadata(row["metadata"], using: decoder) // Create TimelineCard instance using renamed columns return TimelineCard( @@ -474,11 +504,13 @@ extension StorageManager { summary: row["summary"], detailedSummary: row["detailed_summary"], day: row["day"], - distractions: distractions, + distractions: meta.distractions, videoSummaryURL: row["video_summary_url"], otherVideoSummaryURLs: nil, - appSites: appSites, - isBackupGenerated: isBackupGenerated + appSites: meta.appSites, + isBackupGenerated: meta.isBackupGenerated, + providerId: meta.providerId, + modelId: meta.modelId ) } } @@ -508,20 +540,7 @@ extension StorageManager { ) .map { row in // Decode metadata JSON (supports object or legacy array) - var distractions: [Distraction]? = nil - var appSites: AppSites? = nil - var isBackupGenerated: Bool? = nil - if let metadataString: String = row["metadata"], - let jsonData = metadataString.data(using: .utf8) - { - if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { - distractions = meta.distractions - appSites = meta.appSites - isBackupGenerated = meta.isBackupGenerated - } else if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { - distractions = legacy - } - } + let meta = Self.parseMetadata(row["metadata"], using: decoder) // Create TimelineCard instance using renamed columns return TimelineCard( @@ -535,11 +554,13 @@ extension StorageManager { summary: row["summary"], detailedSummary: row["detailed_summary"], day: row["day"], - distractions: distractions, + distractions: meta.distractions, videoSummaryURL: row["video_summary_url"], otherVideoSummaryURLs: nil, - appSites: appSites, - isBackupGenerated: isBackupGenerated + appSites: meta.appSites, + isBackupGenerated: meta.isBackupGenerated, + providerId: meta.providerId, + modelId: meta.modelId ) } } @@ -875,12 +896,16 @@ extension StorageManager { // Insert new cards for card in newCards { - // Encode metadata object with distractions and appSites + // Encode metadata object with distractions, appSites, and the + // provider/model info so the UI can render a "powered by" badge + // on each card without re-deriving it. let meta = TimelineMetadata( distractions: card.distractions, appSites: card.appSites, isBackupGenerated: card.isBackupGenerated, - idle: card.idleMetadata + idle: card.idleMetadata, + providerId: card.providerId, + modelId: card.modelId ) let metadataString: String? = (try? encoder.encode(meta)).flatMap { String(data: $0, encoding: .utf8) diff --git a/Dayflow/Dayflow/Core/Recording/StorageModels.swift b/Dayflow/Dayflow/Core/Recording/StorageModels.swift index 6a8b6deac..37b2735bf 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageModels.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageModels.swift @@ -115,6 +115,15 @@ struct TimelineCard: Codable, Sendable, Identifiable { let otherVideoSummaryURLs: [String]? // For merged cards, subsequent video URLs let appSites: AppSites? let isBackupGenerated: Bool? + /// Stable provider id of the model that produced this card + /// (e.g. "gemini", "ollama", "chatgpt", "claude", "dayflow", + /// "openai_compatible"). Older saved cards don't carry this — leave + /// `nil` so the UI can hide the badge instead of showing a stale value. + let providerId: String? + /// Model id chosen by the user (e.g. "gemini-3.5-flash", + /// "MiniMax-M3", "gpt-5.6-luna", "claude-sonnet-4.5"). Optional for + /// the same reason as `providerId`. + let modelId: String? init( id: UUID = UUID(), @@ -132,7 +141,9 @@ struct TimelineCard: Codable, Sendable, Identifiable { videoSummaryURL: String?, otherVideoSummaryURLs: [String]?, appSites: AppSites?, - isBackupGenerated: Bool? = nil + isBackupGenerated: Bool? = nil, + providerId: String? = nil, + modelId: String? = nil ) { self.id = id self.recordId = recordId @@ -150,6 +161,8 @@ struct TimelineCard: Codable, Sendable, Identifiable { self.otherVideoSummaryURLs = otherVideoSummaryURLs self.appSites = appSites self.isBackupGenerated = isBackupGenerated + self.providerId = providerId + self.modelId = modelId } } @@ -234,6 +247,14 @@ struct TimelineCardShell: Sendable { let appSites: AppSites? let isBackupGenerated: Bool? let idleMetadata: IdleCardMetadata? + /// Stable provider id of the model that produced this card + /// (e.g. "gemini", "ollama", "chatgpt", "claude", "dayflow"). + /// Optional because older shells (e.g. error / idle paths) don't know + /// who produced them; the UI simply omits the badge when nil. + let providerId: String? + /// Model id of the producing model (e.g. "gemini-3.5-flash"). + /// Optional for the same reason as `providerId`. + let modelId: String? // No videoSummaryURL here, as it's added later // No batchId here, as it's passed as a separate parameter to the save function @@ -248,7 +269,9 @@ struct TimelineCardShell: Sendable { distractions: [Distraction]?, appSites: AppSites?, isBackupGenerated: Bool? = nil, - idleMetadata: IdleCardMetadata? = nil + idleMetadata: IdleCardMetadata? = nil, + providerId: String? = nil, + modelId: String? = nil ) { self.startTimestamp = startTimestamp self.endTimestamp = endTimestamp @@ -261,6 +284,8 @@ struct TimelineCardShell: Sendable { self.appSites = appSites self.isBackupGenerated = isBackupGenerated self.idleMetadata = idleMetadata + self.providerId = providerId + self.modelId = modelId } } @@ -285,6 +310,14 @@ struct TimelineMetadata: Codable { let appSites: AppSites? let isBackupGenerated: Bool? let idle: IdleCardMetadata? + /// Stable provider id (e.g. "gemini", "ollama", "chatgpt", "claude", + /// "dayflow", "openai_compatible"). Optional because the column has + /// carried this JSON since before this field existed, so any row + /// written earlier than the migration will simply omit it. + let providerId: String? + /// Model id chosen by the user (e.g. "gemini-3.5-flash", + /// "gpt-5.6-luna", "claude-sonnet"). Optional for the same reason. + let modelId: String? } struct AnalysisBatchDebugEntry: Sendable { diff --git a/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift b/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift index 87db20c8e..a68828ed6 100644 --- a/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift +++ b/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift @@ -19,6 +19,11 @@ struct CanvasActivityCard: View { let isSelected: Bool let isSystemCategory: Bool let isBackupGenerated: Bool + /// Compact "Provider · Model" string shown in the card footer so the + /// user can see at a glance which model produced this card (e.g. + /// "Gemini · 3.5 Flash", "Claude · Sonnet"). Optional because older + /// saved cards don't carry the metadata; when nil the badge is hidden. + let providerBadge: String? let onTap: () -> Void // Raw values for pattern matching (may contain paths) let faviconPrimaryRaw: String? @@ -146,19 +151,29 @@ struct CanvasActivityCard: View { Spacer() - HStack(spacing: 6) { - if isBackupGenerated { - backupIndicator - } + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 6) { + if isBackupGenerated { + backupIndicator + } - Text(time) - .font( - Font.custom("Figtree", size: secondaryFontSize) - .weight(.medium) - ) - .foregroundColor(style.time) - .lineLimit(1) - .truncationMode(.tail) + Text(time) + .font( + Font.custom("Figtree", size: secondaryFontSize) + .weight(.medium) + ) + .foregroundColor(style.time) + .lineLimit(1) + .truncationMode(.tail) + } + if let badge = providerBadge, !badge.isEmpty { + Text(badge) + .font(.custom("Figtree", size: 9)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help("Produced by \(badge)") + } } } } diff --git a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift index 868f5d5b3..3e07565ca 100644 --- a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift +++ b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift @@ -109,6 +109,7 @@ struct CanvasTimelineDataView: View { // between triggers and keeps the body's inline closures tiny (fixes a // Swift type-checker timeout that appeared when each closure inlined its // own copy of this calculation). + private func nowCenteredTargetHourIndex() -> Int { let currentHour = Calendar.current.component(.hour, from: Date()) let hoursSince4AM = currentHour >= 4 ? currentHour - 4 : (24 - 4) + currentHour @@ -331,6 +332,7 @@ struct CanvasTimelineDataView: View { isSystemCategory: item.categoryName.trimmingCharacters(in: .whitespacesAndNewlines) .caseInsensitiveCompare("System") == .orderedSame, isBackupGenerated: item.activity.isBackupGenerated == true, + providerBadge: item.activity.providerBadge, onTap: { if selectedCardId == item.id { clearSelection() diff --git a/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift b/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift index 69a613140..75634558b 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift @@ -206,6 +206,35 @@ struct ActivityCard: View { ) } + // "Produced by …" badge — mirrors the badge shown on the + // timeline card. Surfaces the provider/model that + // generated this card so users can verify which AI + // actually wrote the summary. Hidden when the metadata + // is missing (older cards) or the card is a system + // "Processing failed" error card (provider info is + // surfaced through the retry flow instead). + if !isFailedCard(activity), let providerBadge = activity.providerBadge { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(red: 0.45, green: 0.45, blue: 0.45)) + Text(providerBadge) + .font(Font.custom("Figtree", size: 11)) + .foregroundColor(Color(red: 0.4, green: 0.4, blue: 0.4)) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(red: 0.96, green: 0.96, blue: 0.95).opacity(0.9)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .inset(by: 0.25) + .stroke(Color(red: 0.88, green: 0.88, blue: 0.88), lineWidth: 0.5) + ) + .help("Produced by \(providerBadge)") + } + if !isFailedCard(activity) { Button(action: { withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift index ae9215c8a..129f59a9d 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift @@ -175,7 +175,9 @@ enum TimelineActivityLoader { videoSummaryURL: card.videoSummaryURL, screenshot: nil, appSites: card.appSites, - isBackupGenerated: card.isBackupGenerated + isBackupGenerated: card.isBackupGenerated, + providerId: card.providerId, + modelId: card.modelId ) ) } diff --git a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift index 6154718aa..a5ab0583f 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift @@ -136,7 +136,9 @@ private struct WeekTimelineHoverPrototypeHarness: View { videoSummaryURL: nil, screenshot: nil, appSites: spec.favicon.map { AppSites(primary: $0, secondary: nil) }, - isBackupGenerated: false + isBackupGenerated: false, + providerId: nil, + modelId: nil ) let yPos = CGFloat(spec.startMinutes) * ppm + 1 diff --git a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift index 457c6c2ea..40091d8db 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift @@ -10,6 +10,87 @@ import SwiftUI /// Represents an activity in the timeline view struct TimelineActivity: Identifiable { + + /// Compact "Provider · Model" string the UI can render directly next + /// to a card (timeline list or detail panel). Returns `nil` when both + /// the provider id and model id are missing — i.e. the card was saved + /// before this metadata existed — so callers can simply hide the + /// badge instead of inventing a placeholder. + /// + /// Provider ids stored in `metadata` are stable enum raw values + /// (`gemini`, `ollama`, `chatgpt`, `claude`, `dayflow`, + /// `openai_compatible`) so we map them to human-friendly labels here + /// rather than in storage. Model ids are passed through verbatim — + /// those are the exact strings the user picked in Settings and want to + /// see confirmed. For Claude/Codex the user picks an *alias* (e.g. + /// `sonnet`, `opus`, `gpt-5.6-luna`); we map the alias to a friendly + /// display name when we recognise it, and fall back to the raw + /// string otherwise so a new alias we haven't catalogued yet still + /// surfaces something useful. + var providerBadge: String? { + let rawProvider = (providerId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let rawModel = (modelId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if rawProvider.isEmpty && rawModel.isEmpty { return nil } + + let providerLabel: String + switch rawProvider { + case "gemini": + providerLabel = "Gemini" + case "local", "ollama": + providerLabel = "Local" + case "chatgpt": + providerLabel = "ChatGPT" + case "claude": + providerLabel = "Claude" + case "openai_compatible": + providerLabel = "OpenAI" + case "dayflow": + providerLabel = "Dayflow Pro" + case "chatgpt_claude": + // Legacy shared id used before ChatGPT and Claude were split. + // Disambiguate from the model name so users still see the + // specific tool. + let lowered = rawModel.lowercased() + if lowered.contains("claude") { + providerLabel = "Claude" + } else if lowered.contains("gpt") || lowered.contains("codex") { + providerLabel = "ChatGPT" + } else { + providerLabel = "AI" + } + case "": + providerLabel = "AI" + default: + providerLabel = rawProvider + } + + if rawModel.isEmpty { return providerLabel } + let displayModel = Self.prettyModelName(providerId: rawProvider, alias: rawModel) + return "\(providerLabel) · \(displayModel)" + } + + /// Maps a known Chat CLI alias to a friendlier display name for the + /// card badge. Falls back to the raw alias when we don't recognise + /// it — that keeps the badge useful when a new alias lands before + /// Dayflow is updated. + private static func prettyModelName(providerId: String, alias: String) -> String { + let trimmed = alias.trimmingCharacters(in: .whitespacesAndNewlines) + switch providerId { + case "claude": + if let parsed = ClaudeModel(rawValue: trimmed) { + return parsed.displayName + } + return trimmed + case "chatgpt": + if let parsed = CodexModel(rawValue: trimmed) { + return parsed.displayName + } + return trimmed + default: + return trimmed + } + } + let id: String let recordId: Int64? let batchId: Int64? // Tracks source batch for retry functionality @@ -25,6 +106,14 @@ struct TimelineActivity: Identifiable { let screenshot: NSImage? let appSites: AppSites? let isBackupGenerated: Bool? + /// Which Dayflow provider produced this activity (e.g. "gemini", + /// "ollama", "chatgpt", "claude", "dayflow", "openai_compatible"). + /// Optional because older saved cards don't carry the metadata. + let providerId: String? + /// Which model the provider used (e.g. "gemini-3.5-flash", + /// "gpt-5.6-luna", "claude-sonnet-4.5"). Optional for the same + /// reason as `providerId`. + let modelId: String? static func stableId( recordId: Int64?, batchId: Int64?, startTime: Date, endTime: Date, title: String, @@ -64,7 +153,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: videoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } @@ -84,7 +175,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: videoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } @@ -104,7 +197,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: newVideoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } } diff --git a/Dayflow/Dayflow/Views/UI/TimelineReviewCard.swift b/Dayflow/Dayflow/Views/UI/TimelineReviewCard.swift index dd10aeac6..a0765bbc9 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineReviewCard.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineReviewCard.swift @@ -106,6 +106,26 @@ struct TimelineReviewCard: View { HStack(alignment: .center) { TimelineReviewCategoryPill(name: activity.category, color: categoryColor) + + // Subtle "produced by" chip next to the category pill so + // users in the review flow also know which provider/model + // wrote the summary. Hidden when the metadata is missing. + if let providerBadge = activity.providerBadge { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(hex: "707070")) + Text(providerBadge) + .font(.custom("Figtree", size: 11).weight(.medium)) + .foregroundColor(Color(hex: "707070")) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color(hex: "F4F0ED")) + .cornerRadius(10) + } + Spacer() TimelineReviewTimeRangePill(timeRange: timeRangeText) } diff --git a/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift b/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift index c05356e91..5cb9752b0 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift @@ -235,7 +235,9 @@ func makeTimelineActivities(from cards: [TimelineCard], for date: Date) videoSummaryURL: card.videoSummaryURL, screenshot: nil, appSites: card.appSites, - isBackupGenerated: card.isBackupGenerated + isBackupGenerated: card.isBackupGenerated, + providerId: card.providerId, + modelId: card.modelId )) } diff --git a/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift b/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift index 77d09f8a4..bc100bead 100644 --- a/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift +++ b/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift @@ -74,7 +74,9 @@ final class TimelineActivityLoaderTests: XCTestCase { videoSummaryURL: nil, screenshot: nil, appSites: nil, - isBackupGenerated: false + isBackupGenerated: false, + providerId: nil, + modelId: nil ) } From 88969dea98c3ec9bf0052fb270bb11e69d278223 Mon Sep 17 00:00:00 2001 From: M3NT1 Date: Mon, 20 Jul 2026 00:07:14 +0200 Subject: [PATCH 3/4] fix(ai): pass the `sonnet` alias to Claude CLI, not `claude-sonnet` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude CLI's --model flag accepts aliases (sonnet, opus, fable) or full names (claude-fable-5), but rejects the bare 'claude-sonnet' with 'It may not exist or you may not have access to it' → exit 1. Every batch since the screenshot-recording pipeline landed failed with this exact error, and the user-visible symptom was that no Claude-sourced cards ever appeared in the timeline. transcriptionModelConfiguration() and activityCardModelConfiguration() in ClaudeProvider were hard-coding 'claude-sonnet'; this commit replaces both with 'sonnet' so the CLI picks the latest Sonnet release on the user's account. Tests in CodexClaudeProviderTests were updated to assert the new 'sonnet' default (and the updated Codex GPT 5.6-luna default, which is handled in a separate commit). --- .../AI/ClaudeProvider+ActivityCards.swift | 7 +++++- .../AI/ClaudeProvider+Transcription.swift | 11 ++++++++- .../CodexClaudeProviderTests.swift | 24 ++++++++++++++----- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift index c5dc0abe7..c04146986 100644 --- a/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift +++ b/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift @@ -20,7 +20,12 @@ extension ClaudeProvider { static func activityCardModelConfiguration() -> ( model: String, reasoningEffort: String? ) { - (model: "claude-sonnet", reasoningEffort: "low") + // Mirrors `transcriptionModelConfiguration` — we always pass + // the user's selected alias to the CLI rather than a hard-coded + // model name. The Settings → Providers tab is what writes this + // preference; the catalog at `ChatCLIModelCatalog` powers the + // picker with the live display names. + (model: ClaudeModelPreference.load().primary.rawValue, reasoningEffort: "low") } func generateActivityCards( diff --git a/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift index dc70866e8..efd81cf13 100644 --- a/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift +++ b/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift @@ -6,7 +6,16 @@ extension ClaudeProvider { static func transcriptionModelConfiguration() -> ( model: String, reasoningEffort: String? ) { - (model: "claude-sonnet", reasoningEffort: "low") + // The Claude CLI's `--model` flag only accepts an alias + // (`sonnet`, `opus`, `fable`, `haiku`, …) or a full model name + // like `claude-fable-5`. Anything else returns "It may not exist + // or you may not have access to it" → exit 1. We persist the + // *alias* in `ClaudeModelPreference` because aliases track the + // latest release of each family automatically — Sonnet today + // becomes Sonnet 5.5 tomorrow without us bumping a stored + // version. The user picks the alias from the Settings → Providers + // tab; we send that exact string to the CLI. + (model: ClaudeModelPreference.load().primary.rawValue, reasoningEffort: "low") } func transcribeScreenshots( diff --git a/Dayflow/DayflowTests/CodexClaudeProviderTests.swift b/Dayflow/DayflowTests/CodexClaudeProviderTests.swift index 90031a7c5..4dfd9227c 100644 --- a/Dayflow/DayflowTests/CodexClaudeProviderTests.swift +++ b/Dayflow/DayflowTests/CodexClaudeProviderTests.swift @@ -41,17 +41,29 @@ final class CodexClaudeProviderTests: XCTestCase { ) } - func testClaudeActivityCardsUseSonnetSlugAtLowEffort() { + func testClaudeActivityCardsUseSonnetAliasAtLowEffort() { let configuration = ClaudeProvider.activityCardModelConfiguration() - XCTAssertEqual(configuration.model, "claude-sonnet") + // Default for the Claude picker is the `sonnet` alias (which the + // Claude CLI resolves to the current Sonnet release on the + // user's account). The previous hard-coded value was + // `claude-sonnet`, which the CLI rejects with "It may not + // exist or you may not have access to it" — see the comment + // on `ClaudeProvider.activityCardModelConfiguration`. + XCTAssertEqual(configuration.model, "sonnet") XCTAssertEqual(configuration.reasoningEffort, "low") } - func testChatGPTActivityCardsUseGPT56SolAtLowEffort() { + func testChatGPTActivityCardsUseUserPickedModelAtLowEffort() { let configuration = CodexProvider.activityCardModelConfiguration() - XCTAssertEqual(configuration.model, "gpt-5.6-sol") + // The picker drives both `transcriptionModelConfiguration` and + // `activityCardModelConfiguration` — they read the same + // `CodexModelPreference`. The default is `gpt-5.6-luna` (the + // transcription-tuned variant). The previous hard-coded value + // was `gpt-5.6-sol`; that alias is still selectable from the + // picker, just no longer the default. + XCTAssertEqual(configuration.model, "gpt-5.6-luna") XCTAssertEqual(configuration.reasoningEffort, "low") } @@ -109,10 +121,10 @@ final class CodexClaudeProviderTests: XCTestCase { ) } - func testClaudeTranscriptionUsesSonnetSlugAtLowEffort() { + func testClaudeTranscriptionUsesSonnetAliasAtLowEffort() { let configuration = ClaudeProvider.transcriptionModelConfiguration() - XCTAssertEqual(configuration.model, "claude-sonnet") + XCTAssertEqual(configuration.model, "sonnet") XCTAssertEqual(configuration.reasoningEffort, "low") } From 10fd4ca073ce18fcebd4b16c6d67d0f0d96090a2 Mon Sep 17 00:00:00 2001 From: M3NT1 Date: Mon, 20 Jul 2026 00:08:09 +0200 Subject: [PATCH 4/4] feat(ai): add Claude and Codex model picker with CLI discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings → Providers tab now offers a model picker for the Chat CLI providers (Claude and Codex), mirroring the existing Gemini picker. The picker is driven by a live catalog of models the user can actually pick — not a hard-coded list — so it stays in sync with whatever models the user's account has access to. Discovery: a new ChatCLIModelCatalog runs one tiny 'claude -p --model OK' probe per known alias in parallel and pulls the resolved full model name (e.g. 'claude-sonnet-5') out of the JSONL response's 'modelUsage' key. The catalog caches its result in UserDefaults for an hour so opening Settings doesn't trigger a probe every time. A 'Refresh model list' button forces a re-probe for users who just upgraded their subscription tier. UX details worth calling out: - The picker shows the resolved display name ('Claude Sonnet 5'), not the raw alias the CLI accepts. The versionSuffix helper drops build dates and preview tags so 'claude-haiku-4-5-20251001' surfaces as 'Claude Haiku 4.5'. - Provider IDs are mapped to friendly labels in one place (ChatCLIModelCatalog.prettyName). The picker and the per-card badge both pull from this mapping, so they can never disagree. - Probe uses a vanilla Process invocation, not ChatCLIProcessRunner, to sidestep the runner's safe-mode and MCP-config quirks that were leaving the catalog empty. Wiring: - ClaudeModelPreference and CodexModelPreference persist the primary model id to UserDefaults, mirroring GeminiModelPreference. - ClaudeProvider and CodexProvider now read the preference struct rather than hard-coding the default, so the model that runs in the CLI matches what the user picked in the picker. - LLMService.providerModelId(for: .claude/.chatGPT) now reads the same preference struct so the per-card badge stays in sync with what the provider actually sent to the CLI. --- .../Dayflow/Core/AI/ChatCLIModelCatalog.swift | 474 ++++++++++++++++++ .../Core/AI/ClaudeModelPreference.swift | 88 ++++ .../Core/AI/CodexModelPreference.swift | 70 +++ .../Core/AI/CodexProvider+ActivityCards.swift | 7 +- .../Core/AI/CodexProvider+Transcription.swift | 10 +- .../Settings/ProvidersSettingsViewModel.swift | 108 ++++ .../Settings/SettingsProvidersTabView.swift | 169 +++++++ 7 files changed, 922 insertions(+), 4 deletions(-) create mode 100644 Dayflow/Dayflow/Core/AI/ChatCLIModelCatalog.swift create mode 100644 Dayflow/Dayflow/Core/AI/ClaudeModelPreference.swift create mode 100644 Dayflow/Dayflow/Core/AI/CodexModelPreference.swift diff --git a/Dayflow/Dayflow/Core/AI/ChatCLIModelCatalog.swift b/Dayflow/Dayflow/Core/AI/ChatCLIModelCatalog.swift new file mode 100644 index 000000000..83a4872b5 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/ChatCLIModelCatalog.swift @@ -0,0 +1,474 @@ +// +// ChatCLIModelCatalog.swift +// Dayflow +// +// Discovers the list of models a user can pick for the Chat CLI +// providers (Claude and Codex). Mirrors the role +// `GeminiModelPreference` plays for Gemini, but with one important +// difference: we don't hard-code the model list. The CLI is the +// source of truth because: +// 1. Each user has a different account and therefore access to +// different models — we can't ship a list that's right for +// everyone. +// 2. Model names move (Sonnet 4 → Sonnet 4.5 → Sonnet 5 …). The +// aliases (`sonnet`, `opus`, `fable`) track the latest release +// automatically; the catalog just records what the user can +// actually use *right now*. +// +// The probe is a tiny `--print` call with a one-word prompt. It +// costs one trivial API call per alias. We run all aliases in +// parallel and cache the result for one hour so the Settings UI +// doesn't trigger a network roundtrip on every refresh. +// +// When the CLI isn't installed or the probe fails, we fall back to +// the static enum's display name. The picker always shows *something* +// useful — even offline. + +import Foundation + +/// A model that the user can pick for a Chat CLI provider. +struct DiscoveredChatCLIModel: Identifiable, Hashable, Sendable, Codable { + let id: String // Stable identifier — the CLI alias (e.g. "sonnet") + let displayName: String // Resolved user-facing label (e.g. "Claude Sonnet 5") + let fullName: String // What the CLI reported back (e.g. "claude-sonnet-5") +} + +enum ChatCLIModelCatalogError: Error { + case cliUnavailable(String) + case probeFailed(alias: String, underlying: String) +} + +/// Result of a discovery pass. Bundles per-provider results so the +/// Settings UI can show whatever's available without re-querying. +struct ChatCLIModelCatalogResult: Sendable { + let claude: [DiscoveredChatCLIModel] + let codex: [DiscoveredChatCLIModel] + let discoveredAt: Date + + static let empty = ChatCLIModelCatalogResult( + claude: [], codex: [], discoveredAt: .distantPast) +} + +enum ChatCLIModelCatalog { + + // MARK: - Alias sources of truth + // + // These are the *aliases* the CLI accepts as `--model` values. + // We keep them aligned with the `ClaudeModel` / `CodexModel` enums + // so the picker can show the resolved display name next to the + // enum-driven hint. If a new alias lands, add it to the enum AND + // to this list — the catalog discovery is what powers the + // Settings UI's model list. + + static let knownClaudeAliases: [String] = [ + ClaudeModel.sonnet.rawValue, + ClaudeModel.opus.rawValue, + ClaudeModel.fable.rawValue, + ClaudeModel.haiku.rawValue, + ] + + static let knownCodexAliases: [String] = [ + CodexModel.gpt56Luna.rawValue, + CodexModel.gpt54.rawValue, + CodexModel.gpt54Mini.rawValue, + ] + + // MARK: - Caching + + private static let cacheKey = "chatCLIModelCatalog.v1" + private static let cacheLifetime: TimeInterval = 60 * 60 // 1 hour + + /// Returns the cached catalog if it's fresh, otherwise `nil` and + /// the caller should invoke `discover(refresh: true)`. + static func cached() -> ChatCLIModelCatalogResult? { + guard + let data = UserDefaults.standard.data(forKey: cacheKey), + let result = try? JSONDecoder().decode(CachedEnvelope.self, from: data) + else { + return nil + } + let age = Date().timeIntervalSince(result.discoveredAt) + guard age < cacheLifetime else { return nil } + return result.toResult() + } + + /// Stores the result on disk. Failure is non-fatal — the next + /// discover call will just re-probe. + static func store(_ result: ChatCLIModelCatalogResult) { + let envelope = CachedEnvelope( + discoveredAt: result.discoveredAt, + claude: result.claude, + codex: result.codex + ) + if let data = try? JSONEncoder().encode(envelope) { + UserDefaults.standard.set(data, forKey: cacheKey) + } + } + + /// Clears the cache. Wired to the Settings UI's "Refresh" button + /// so users can force a re-probe (e.g. after upgrading their + /// subscription tier). + static func invalidate() { + UserDefaults.standard.removeObject(forKey: cacheKey) + } + + // MARK: - Discovery + + /// Probes the installed CLIs in parallel and returns whatever + /// models are accessible. Always returns a value — if the CLI + /// is missing or every probe fails, the result is empty (the + /// Settings UI will then fall back to the static enum list). + static func discover(refresh: Bool = false) async -> ChatCLIModelCatalogResult { + if !refresh, let cached = cached() { + return cached + } + + async let claudeResult = probeClaude() + async let codexResult = probeCodex() + + let result = ChatCLIModelCatalogResult( + claude: await claudeResult, + codex: await codexResult, + discoveredAt: Date() + ) + store(result) + return result + } + + // MARK: - Probes + + /// Probes every Claude alias. Skips aliases that fail; preserves + /// the rest. The order in the result matches `knownClaudeAliases` + /// so the picker shows them in a stable order. + private static func probeClaude() async -> [DiscoveredChatCLIModel] { + let probeResults = await withTaskGroup( + of: (String, DiscoveredChatCLIModel?).self + ) { group in + for alias in knownClaudeAliases { + group.addTask { (alias, await probeModel(cli: .claude, alias: alias)) } + } + var out: [(String, DiscoveredChatCLIModel?)] = [] + for await pair in group { + out.append(pair) + } + return out + } + + return probeResults + .sorted { lhs, rhs in + // Preserve the order in `knownClaudeAliases` so the picker + // shows Sonnet → Opus → Fable → Haiku consistently. + let li = knownClaudeAliases.firstIndex(of: lhs.0) ?? Int.max + let ri = knownClaudeAliases.firstIndex(of: rhs.0) ?? Int.max + return li < ri + } + .compactMap { $0.1 } + } + + private static func probeCodex() async -> [DiscoveredChatCLIModel] { + let probeResults = await withTaskGroup( + of: (String, DiscoveredChatCLIModel?).self + ) { group in + for alias in knownCodexAliases { + group.addTask { (alias, await probeModel(cli: .codex, alias: alias)) } + } + var out: [(String, DiscoveredChatCLIModel?)] = [] + for await pair in group { + out.append(pair) + } + return out + } + + return probeResults + .sorted { lhs, rhs in + let li = knownCodexAliases.firstIndex(of: lhs.0) ?? Int.max + let ri = knownCodexAliases.firstIndex(of: rhs.0) ?? Int.max + return li < ri + } + .compactMap { $0.1 } + } + + /// Probes a single model. Returns `nil` on any failure (CLI + /// missing, alias not recognized, transient error). The cache + /// layer above swallows the nil and the Settings UI just omits + /// the row. + /// + /// We deliberately bypass `ChatCLIProcessRunner` here. The runner + /// is built for real screen-recording batches (safe mode, MCP + /// config, session management, PTY-vs-pipe handling) and those + /// affordances cause the probe to silently produce no stdout on + /// some setups — which leaves the catalog empty. The probe only + /// needs three things from the CLI: does the alias resolve, what + /// model name did the CLI use, and does the user have access. A + /// plain `claude -p --output-format json --model "OK"` on + /// a vanilla `Process` pipe answers all three without any of the + /// runner's complications. + private static func probeModel( + cli: ChatCLITool, + alias: String + ) async -> DiscoveredChatCLIModel? { + let probeCommand: String + let arguments: [String] + switch cli { + case .claude: + // Plain `claude -p` with `--output-format json` returns a + // single JSON object on stdout (not the JSONL stream the + // `--verbose` flag produces). The parser handles both shapes, + // but the plain form is simpler and matches what `claude + // --help` documents as the standard non-interactive path. + // The trailing `--` is important: the prompt is the next + // argument and we don't want it to be interpreted as a flag. + probeCommand = "/usr/bin/env" + arguments = [ + "claude", "-p", "--output-format", "json", + "--model", alias, "--", + "Respond with only the word OK.", + ] + case .codex: + // Codex CLI exposes the same `--json` flag on `exec` and + // takes the model via `-m`. We invoke through `/usr/bin/env` + // so the user's PATH (with their Codex install) is honoured + // without us having to resolve the executable ourselves. + probeCommand = "/usr/bin/env" + arguments = [ + "codex", "exec", "--skip-git-repo-check", "--json", + "-m", alias, "--", + "Respond with only the word OK.", + ] + } + + do { + let result = try await Task.detached(priority: .utility) { + try Self.runProbeCommand(executable: probeCommand, arguments: arguments) + }.value + + guard result.exitCode == 0 else { + return nil + } + + let resolved = parseResolvedModelName( + from: result.stdout, fallback: result.stdout) + let displayName = Self.prettyName(forResolved: resolved, alias: alias, cli: cli) + return DiscoveredChatCLIModel( + id: alias, displayName: displayName, fullName: resolved) + } catch { + return nil + } + } + + /// Minimal `Process`-based probe runner. We don't need login-shell + /// indirection or MCP config or safe mode — we just need a one-shot + /// `claude -p "OK"` and the JSON it produces. Anything more + /// elaborate drags in the runtime quirks that make a probe + /// unreliable (see the long comment on `probeModel`). + private static func runProbeCommand( + executable: String, + arguments: [String], + timeoutSeconds: TimeInterval = 60 + ) throws -> (exitCode: Int32, stdout: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + // Inherit the user's environment so the CLI finds its own + // config (e.g. ~/.claude, Codex auth). The probe doesn't need + // anything special beyond that. + process.environment = ProcessInfo.processInfo.environment + + let stdoutPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = Pipe() // discard — the probe only cares about exit + JSON stdout + + try process.run() + + // Cap the probe at 60s. In practice a "Respond with only the word + // OK" call takes 1-5s; if it doesn't finish in a minute something + // is fundamentally wrong (auth expired, network hung, account + // throttled) and we should give up rather than block the UI. + let deadline = Date().addingTimeInterval(timeoutSeconds) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.1) + } + if process.isRunning { + process.terminate() + return (exitCode: -1, stdout: "") + } + + let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let stdout = String(data: data, encoding: .utf8) ?? "" + return (exitCode: process.terminationStatus, stdout: stdout) + } + + /// Pulls the first `modelUsage.*` key out of the JSON the CLI + /// emitted. Handles three shapes the Claude CLI can produce: + /// + /// 1. **Plain JSON** (no `--verbose`): a single object on stdout, + /// e.g. `{"modelUsage":{"claude-opus-4-8":{...}}}`. + /// 2. **JSONL** (with `--verbose`, which the `ChatCLIProcessRunner` + /// always adds when a `profile` is set): newline-delimited + /// objects, the last of which is `{"type":"result", ..., + /// "modelUsage":{"claude-opus-4-8":{...}}}`. + /// 3. **Garbage / older CLI** that we can't parse — we return `""` + /// and let the static enum display name be the fallback. + /// + /// We split JSONL on newlines and look at every line, because the + /// exact ordering of stream events has changed between Claude CLI + /// versions and a naive "last line" assumption bit us with 2.1.197. + private static func parseResolvedModelName( + from rawStdout: String, fallback stdout: String + ) -> String { + // First try: plain single-object JSON. Cheap, and works when the + // CLI is invoked without `--verbose` (e.g. directly from a + // terminal with `claude -p --output-format json ...`). + if let resolved = parseSingleJSONObjectForModelUsage(rawStdout) { + return resolved + } + // Second try: JSONL. Split on newlines, parse each line as its + // own JSON object, take the first `modelUsage` we find. We + // scan the whole stream because in practice `modelUsage` only + // appears on the final result event, but we don't want to + // depend on that — if a future CLI version emits usage in an + // earlier event, we'll still find it. + for line in rawStdout.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + if let resolved = parseSingleJSONObjectForModelUsage(trimmed) { + return resolved + } + } + // Last-ditch: legacy text scan. Covers older CLI versions whose + // output we don't recognise as JSON at all. + for line in stdout.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("modelUsage") || trimmed.hasPrefix("\"modelUsage") { + if let start = trimmed.firstIndex(of: "\""), + let end = trimmed.lastIndex(of: "\""), + start != end + { + let after = trimmed.index(after: start) + if after < end { + return String(trimmed[after.. String? { + guard let data = text.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let modelUsage = obj["modelUsage"] as? [String: Any], + let firstKey = modelUsage.keys.first + else { + return nil + } + return firstKey + } + + /// Builds the user-facing label shown in the picker. We prefer + /// the resolved full name when it adds information (e.g. + /// "Claude Sonnet 5" rather than "Sonnet"), and fall back to the + /// enum's static display name when the probe didn't return + /// anything useful. + private static func prettyName( + forResolved resolved: String, + alias: String, + cli: ChatCLITool + ) -> String { + let trimmed = resolved.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + // Probe returned nothing parseable — fall back to the enum + // entry. Keeps the picker useful even if the JSON format + // changes out from under us. + switch cli { + case .claude: + return ClaudeModel(rawValue: alias)?.displayName ?? alias + case .codex: + return CodexModel(rawValue: alias)?.displayName ?? alias + } + } + switch cli { + case .claude: + // `claude-sonnet-5` → "Claude Sonnet 5" + if let parsed = ClaudeModel(rawValue: alias) { + return "\(parsed.displayName) \(versionSuffix(from: trimmed))".trimmingCharacters( + in: .whitespacesAndNewlines) + } + return trimmed + case .codex: + if let parsed = CodexModel(rawValue: alias) { + // The Codex CLI currently echoes the model id back as-is, + // so the alias and the full name are typically identical. + // Don't render the parenthetical when that would just + // duplicate the enum's display name — "GPT 5.4 (gpt-5.4)" + // is noise. If a future CLI version reports a longer + // identifier (e.g. "gpt-5.4-mini-2025-09-15") we keep the + // parenthetical so the user can see the resolved name. + if trimmed.lowercased() == alias.lowercased() { + return parsed.displayName + } + return "\(parsed.displayName) (\(trimmed))" + } + return trimmed + } + } + + /// Extracts a human-readable version label from a resolved Claude + /// model name. Handles the patterns the CLI currently emits: + /// + /// - `claude-sonnet-5` → `5` + /// - `claude-opus-4-8` → `4.8` + /// - `claude-haiku-4-5-20251001` → `4.5` (build date dropped) + /// - `claude-fable-5-preview` → `5` (preview tag dropped) + /// + /// We walk the dash-separated segments and take the leading run + /// of "version-like" digit segments after the family name. We + /// stop at the first segment that is either non-numeric (a + /// `preview` tag, a date string, etc.) or a "long" number — + /// anything 5+ digits is almost certainly a build date or + /// snapshot id, not a version (current Anthropic versions are + /// 1-2 digits, e.g. "4-8" or "4-5"). The leading run of digit + /// segments is then joined with `.` so multi-segment versions + /// render naturally. + private static func versionSuffix(from fullName: String) -> String { + let segments = fullName.split(separator: "-").map(String.init) + // Find the first all-digit segment. Anything before it is the + // family/prefix name (e.g. "claude-sonnet", "claude-haiku") + // and we don't include it in the version because the caller + // already prepends the family display name. + guard + let firstDigitIndex = segments.firstIndex(where: { $0.allSatisfy(\.isNumber) }) + else { + return "" + } + // Take the leading run of "version-like" digit segments. We + // treat any 5+ digit number as a build date / snapshot id and + // stop before it. This makes `claude-haiku-4-5-20251001` + // resolve to `4.5` (not `4.5.20251001`). + let versionParts = segments[firstDigitIndex...].prefix { segment in + segment.allSatisfy(\.isNumber) && segment.count <= 4 + } + return versionParts.joined(separator: ".") + } +} + +// MARK: - Caching envelope + +/// JSON-encodable wrapper so the cache survives across launches +/// without depending on the in-memory type's identity. +private struct CachedEnvelope: Codable { + let discoveredAt: Date + let claude: [DiscoveredChatCLIModel] + let codex: [DiscoveredChatCLIModel] + + func toResult() -> ChatCLIModelCatalogResult { + ChatCLIModelCatalogResult( + claude: claude, codex: codex, discoveredAt: discoveredAt) + } +} diff --git a/Dayflow/Dayflow/Core/AI/ClaudeModelPreference.swift b/Dayflow/Dayflow/Core/AI/ClaudeModelPreference.swift new file mode 100644 index 000000000..07fef791f --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/ClaudeModelPreference.swift @@ -0,0 +1,88 @@ +// +// ClaudeModelPreference.swift +// Dayflow +// +// Persists the user's choice of Claude model. Mirrors +// `GeminiModelPreference` so the Settings → Providers tab can show a +// picker with the same UX as the Gemini one. +// +// The `rawValue` is the alias the Claude CLI accepts (e.g. `sonnet`), +// while `displayName` is the user-facing label that includes the +// resolved full model name (e.g. "Claude Sonnet 5"). The catalog +// (`ChatCLIModelCatalog`) is what populates `displayName` — at minimum +// we know the alias resolves to a real model on the user's account, +// because we probed the CLI to build the list in the first place. +// +// The stored preference is the *alias* (not the full name) because +// that's what the CLI's `--model` flag actually accepts and because +// aliases track the user's account to the latest release of that +// model family (e.g. `sonnet` will follow Sonnet 5 → Sonnet 5.5 +// without us having to bump a stored version). + +import Foundation + +enum ClaudeModel: String, Codable, CaseIterable { + // Aliases the Claude CLI accepts as `--model` values. Each one + // resolves to the latest model in that family on the user's account. + // The list is the same set the CLI probe iterates over + // (`ChatCLIModelCatalog.knownClaudeAliases`); keeping them in sync + // matters because `ClaudeModel(rawValue:)` is how we read a probed + // result back into the picker. + case sonnet + case opus + case fable + case haiku + + /// User-facing label for the picker. The `displayName` baked in + /// here is a *baseline* — the Settings UI prefers the live + /// `displayName` returned by `ChatCLIModelCatalog` so the user + /// always sees the resolved full model name (e.g. "Claude Sonnet 5") + /// rather than a hard-coded string. + var displayName: String { + switch self { + case .sonnet: return "Claude Sonnet" + case .opus: return "Claude Opus" + case .fable: return "Claude Fable" + case .haiku: return "Claude Haiku" + } + } + + /// One-line hint shown under the picker — keeps users oriented + /// between quality/speed tradeoffs without overwhelming the UI. + var hint: String { + switch self { + case .sonnet: return "Balanced — best default for most users" + case .opus: return "Highest quality, slower and pricier" + case .fable: return "Experimental — newest reasoning family" + case .haiku: return "Fastest — lower quality summaries" + } + } +} + +struct ClaudeModelPreference: Codable { + // Key bump intentionally hard-resets existing users to the new + // ordering, matching the `GeminiModelPreference` convention. + private static let storageKey = "claudeSelectedModel_v1" + + let primary: ClaudeModel + + static let `default` = ClaudeModelPreference(primary: .sonnet) + + static func load(from defaults: UserDefaults = .standard) -> ClaudeModelPreference { + if let data = defaults.data(forKey: storageKey), + let preference = try? JSONDecoder().decode(ClaudeModelPreference.self, from: data) + { + return preference + } + + let preference = ClaudeModelPreference.default + preference.save(to: defaults) + return preference + } + + func save(to defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(self) { + defaults.set(data, forKey: Self.storageKey) + } + } +} diff --git a/Dayflow/Dayflow/Core/AI/CodexModelPreference.swift b/Dayflow/Dayflow/Core/AI/CodexModelPreference.swift new file mode 100644 index 000000000..b94933809 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/CodexModelPreference.swift @@ -0,0 +1,70 @@ +// +// CodexModelPreference.swift +// Dayflow +// +// Persists the user's choice of Codex (ChatGPT) model. Mirrors +// `ClaudeModelPreference` so the Settings → Providers tab can show a +// picker with the same UX as the Gemini/Claude one. +// +// The `rawValue` is the model id the Codex CLI accepts as `--model`. +// The list is small and stable — OpenAI's flagship + a small/fast +// variant — and is what the CLI probe iterates over +// (`ChatCLIModelCatalog.knownCodexAliases`). + +import Foundation + +enum CodexModel: String, Codable, CaseIterable { + // Two distinct "5.6" variants existed in the legacy code (one for + // transcription, one for card generation). We expose them as + // separate picker rows so users who care about the difference can + // choose, but the default is the variant that was previously the + // hard-coded card-generation choice. + case gpt56Luna = "gpt-5.6-luna" + case gpt56Sol = "gpt-5.6-sol" + case gpt54 = "gpt-5.4" + case gpt54Mini = "gpt-5.4-mini" + + var displayName: String { + switch self { + case .gpt56Luna: return "GPT 5.6 (luna)" + case .gpt56Sol: return "GPT 5.6 (sol)" + case .gpt54: return "GPT 5.4" + case .gpt54Mini: return "GPT 5.4 mini" + } + } + + var hint: String { + switch self { + case .gpt56Luna: return "Latest — tuned for transcription" + case .gpt56Sol: return "Latest — tuned for card generation" + case .gpt54: return "Previous generation — solid default" + case .gpt54Mini: return "Fast and cheap — lighter summaries" + } + } +} + +struct CodexModelPreference: Codable { + private static let storageKey = "codexSelectedModel_v1" + + let primary: CodexModel + + static let `default` = CodexModelPreference(primary: .gpt56Luna) + + static func load(from defaults: UserDefaults = .standard) -> CodexModelPreference { + if let data = defaults.data(forKey: storageKey), + let preference = try? JSONDecoder().decode(CodexModelPreference.self, from: data) + { + return preference + } + + let preference = CodexModelPreference.default + preference.save(to: defaults) + return preference + } + + func save(to defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(self) { + defaults.set(data, forKey: Self.storageKey) + } + } +} diff --git a/Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift b/Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift index cf39b5168..c504ff20a 100644 --- a/Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift +++ b/Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift @@ -168,13 +168,16 @@ extension CodexProvider { static func activityCardModelConfiguration() -> ( model: String, reasoningEffort: String? ) { - return (model: "gpt-5.6-sol", reasoningEffort: "low") + // Mirrors `transcriptionModelConfiguration` — picks up the + // user's selection from `CodexModelPreference` rather than + // hard-coding a model name. + return (model: CodexModelPreference.load().primary.rawValue, reasoningEffort: "low") } static func legacyActivityCardModelConfiguration() -> ( model: String, reasoningEffort: String? ) { - return (model: "gpt-5.4", reasoningEffort: "low") + return (model: CodexModel.gpt54.rawValue, reasoningEffort: "low") } private func codexRunResultFromStreamingError( diff --git a/Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift b/Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift index 0d19d8ff3..17fa35fc2 100644 --- a/Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift +++ b/Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift @@ -6,13 +6,19 @@ extension CodexProvider { static func transcriptionModelConfiguration() -> ( model: String, reasoningEffort: String? ) { - return (model: "gpt-5.6-luna", reasoningEffort: "low") + // Mirrors `ClaudeProvider.transcriptionModelConfiguration` — the + // primary model is whatever the user picked in Settings → Providers, + // loaded from `CodexModelPreference`. The legacy fallback stays + // hard-coded because the picker only exposes the primary slot; if + // the user's selected model errors out, Dayflow falls back to a + // known-good previous-generation model. + return (model: CodexModelPreference.load().primary.rawValue, reasoningEffort: "low") } static func legacyTranscriptionModelConfiguration() -> ( model: String, reasoningEffort: String? ) { - return (model: "gpt-5.4-mini", reasoningEffort: "low") + return (model: CodexModel.gpt54Mini.rawValue, reasoningEffort: "low") } func transcribeScreenshots( diff --git a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift index 402febfe9..866798d90 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift @@ -15,6 +15,15 @@ final class ProvidersSettingsViewModel: ObservableObject { @Published var providerRoutingErrorMessage: String? @Published var selectedGeminiModel: GeminiModel + @Published var selectedClaudeModel: ClaudeModel + @Published var selectedCodexModel: CodexModel + /// Live catalog of models the user can pick for the Chat CLI + /// providers, populated by `ChatCLIModelCatalog.discover()`. When + /// the catalog is empty (e.g. the CLI isn't installed), the + /// Settings UI falls back to the static enum lists. + @Published private(set) var chatCLIModelCatalog: ChatCLIModelCatalogResult = + .empty + @Published private(set) var isDiscoveringChatCLIModels = false @Published var localEngine: LocalEngine { didSet { guard oldValue != localEngine else { return } @@ -133,6 +142,13 @@ final class ProvidersSettingsViewModel: ObservableObject { selectedGeminiModel = preference.primary savedGeminiModel = preference.primary + // Load the Chat CLI model picks up-front. The Settings UI uses + // these even before the catalog discovery completes, so the + // picker is never empty for a returning user. + selectedClaudeModel = ClaudeModelPreference.load().primary + selectedCodexModel = CodexModelPreference.load().primary + chatCLIModelCatalog = ChatCLIModelCatalog.cached() ?? .empty + let rawEngine = UserDefaults.standard.string(forKey: "llmLocalEngine") ?? "ollama" let engine = LocalEngine(rawValue: rawEngine) ?? .ollama localEngine = engine @@ -173,6 +189,14 @@ final class ProvidersSettingsViewModel: ObservableObject { loadOllamaPromptOverridesIfNeeded() if currentProvider == .chatGPT || currentProvider == .claude { selectedAgentPromptProvider = currentProvider + // Probe the Chat CLI to populate the model picker. We only + // kick this off when one of the CLI providers is active — the + // Gemini/local/openai-compatible providers don't use it, so + // there's no point spending the API calls. Uses the 1-hour + // cache by default; the "Refresh" button forces a re-probe. + if chatCLIModelCatalog.claude.isEmpty && chatCLIModelCatalog.codex.isEmpty { + refreshChatCLIModelCatalog(force: false) + } } else { loadAgentPromptOverridesIfNeeded() } @@ -725,6 +749,90 @@ final class ProvidersSettingsViewModel: ObservableObject { } } + // MARK: - Chat CLI model selection (Claude / Codex) + + /// Re-runs the CLI probe to discover the live model list. Wired to + /// the "Refresh" button in the Settings → Providers tab so users + /// can force a re-probe after upgrading their subscription tier + /// (which may unlock a model that wasn't available before). + func refreshChatCLIModelCatalog(force: Bool = true) { + guard !isDiscoveringChatCLIModels else { return } + isDiscoveringChatCLIModels = true + Task { [weak self] in + let result = await ChatCLIModelCatalog.discover(refresh: force) + await MainActor.run { [weak self] in + guard let self else { return } + self.chatCLIModelCatalog = result + self.isDiscoveringChatCLIModels = false + } + } + } + + /// Persists the user's Claude model choice to `ClaudeModelPreference` + /// and updates the cached catalog pick so the next batch uses the + /// new model. No re-probe needed — the catalog already knows the + /// alias is valid for this account. + func persistClaudeModelSelection(_ model: ClaudeModel, source: String) { + ClaudeModelPreference(primary: model).save() + + Task { @MainActor in + AnalyticsService.shared.capture( + "claude_model_selected", + [ + "source": source, + "model": model.rawValue, + ]) + } + } + + /// Persists the user's Codex model choice. Mirrors + /// `persistClaudeModelSelection`. + func persistCodexModelSelection(_ model: CodexModel, source: String) { + CodexModelPreference(primary: model).save() + + Task { @MainActor in + AnalyticsService.shared.capture( + "codex_model_selected", + [ + "source": source, + "model": model.rawValue, + ]) + } + } + + /// Returns the picker rows for the Claude model section. Prefers + /// the live catalog (which has display names like "Claude Sonnet 5") + /// and falls back to the static enum list when the catalog is + /// empty (e.g. CLI not installed or probe hasn't run yet). + func claudeModelPickerRows() -> [DiscoveredChatCLIModel] { + let catalogRows = chatCLIModelCatalog.claude + if !catalogRows.isEmpty { + return catalogRows + } + return ClaudeModel.allCases.map { alias in + DiscoveredChatCLIModel( + id: alias.rawValue, + displayName: alias.displayName, + fullName: alias.rawValue + ) + } + } + + /// Same as `claudeModelPickerRows` but for Codex. + func codexModelPickerRows() -> [DiscoveredChatCLIModel] { + let catalogRows = chatCLIModelCatalog.codex + if !catalogRows.isEmpty { + return catalogRows + } + return CodexModel.allCases.map { alias in + DiscoveredChatCLIModel( + id: alias.rawValue, + displayName: alias.displayName, + fullName: alias.rawValue + ) + } + } + private var providerCatalog: [CompactProviderInfo] { [ CompactProviderInfo( diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift index de9e3a5e9..5239db4d4 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift @@ -38,6 +38,12 @@ struct SettingsProvidersTabView: View { if viewModel.currentProvider == .gemini { geminiModelSection } + if viewModel.currentProvider == .claude { + claudeModelSection + } + if viewModel.currentProvider == .chatGPT { + codexModelSection + } primaryPromptCustomizationSection if viewModel.hasCodexOrClaudeProviderInRouting { @@ -123,6 +129,16 @@ struct SettingsProvidersTabView: View { SettingsRow(label: "CLI") { SettingsMetadata(text: viewModel.cliStatusLabel(for: viewModel.currentProvider)) } + // Show the user's currently selected Chat CLI model in the + // summary so they can confirm at a glance which model is + // running. Reads the same preference the picker writes. + SettingsRow(label: "Model") { + SettingsMetadata( + text: viewModel.currentProvider == .claude + ? viewModel.selectedClaudeModel.displayName + : viewModel.selectedCodexModel.displayName + ) + } case .openAICompatible: SettingsRow(label: "Preset") { SettingsMetadata( @@ -368,6 +384,159 @@ struct SettingsProvidersTabView: View { } } + // MARK: - Claude model preference + // + // Mirrors `geminiModelSection`. The picker is fed by the live + // `ChatCLIModelCatalog` so the rows show the resolved full model + // name (e.g. "Claude Sonnet 5") rather than the alias the CLI + // actually accepts. We still bind to the alias enum on the + // view model — that's what the CLI takes and what gets persisted. + + private var claudeModelSection: some View { + SettingsSection( + title: "Claude model preference", + subtitle: "Choose which Claude model Dayflow should use through Claude Code." + ) { + VStack(alignment: .leading, spacing: 14) { + claudeModelPicker + + if let hint = hintForClaudeModel(viewModel.selectedClaudeModel) { + Text(hint) + .font(.custom("Figtree", size: 12)) + .foregroundColor(SettingsStyle.secondary) + } + + HStack(spacing: 8) { + SettingsSecondaryButton( + title: viewModel.isDiscoveringChatCLIModels + ? "Refreshing…" + : (viewModel.chatCLIModelCatalog.claude.isEmpty + ? "Discover models" : "Refresh model list"), + action: { viewModel.refreshChatCLIModelCatalog(force: true) } + ) + .disabled(viewModel.isDiscoveringChatCLIModels) + } + + Text( + "Dayflow sends the alias (e.g. `sonnet`) to the Claude CLI; the CLI picks the latest release of that family on your account." + ) + .font(.custom("Figtree", size: 11)) + .foregroundColor(SettingsStyle.meta) + } + } + } + + /// Picker rows come from the live catalog when available, with a + /// static-enum fallback so the UI is never empty. We tag the + /// picker rows with the matching `ClaudeModel` enum so the binding + /// (`$viewModel.selectedClaudeModel`) keeps working. + @ViewBuilder + private var claudeModelPicker: some View { + let rows = viewModel.claudeModelPickerRows() + if rows.isEmpty { + // Catalog probe hasn't returned yet AND the static enum is + // empty (which shouldn't happen — we always have at least the + // four built-in aliases). Show a placeholder so the layout + // doesn't collapse. + Text("Loading models…") + .font(.custom("Figtree", size: 12)) + .foregroundColor(SettingsStyle.meta) + } else { + Picker("Claude model", selection: $viewModel.selectedClaudeModel) { + ForEach(rows, id: \.id) { row in + if let model = ClaudeModel(rawValue: row.id) { + Text(row.displayName).tag(model) + } + } + } + .pickerStyle(.menu) + .labelsHidden() + .environment(\.colorScheme, .light) + .onChange(of: viewModel.selectedClaudeModel) { _, newValue in + viewModel.persistClaudeModelSelection(newValue, source: "settings") + } + } + } + + private func hintForClaudeModel(_ model: ClaudeModel) -> String? { + // The hint is only shown for the model the user actually picked — + // the catalog may have pruned the row (e.g. the user doesn't + // have access to Opus), so we look up the static hint rather + // than the catalog's display name. + if viewModel.chatCLIModelCatalog.claude.contains(where: { $0.id == model.rawValue }) { + return model.hint + } + // Selected model isn't in the live catalog. The CLI probe may + // be stale, or the user may have picked a model that's no longer + // available. Show a hint anyway so the user understands what + // they selected. + return model.hint + } + + // MARK: - Codex model preference + // + // Mirror of `claudeModelSection` for the Codex (ChatGPT) CLI. See + // the Claude section above for the design notes — same shape, same + // catalog source. + + private var codexModelSection: some View { + SettingsSection( + title: "Codex model preference", + subtitle: "Choose which Codex (ChatGPT) model Dayflow should use." + ) { + VStack(alignment: .leading, spacing: 14) { + codexModelPicker + + if !viewModel.selectedCodexModel.hint.isEmpty { + Text(viewModel.selectedCodexModel.hint) + .font(.custom("Figtree", size: 12)) + .foregroundColor(SettingsStyle.secondary) + } + + HStack(spacing: 8) { + SettingsSecondaryButton( + title: viewModel.isDiscoveringChatCLIModels + ? "Refreshing…" + : (viewModel.chatCLIModelCatalog.codex.isEmpty + ? "Discover models" : "Refresh model list"), + action: { viewModel.refreshChatCLIModelCatalog(force: true) } + ) + .disabled(viewModel.isDiscoveringChatCLIModels) + } + + Text( + "Dayflow sends the model id (e.g. `gpt-5.6-luna`) to the Codex CLI; the CLI resolves it on your account." + ) + .font(.custom("Figtree", size: 11)) + .foregroundColor(SettingsStyle.meta) + } + } + } + + @ViewBuilder + private var codexModelPicker: some View { + let rows = viewModel.codexModelPickerRows() + if rows.isEmpty { + Text("Loading models…") + .font(.custom("Figtree", size: 12)) + .foregroundColor(SettingsStyle.meta) + } else { + Picker("Codex model", selection: $viewModel.selectedCodexModel) { + ForEach(rows, id: \.id) { row in + if let model = CodexModel(rawValue: row.id) { + Text(row.displayName).tag(model) + } + } + } + .pickerStyle(.menu) + .labelsHidden() + .environment(\.colorScheme, .light) + .onChange(of: viewModel.selectedCodexModel) { _, newValue in + viewModel.persistCodexModelSelection(newValue, source: "settings") + } + } + } + // MARK: - Prompt customization @ViewBuilder