diff --git a/Dayflow/Dayflow/Core/AI/ChatCLIModelCatalog.swift b/Dayflow/Dayflow/Core/AI/ChatCLIModelCatalog.swift new file mode 100644 index 00000000..83a4872b --- /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 00000000..07fef791 --- /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/ClaudeProvider+ActivityCards.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift index c5dc0abe..c0414698 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 dc70866e..efd81cf1 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/Dayflow/Core/AI/CodexModelPreference.swift b/Dayflow/Dayflow/Core/AI/CodexModelPreference.swift new file mode 100644 index 00000000..b9493380 --- /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 cf39b516..c504ff20 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 0d19d8ff..17fa35fc 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/Core/AI/LLMService.swift b/Dayflow/Dayflow/Core/AI/LLMService.swift index 00e274c3..5166cc28 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 4a3816b2..a24c3cac 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 6a8b6dea..37b2735b 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 87db20c8..a68828ed 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 868f5d5b..3e07565c 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 69a61314..75634558 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/Layout.swift b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift index e61f5b07..1879254e 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 diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift index ae9215c8..129f59a9 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 6154718a..a5ab0583 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/Settings/ProvidersSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift index 402febfe..866798d9 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 de9e3a5e..5239db4d 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 diff --git a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift index 457c6c2e..40091d8d 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 dd10aeac..a0765bbc 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 c05356e9..5cb9752b 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/CodexClaudeProviderTests.swift b/Dayflow/DayflowTests/CodexClaudeProviderTests.swift index 90031a7c..4dfd9227 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") } diff --git a/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift b/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift index 77d09f8a..bc100bea 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 ) }