485 lines
23 KiB
Swift
485 lines
23 KiB
Swift
import Foundation
|
|
import NucleicCore
|
|
|
|
/// Selectable model SKUs and effort levels, plus the persistence keys for the per-app
|
|
/// defaults applied to new chats. The chosen SKU also selects the backend
|
|
/// (`BackendID.forModel`), and each model exposes only the effort levels it supports.
|
|
enum ModelCatalog {
|
|
/// Full model SKUs the picker offers (not friendly aliases). The chosen SKU also selects
|
|
/// the backend (`BackendID.forModel`): the Claude SKUs run on Claude Code, the `gpt-*` SKUs
|
|
/// on Codex (`codex app-server`), and the `grok-*` SKUs on Grok over ACP (`grok agent stdio`).
|
|
static let models: [String] = [
|
|
"claude-opus-5",
|
|
"claude-fable-5",
|
|
"claude-opus-4-8[1m]",
|
|
"claude-opus-4-8",
|
|
"claude-sonnet-5",
|
|
"claude-sonnet-4-6",
|
|
"claude-haiku-4-5",
|
|
"gpt-5.6-sol",
|
|
"gpt-5.6-terra",
|
|
"gpt-5.6-luna",
|
|
"gpt-5.5",
|
|
"gpt-5.4",
|
|
"gpt-5.4-mini",
|
|
"grok-build",
|
|
"opencode",
|
|
"openclaw",
|
|
"hermes",
|
|
"cursor-agent",
|
|
"acp-agent",
|
|
]
|
|
/// All effort levels, ordered lowest → highest. A given model supports a *prefix* of
|
|
/// these (see `efforts(for:)`); the picker should only ever offer the supported ones.
|
|
static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"]
|
|
|
|
/// The single **"Auto"** reasoning mode used by the ACP wrapper agents (Grok/OpenCode/OpenClaw/
|
|
/// Hermes/Cursor): the agent picks the thinking depth itself, so the only API-level choice is `auto` —
|
|
/// presented under the "Reasoning" noun (see `effortNoun`) and rendered "Auto"
|
|
/// (`effortDisplayName`). The backend never passes a reasoning flag; the selection is cosmetic.
|
|
/// (Orchestra is still offered below it in the menu — an orchestration mode, not a reasoning
|
|
/// level.)
|
|
static let autoEfforts: [String] = ["auto"]
|
|
|
|
/// The backends whose agents expose only the single "Auto" reasoning (`autoEfforts`) — the ACP
|
|
/// wrapper agents, which don't take a `reasoning_effort`-style flag.
|
|
static let autoReasoningBackends: Set<BackendID> = [
|
|
.grok, .opencode, .openclaw, .hermes, .cursorAgent, .acp,
|
|
]
|
|
|
|
/// The effort levels `sku` actually supports. The ACP wrapper agents expose only their single
|
|
/// "Auto" reasoning (`autoEfforts`); Codex models top out at "xhigh" (confirmed from `codex
|
|
/// model/list`: gpt-5.x supports low/medium/high/xhigh); Claude models support the full range
|
|
/// incl. "max".
|
|
static func efforts(for sku: String) -> [String] {
|
|
if let backend = BackendID.forModel(sku), autoReasoningBackends.contains(backend) {
|
|
return autoEfforts
|
|
}
|
|
guard let cap = effortCap(for: sku), let idx = efforts.firstIndex(of: cap) else {
|
|
return efforts
|
|
}
|
|
return Array(efforts[...idx])
|
|
}
|
|
|
|
/// Highest effort `sku` supports, or `nil` for the full range.
|
|
private static func effortCap(for sku: String) -> String? {
|
|
BackendID.forModel(sku) == .codex ? "xhigh" : nil
|
|
}
|
|
|
|
/// Clamp `effort` to what `sku` supports, so switching to a model with a lower cap can't
|
|
/// leave an unsupported level selected (e.g. "max" carried onto a Codex model → "xhigh").
|
|
/// Orchestra is an orchestration mode, not an API level — valid for every model (it
|
|
/// resolves to a supported effort, `xhigh`, on the host), so it's always preserved.
|
|
static func clampedEffort(_ effort: String, for sku: String) -> String {
|
|
if isOrchestra(effort) { return effort }
|
|
let supported = efforts(for: sku)
|
|
return supported.contains(effort) ? effort : (supported.last ?? fallbackEffort)
|
|
}
|
|
|
|
/// The reasoning control's noun in the composer: Codex and Grok call it "Reasoning" (both
|
|
/// expose a `reasoning_effort`-style thinking level), Claude "Effort".
|
|
static func effortNoun(for sku: String) -> String {
|
|
switch BackendID.forModel(sku) {
|
|
case .codex, .grok, .opencode, .openclaw, .hermes, .cursorAgent, .acp: return "Reasoning"
|
|
default: return "Effort"
|
|
}
|
|
}
|
|
|
|
/// The picker's SKUs split into per-provider runs, preserving `models` order, so the
|
|
/// composer's model menu can set each provider apart with a divider (Claude, then GPT,
|
|
/// then Grok). Providers are contiguous in `models`, so a new group starts whenever the
|
|
/// inferred backend changes (`BackendID.forModel`).
|
|
static var modelGroups: [[String]] {
|
|
var groups: [[String]] = []
|
|
var current: [String] = []
|
|
var currentBackend: BackendID?
|
|
for sku in models {
|
|
let backend = BackendID.forModel(sku) ?? .claudeCode
|
|
if backend != currentBackend, !current.isEmpty {
|
|
groups.append(current)
|
|
current = []
|
|
}
|
|
currentBackend = backend
|
|
current.append(sku)
|
|
}
|
|
if !current.isEmpty { groups.append(current) }
|
|
return groups
|
|
}
|
|
|
|
/// The SKUs that run on `backend` — for the in-session model menu, where the backend is
|
|
/// fixed at creation and can't change. (The home composer offers all SKUs, since the model
|
|
/// chooses the backend.)
|
|
static func models(for backend: BackendID) -> [String] {
|
|
models.filter { sku in
|
|
let modelBackend = BackendID.forModel(sku) ?? .claudeCode
|
|
switch backend {
|
|
case .claudeCode: return modelBackend == .claudeCode
|
|
case .codex, .codexExec: return modelBackend == .codex
|
|
case .grok: return modelBackend == .grok
|
|
case .opencode: return modelBackend == .opencode
|
|
case .openclaw: return modelBackend == .openclaw
|
|
case .hermes: return modelBackend == .hermes
|
|
case .cursorAgent: return modelBackend == .cursorAgent
|
|
case .acp: return modelBackend == .acp
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The "orchestra" orchestration mode (`OrchestrationMode`). It rides in the effort menu
|
|
/// like a level, but it isn't an API effort — it pairs `xhigh` with standing consent to
|
|
/// fan work out to parallel subagents. Kept out of `efforts` (the canonical API levels) so
|
|
/// it can be presented separately, below a divider, with its own purple treatment.
|
|
static let orchestraEffort = OrchestrationMode.effortSentinel
|
|
|
|
/// Whether `effort` selects orchestra.
|
|
static func isOrchestra(_ effort: String?) -> Bool { OrchestrationMode.isOrchestra(effort) }
|
|
|
|
/// A pretty, human label for an effort level — orchestra reads "Orchestra"; Grok's single
|
|
/// reasoning mode reads "Auto" (a named mode, like Orchestra, not a generic level); the plain
|
|
/// API levels keep their lowercase word (matching how they're shown in the menu today).
|
|
static func effortDisplayName(_ effort: String) -> String {
|
|
if isOrchestra(effort) { return "Orchestra" }
|
|
if effort == "auto" { return "Auto" }
|
|
return effort
|
|
}
|
|
|
|
/// One-line description of what orchestra does, for the menu item's help tooltip.
|
|
static let orchestraBlurb =
|
|
"Orchestra: maximum-effort orchestration. Runs at xhigh and lets the agent fan work "
|
|
+ "out to parallel subagents on its own for thorough, verified results (uses more tokens)."
|
|
|
|
/// Tooltip shown once Orchestra is on for a chat: it's a one-way latch and can't be turned off,
|
|
/// so the other effort levels are disabled (the host enforces this in `SessionController`).
|
|
static let orchestraLockedHelp =
|
|
"Orchestra is on for this chat and can't be turned off — start a new chat to use a "
|
|
+ "different effort level."
|
|
|
|
/// Tooltip shown on the Orchestra row for a chat that isn't already in Orchestra: the mode is
|
|
/// fixed when a chat is created (that's when the supervisor model is chosen), so it can't be
|
|
/// switched on partway through — you pick it in the new-chat composer instead.
|
|
static let orchestraStartOnlyHelp =
|
|
"Orchestra can only be turned on when you start a chat — pick it in the new-chat composer."
|
|
|
|
/// Why Orchestra may be unavailable: it's a Nucleic Control capability (the hardened,
|
|
/// sandboxed path), so it's only offered for projects under Nucleic Control. Shown as the
|
|
/// grayed annotation on the disabled menu item and as its tooltip.
|
|
static let orchestraRequiresControlNote = "Requires Nucleic Control"
|
|
|
|
/// Full explanation for the requirement tooltip.
|
|
static let orchestraRequiresControlHelp =
|
|
"Orchestra is a Nucleic Control capability — clone or move this project under Nucleic "
|
|
+ "Control to enable it."
|
|
|
|
/// A pretty, human label for a model SKU (e.g. "claude-sonnet-4-6" → "Sonnet
|
|
/// 4.6"). The two Opus 4.8 SKUs share the name "Opus 4.8"; their context window
|
|
/// is conveyed by the picker badge (see `contextBadge`). Falls back to a
|
|
/// best-effort prettifier for unknown SKUs.
|
|
static func displayName(_ sku: String) -> String {
|
|
switch sku {
|
|
case "claude-opus-5": return "Opus 5"
|
|
case "claude-fable-5": return "Fable 5"
|
|
case "claude-opus-4-8[1m]": return "Opus 4.8"
|
|
case "claude-opus-4-8": return "Opus 4.8"
|
|
case "claude-sonnet-5": return "Sonnet 5"
|
|
case "claude-sonnet-4-6": return "Sonnet 4.6"
|
|
case "claude-haiku-4-5": return "Haiku 4.5"
|
|
case "gpt-5.6-terra": return "GPT-5.6 Terra"
|
|
case "gpt-5.6-luna": return "GPT-5.6 Luna"
|
|
case "gpt-5.6-sol": return "GPT-5.6 Sol"
|
|
case "gpt-5.5": return "GPT-5.5"
|
|
case "gpt-5.4": return "GPT-5.4"
|
|
case "gpt-5.4-mini": return "GPT-5.4 Mini"
|
|
case "grok-build": return "Grok Build"
|
|
case "opencode": return "OpenCode"
|
|
case "openclaw": return "OpenClaw"
|
|
case "hermes": return "Hermes"
|
|
case "cursor-agent": return "Cursor Agent"
|
|
case "acp-agent": return "Custom ACP"
|
|
default: return prettify(sku)
|
|
}
|
|
}
|
|
|
|
private static func prettify(_ sku: String) -> String {
|
|
var stem = sku
|
|
if stem.hasPrefix("claude-") { stem.removeFirst("claude-".count) }
|
|
var suffix = ""
|
|
if let open = stem.firstIndex(of: "["), let close = stem.firstIndex(of: "]") {
|
|
suffix = " (\(stem[stem.index(after: open)..<close].uppercased()))" // [1m] → (1M)
|
|
stem = String(stem[..<open])
|
|
}
|
|
let parts = stem.split(separator: "-")
|
|
guard let family = parts.first else { return sku }
|
|
let familyName = family.prefix(1).uppercased() + family.dropFirst()
|
|
let version = parts.dropFirst().joined(separator: ".")
|
|
return version.isEmpty ? "\(familyName)\(suffix)" : "\(familyName) \(version)\(suffix)"
|
|
}
|
|
|
|
static let fallbackModel = "claude-opus-5"
|
|
static let fallbackEffort = "high"
|
|
static let fallbackSupervisorModel = "claude-fable-5"
|
|
static let fallbackOrchestraWorkerModel = "claude-sonnet-5"
|
|
/// How many Orchestra workers the supervisor may run at once by default. The fan-out
|
|
/// multiplies token usage, so this bounds a single supervisor's concurrent workers rather
|
|
/// than letting an over-eager fan-out spawn an unlimited wave. `0` means "unlimited".
|
|
static let fallbackOrchestraMaxWorkers = 4
|
|
/// The discrete choices offered in Settings for the concurrent-worker cap; `0` = Unlimited.
|
|
static let orchestraMaxWorkerChoices = [0, 2, 3, 4, 6, 8, 12, 16]
|
|
|
|
/// Per-provider default model + effort, **highest priority first**, used to pick a new-chat
|
|
/// default the user can actually run. The first provider the user has connected wins: Claude
|
|
/// → Opus 5 at high effort, then Codex → GPT-5.5 at medium, then Grok → Grok Build at
|
|
/// "auto" (its only reasoning mode). Consulted by `resolvedDefault` when the stored default's
|
|
/// backend isn't connected — so the default is never a model on a provider the user lacks.
|
|
static let providerDefaults: [(backend: BackendID, model: String, effort: String)] = [
|
|
(.claudeCode, "claude-opus-5", "high"),
|
|
(.codex, "gpt-5.5", "medium"),
|
|
(.grok, "grok-build", "auto"),
|
|
(.opencode, "opencode", "auto"),
|
|
(.openclaw, "openclaw", "auto"),
|
|
(.hermes, "hermes", "auto"),
|
|
(.cursorAgent, "cursor-agent", "auto"),
|
|
(.acp, "acp-agent", "auto"),
|
|
]
|
|
|
|
/// A comparable model on the *other* provider, for status-driven failover: each SKU pairs
|
|
/// with its nearest-tier counterpart across the Claude ↔ GPT line, so when one provider
|
|
/// reports an incident the app can offer a like-for-like default on the other (Opus ↔
|
|
/// GPT-5.5, Sonnet ↔ GPT-5.4, Haiku ↔ GPT-5.4 Mini). Nil for an unrecognized SKU.
|
|
static func crossProviderAlternative(for sku: String) -> String? {
|
|
switch sku {
|
|
case "claude-opus-5": return "gpt-5.5"
|
|
case "claude-fable-5": return "gpt-5.5"
|
|
case "claude-opus-4-8[1m]", "claude-opus-4-8": return "gpt-5.5"
|
|
case "claude-sonnet-5", "claude-sonnet-4-6": return "gpt-5.4"
|
|
case "claude-haiku-4-5": return "gpt-5.4-mini"
|
|
case "gpt-5.5": return "claude-opus-4-8[1m]"
|
|
case "gpt-5.4": return "claude-sonnet-5"
|
|
case "gpt-5.4-mini": return "claude-haiku-4-5"
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
/// Approximate input context-window size (tokens) for a model SKU, used by the
|
|
/// composer's session context-usage indicator. The `[1m]` variant gets the 1M
|
|
/// window; standard Opus 4.8 gets 256K; every other Claude model uses 200K.
|
|
static func contextWindow(for sku: String) -> Int {
|
|
if sku.contains("[1m]") { return 1_000_000 }
|
|
if sku.hasPrefix("claude-fable") { return 1_000_000 } // Fable 5: 1M (max and default)
|
|
if sku.hasPrefix("claude-sonnet-5") { return 1_000_000 } // Sonnet 5: 1M
|
|
if sku.hasPrefix("claude-opus-5") { return 1_000_000 } // Opus 5: 1M (default)
|
|
if sku.hasPrefix("claude-opus-4-8") { return 256_000 }
|
|
if sku.hasPrefix("gpt-5") { return 350_000 } // codex gpt-5.x window (~353K observed)
|
|
if sku.hasPrefix("grok") { return 256_000 } // grok-build window (inferred)
|
|
// ACP wrapper agents are model-agnostic; use a conservative default window for the
|
|
// composer's usage indicator until a live capture pins each one.
|
|
if sku.hasPrefix("opencode") || sku.hasPrefix("openclaw") || sku.hasPrefix("hermes")
|
|
|| sku.hasPrefix("cursor") || sku == "acp-agent" {
|
|
return 200_000
|
|
}
|
|
return 200_000
|
|
}
|
|
|
|
/// Short, grayed annotation shown beside a SKU in the model picker to call out
|
|
/// its context window — "1M" for the `[1m]` Opus 4.8 and "256K" for the
|
|
/// standard one — so the two same-named Opus entries are distinguishable. Nil
|
|
/// for other models.
|
|
static func contextBadge(for sku: String) -> String? {
|
|
switch sku {
|
|
case "claude-opus-4-8[1m]": return "1M"
|
|
case "claude-opus-4-8": return "256K"
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
static let defaultModelKey = "nucleic.defaultModel"
|
|
static let defaultEffortKey = "nucleic.defaultEffort"
|
|
static let defaultSupervisorModelKey = "nucleic.defaultSupervisorModel"
|
|
static let defaultOrchestraWorkerModelKey = "nucleic.defaultOrchestraWorkerModel"
|
|
static let defaultOrchestraMaxWorkersKey = "nucleic.defaultOrchestraMaxConcurrentWorkers"
|
|
static let defaultAutoKey = "nucleic.defaultAuto"
|
|
static let defaultAutoShipKey = "nucleic.defaultAutoShip"
|
|
|
|
static var storedDefaultModel: String {
|
|
UserDefaults.standard.string(forKey: defaultModelKey) ?? fallbackModel
|
|
}
|
|
static var storedDefaultEffort: String {
|
|
UserDefaults.standard.string(forKey: defaultEffortKey) ?? fallbackEffort
|
|
}
|
|
static var storedDefaultSupervisorModel: String {
|
|
UserDefaults.standard.string(forKey: defaultSupervisorModelKey) ?? fallbackSupervisorModel
|
|
}
|
|
static var storedDefaultOrchestraWorkerModel: String {
|
|
UserDefaults.standard.string(forKey: defaultOrchestraWorkerModelKey) ?? fallbackOrchestraWorkerModel
|
|
}
|
|
/// The stored concurrent-worker cap, or the fallback when unset. `object(forKey:)` (not
|
|
/// `integer(forKey:)`) so an explicit `0` (Unlimited) is preserved rather than read as unset.
|
|
static var storedDefaultOrchestraMaxWorkers: Int {
|
|
UserDefaults.standard.object(forKey: defaultOrchestraMaxWorkersKey) as? Int ?? fallbackOrchestraMaxWorkers
|
|
}
|
|
static var storedDefaultAuto: Bool {
|
|
UserDefaults.standard.bool(forKey: defaultAutoKey)
|
|
}
|
|
static var storedDefaultAutoShip: Bool {
|
|
UserDefaults.standard.bool(forKey: defaultAutoShipKey)
|
|
}
|
|
|
|
/// The default `(model, effort)` a new chat should use given which provider backends are
|
|
/// **connected** (installed *and* authenticated — see `ProviderAvailability`). Honors the
|
|
/// stored default when its backend is connected, so an explicit choice still wins; otherwise
|
|
/// falls through `providerDefaults` (Claude → Codex → Grok) to the highest-priority connected
|
|
/// provider, so the default is never a model whose backend the user can't reach. With nothing
|
|
/// connected, keeps the stored/`fallback` default so the picker always has *a* selection.
|
|
static func resolvedDefault(connected: Set<BackendID>) -> (model: String, effort: String) {
|
|
let storedModel = storedDefaultModel
|
|
if let backend = BackendID.forModel(storedModel), connected.contains(backend) {
|
|
return (storedModel, clampedEffort(storedDefaultEffort, for: storedModel))
|
|
}
|
|
if let pick = providerDefaults.first(where: { connected.contains($0.backend) }) {
|
|
return (pick.model, pick.effort)
|
|
}
|
|
return (storedModel, storedDefaultEffort)
|
|
}
|
|
|
|
/// The wire projection sent to paired phones (SYNC §5.2) so their composer/header pickers
|
|
/// mirror this catalog — same per-backend effort caps, context windows, and grouping —
|
|
/// without re-deriving any backend rules on-device. Built from this single source of truth.
|
|
static var wireCatalog: WireModelCatalog {
|
|
let groups: [[WireModelCatalog.Model]] = modelGroups.map { group in
|
|
group.map { sku in
|
|
WireModelCatalog.Model(
|
|
sku: sku,
|
|
displayName: displayName(sku),
|
|
backend: BackendID.forModel(sku) ?? .claudeCode,
|
|
contextBadge: contextBadge(for: sku),
|
|
contextWindow: contextWindow(for: sku),
|
|
efforts: efforts(for: sku),
|
|
effortNoun: effortNoun(for: sku))
|
|
}
|
|
}
|
|
// Pretty labels only for the non-identity effort levels (e.g. "auto" → "Auto") plus the
|
|
// orchestra sentinel; the phone shows the raw level for everything else.
|
|
var effortNames: [String: String] = [:]
|
|
for sku in models {
|
|
for level in efforts(for: sku) where effortDisplayName(level) != level {
|
|
effortNames[level] = effortDisplayName(level)
|
|
}
|
|
}
|
|
effortNames[orchestraEffort] = effortDisplayName(orchestraEffort) // "Orchestra"
|
|
return WireModelCatalog(
|
|
groups: groups,
|
|
effortDisplayNames: effortNames,
|
|
orchestraSentinel: orchestraEffort,
|
|
orchestraRequiresControlNote: orchestraRequiresControlNote,
|
|
fallbackModel: fallbackModel,
|
|
fallbackEffort: fallbackEffort)
|
|
}
|
|
}
|
|
|
|
|
|
/// How long a *completed* chat may sit untouched before Nucleic auto-archives it.
|
|
/// Persisted as the raw seconds value; `.never` (0) turns auto-archiving off. The
|
|
/// `AppStore` only ever sees the resolved `interval`, keeping the core unaware of the
|
|
/// UI's specific choices.
|
|
enum AutoArchivePolicy: Int, CaseIterable, Identifiable {
|
|
case never = 0
|
|
case after30Minutes = 1800
|
|
case afterHour = 3600
|
|
case after3Hours = 10800
|
|
case afterDay = 86_400
|
|
case afterWeek = 604_800
|
|
|
|
var id: Int { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .never: "Never"
|
|
case .after30Minutes: "After 30 minutes"
|
|
case .afterHour: "After 1 hour"
|
|
case .after3Hours: "After 3 hours"
|
|
case .afterDay: "After 1 day"
|
|
case .afterWeek: "After 1 week"
|
|
}
|
|
}
|
|
|
|
/// The bare duration phrase used in explanatory copy ("30 minutes", "a week").
|
|
var durationPhrase: String {
|
|
switch self {
|
|
case .never: ""
|
|
case .after30Minutes: "30 minutes"
|
|
case .afterHour: "an hour"
|
|
case .after3Hours: "3 hours"
|
|
case .afterDay: "a day"
|
|
case .afterWeek: "a week"
|
|
}
|
|
}
|
|
|
|
/// Idle threshold in seconds the `AppStore` sweeps against, or nil to disable.
|
|
var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) }
|
|
|
|
static let storageKey = "nucleic.autoArchivePolicy"
|
|
/// Out of the box, completed chats archive after a day; the user can shorten it,
|
|
/// lengthen it, or pick "Never" in Settings.
|
|
static let fallback = AutoArchivePolicy.afterDay
|
|
|
|
static var stored: AutoArchivePolicy {
|
|
// No stored key yet → the default behavior; `.never` is a real stored 0.
|
|
guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback }
|
|
return AutoArchivePolicy(rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback
|
|
}
|
|
}
|
|
|
|
|
|
/// How long a chat may stay *archived* before Nucleic reclaims its worktree checkout to free
|
|
/// disk. The branch — and any uncommitted work, committed as a WIP commit first — is kept, so
|
|
/// unarchiving the chat re-creates the worktree exactly. Persisted as the raw seconds value;
|
|
/// `.never` (0) turns cleanup off. Like `AutoArchivePolicy`, the `AppStore` only ever sees the
|
|
/// resolved `interval`, keeping the core unaware of the UI's specific choices.
|
|
enum ArchivedWorktreeCleanupPolicy: Int, CaseIterable, Identifiable {
|
|
case never = 0
|
|
case afterHour = 3600
|
|
case afterDay = 86_400
|
|
case after3Days = 259_200
|
|
case afterWeek = 604_800
|
|
case afterMonth = 2_592_000
|
|
|
|
var id: Int { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .never: "Never"
|
|
case .afterHour: "After 1 hour"
|
|
case .afterDay: "After 1 day"
|
|
case .after3Days: "After 3 days"
|
|
case .afterWeek: "After 1 week"
|
|
case .afterMonth: "After 1 month"
|
|
}
|
|
}
|
|
|
|
/// The bare duration phrase used in explanatory copy ("an hour", "a month").
|
|
var durationPhrase: String {
|
|
switch self {
|
|
case .never: ""
|
|
case .afterHour: "an hour"
|
|
case .afterDay: "a day"
|
|
case .after3Days: "3 days"
|
|
case .afterWeek: "a week"
|
|
case .afterMonth: "a month"
|
|
}
|
|
}
|
|
|
|
/// Threshold in seconds the `AppStore` sweeps against, or nil to disable.
|
|
var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) }
|
|
|
|
static let storageKey = "nucleic.archivedWorktreeCleanupPolicy"
|
|
/// Out of the box, an archived chat's worktree is reclaimed after a day; the user can
|
|
/// shorten it, lengthen it, or pick "Never" in Settings.
|
|
static let fallback = ArchivedWorktreeCleanupPolicy.afterDay
|
|
|
|
static var stored: ArchivedWorktreeCleanupPolicy {
|
|
// No stored key yet → the default behavior; `.never` is a real stored 0.
|
|
guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback }
|
|
return ArchivedWorktreeCleanupPolicy(
|
|
rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback
|
|
}
|
|
}
|