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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/mac/Sources/SurgeCodeMac/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ struct SurgeCodeApp: App {
}
.keyboardShortcut("n", modifiers: .command)
}
CommandMenu("Workspace") {
Button("Worktree Control Center…") {
WorktreeControlCenterWindowController.shared.show(multi: multi)
}
.keyboardShortcut("w", modifiers: [.command, .shift])
}
CommandMenu("Navigate") {
// Matches the shared `commandPalette.toggle` identifier's
// default binding without registering the shortcut in a
Expand Down
20 changes: 20 additions & 0 deletions apps/mac/Sources/SurgeCodeMac/Model/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2459,6 +2459,10 @@ public final class AppModel {
}
}

public func usageSummary(_ query: AppUsageQuery) async throws -> AppUsageSummary {
try await backend.usageSummary(query)
}

public func discoverExtensions() async throws -> ExtensionDiscoveryResult {
try await backend.discoverExtensions()
}
Expand Down Expand Up @@ -2725,6 +2729,22 @@ public final class AppModel {
}
}

public func worktreeSnapshots() async -> [WorktreeControlSnapshot] {
await backend.worktreeSnapshots()
}

public func createWorktree(threadID: String) async throws {
try await backend.createWorktree(threadID: threadID)
}

public func repairWorktree(threadID: String) async throws {
try await backend.repairWorktree(threadID: threadID)
}

public func removeWorktree(threadID: String, force: Bool) async throws {
try await backend.removeWorktree(threadID: threadID, force: force)
}

/// Page size for the Archive settings tab.
static let archivedThreadsPageSize = 50

Expand Down
32 changes: 32 additions & 0 deletions apps/mac/Sources/SurgeCodeMac/Model/BackendService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ public protocol BackendService: Sendable {
threadID: String, action: GitAction, commitMessage: String?
) async throws -> GitActionOutcome

/// Thread-owned worktrees only. Inventory is derived from each thread's
/// persisted worktreePath; there is intentionally no host-wide scan.
func worktreeSnapshots() async -> [WorktreeControlSnapshot]
func createWorktree(threadID: String) async throws
func repairWorktree(threadID: String) async throws
func removeWorktree(threadID: String, force: Bool) async throws

// Mobile (iPhone) pairing — Settings ▸ iPhone.

/// Whether the running sidecar is bound beyond loopback, i.e. reachable
Expand All @@ -217,6 +224,8 @@ public protocol BackendService: Sendable {
func settings() async throws -> AppSettings
/// Applies the full editable subset as a patch; returns the merged result.
func updateSettings(_ settings: AppSettings) async throws -> AppSettings
/// Aggregated provider usage for the requested window and optional filters.
func usageSummary(_ query: AppUsageQuery) async throws -> AppUsageSummary
func keybindings() async throws -> [ResolvedKeybindingRule]
func upsertKeybinding(
_ rule: KeybindingRuleInput, replacing: KeybindingRuleInput?
Expand Down Expand Up @@ -283,6 +292,29 @@ public extension BackendService {
/// Default for conformers without one-shot VCS refresh (test fakes).
func refreshVcsStatus(threadID: String) async throws {}

func worktreeSnapshots() async -> [WorktreeControlSnapshot] { [] }

func createWorktree(threadID: String) async throws {
throw BackendServiceError.unsupportedOperation("Worktree creation is unavailable.")
}

func repairWorktree(threadID: String) async throws {
throw BackendServiceError.unsupportedOperation("Worktree repair is unavailable.")
}

func removeWorktree(threadID: String, force: Bool) async throws {
throw BackendServiceError.unsupportedOperation("Worktree removal is unavailable.")
}

func usageSummary(_ query: AppUsageQuery) async throws -> AppUsageSummary {
AppUsageSummary(
turns: 0,
tokens: AppUsageTokens(
processed: 0, input: 0, uncachedInput: 0, cachedInput: 0,
cacheCreationInput: 0, cacheReadInput: 0, output: 0, reasoningOutput: 0),
costUsd: nil, costIsPartial: false, byProvider: [], byModel: [])
}

func cloneRepository(
provider: SourceControlProviderKind?, repository: String?, remoteURL: String?,
destinationPath: String, protocol cloneProtocol: SourceControlCloneProtocol?
Expand Down
143 changes: 143 additions & 0 deletions apps/mac/Sources/SurgeCodeMac/Model/Entities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ public struct ChatThread: Identifiable, Hashable, Sendable {
/// Parent thread id when this is a nested sub-agent session; nil for
/// top-level threads (and for historical rows that predate the field).
public var parentThreadId: String?
/// Git branch persisted with the thread. A nil value means the project
/// checkout's current branch is authoritative.
public var branch: String?
/// Server turn liveness (`session.health`); `nil` until the server reports
/// it. Stalled and fresh-active signals override the client-side heuristic.
public var health: ThreadHealth?
Expand Down Expand Up @@ -359,6 +362,7 @@ public struct ChatThread: Identifiable, Hashable, Sendable {
executorMaxSubAgents: Int = ChatThread.defaultExecutorMaxSubAgents,
reasoningEffort: String? = nil, serviceTier: String? = nil,
backgroundAgentCount: Int = 0, parentThreadId: String? = nil,
branch: String? = nil,
health: ThreadHealth? = nil
) {
self.id = id
Expand Down Expand Up @@ -395,6 +399,7 @@ public struct ChatThread: Identifiable, Hashable, Sendable {
self.serviceTier = serviceTier
self.backgroundAgentCount = backgroundAgentCount
self.parentThreadId = parentThreadId
self.branch = branch
self.health = health
}

Expand Down Expand Up @@ -433,10 +438,54 @@ public struct ChatThread: Identifiable, Hashable, Sendable {
&& serviceTier == other.serviceTier
&& backgroundAgentCount == other.backgroundAgentCount
&& parentThreadId == other.parentThreadId
&& branch == other.branch
&& worktreePath == other.worktreePath
&& health == other.health
}
}

public enum WorktreeSetupStatus: String, Sendable {
case ready
case repairNeeded
}

public struct WorktreeControlSnapshot: Identifiable, Sendable {
public var threadID: String
public var threadTitle: String
public var threadStatus: ThreadStatus
public var projectID: String
public var projectName: String
public var projectPath: String
public var branch: String
public var path: String
/// nil means status could not be read; callers must treat it as unsafe,
/// never as clean.
public var isDirty: Bool?
public var diskUsageBytes: Int64
public var setupStatus: WorktreeSetupStatus

public var id: String { threadID }

public init(
threadID: String, threadTitle: String, threadStatus: ThreadStatus,
projectID: String, projectName: String, projectPath: String, branch: String,
path: String, isDirty: Bool?,
diskUsageBytes: Int64, setupStatus: WorktreeSetupStatus
) {
self.threadID = threadID
self.threadTitle = threadTitle
self.threadStatus = threadStatus
self.projectID = projectID
self.projectName = projectName
self.projectPath = projectPath
self.branch = branch
self.path = path
self.isDirty = isDirty
self.diskUsageBytes = diskUsageBytes
self.setupStatus = setupStatus
}
}

public enum ToolEventStatus: String, Sendable {
case running, succeeded, failed
}
Expand Down Expand Up @@ -1807,6 +1856,97 @@ public struct AppWorkflowModelRouting: Hashable, Sendable {
}
}

public enum AppEfficiencyProfile: String, CaseIterable, Hashable, Sendable, Identifiable {
case manual, balanced
case costSaver = "cost_saver"

public var id: String { rawValue }

public var title: String {
switch self {
case .manual: "Manual"
case .balanced: "Balanced"
case .costSaver: "Cost Saver"
}
}
}

public struct AppTokenEfficiencySettings: Hashable, Sendable {
public var showComposerHints: Bool
public var showCostWarnings: Bool
public var profile: AppEfficiencyProfile
public var perTurnWarningUsd: Double?
public var dailyWarningUsd: Double?
public var monthlyWarningUsd: Double?

public init(
showComposerHints: Bool = true, showCostWarnings: Bool = true,
profile: AppEfficiencyProfile = .manual, perTurnWarningUsd: Double? = nil,
dailyWarningUsd: Double? = nil, monthlyWarningUsd: Double? = nil
) {
self.showComposerHints = showComposerHints
self.showCostWarnings = showCostWarnings
self.profile = profile
self.perTurnWarningUsd = perTurnWarningUsd
self.dailyWarningUsd = dailyWarningUsd
self.monthlyWarningUsd = monthlyWarningUsd
}
}

public struct AppUsageQuery: Hashable, Sendable {
public var since: Date
public var until: Date?
public var projectID: String?
public var providerInstanceID: String?
public var threadID: String?

public init(
since: Date, until: Date? = nil, projectID: String? = nil,
providerInstanceID: String? = nil, threadID: String? = nil
) {
self.since = since
self.until = until
self.projectID = projectID
self.providerInstanceID = providerInstanceID
self.threadID = threadID
}
}

public struct AppUsageTokens: Hashable, Sendable {
public var processed: Int
public var input: Int
public var uncachedInput: Int
public var cachedInput: Int
public var cacheCreationInput: Int
public var cacheReadInput: Int
public var output: Int
public var reasoningOutput: Int

public var cacheEfficiency: Double? {
let reusableInput = uncachedInput + cachedInput
guard reusableInput > 0 else { return nil }
return Double(cachedInput) / Double(reusableInput)
}
}

public struct AppUsageBucket: Identifiable, Hashable, Sendable {
public var id: String
public var label: String
public var turns: Int
public var tokens: AppUsageTokens
public var costUsd: Double?
public var costIsPartial: Bool
}

public struct AppUsageSummary: Hashable, Sendable {
public var turns: Int
public var tokens: AppUsageTokens
public var costUsd: Double?
public var costIsPartial: Bool
public var byProvider: [AppUsageBucket]
public var byModel: [AppUsageBucket]
}

public struct AppSettings: Hashable, Sendable {
public var assistantStreaming: Bool
public var providerUpdateChecks: Bool
Expand All @@ -1816,6 +1956,7 @@ public struct AppSettings: Hashable, Sendable {
public var addProjectBaseDirectory: String
public var workflowModelRouting: AppWorkflowModelRouting
public var autoReview: AppAutoReviewSettings
public var tokenEfficiency: AppTokenEfficiencySettings
/// Milliseconds a settled thread may sit before the server auto-archives
/// it. `nil` disables auto-archiving.
public var autoArchiveSettledAfterMs: Double?
Expand All @@ -1826,6 +1967,7 @@ public struct AppSettings: Hashable, Sendable {
computerUseEnabled: Bool = false,
workflowModelRouting: AppWorkflowModelRouting = AppWorkflowModelRouting(),
autoReview: AppAutoReviewSettings = AppAutoReviewSettings(),
tokenEfficiency: AppTokenEfficiencySettings = AppTokenEfficiencySettings(),
autoArchiveSettledAfterMs: Double? = nil
) {
self.assistantStreaming = assistantStreaming
Expand All @@ -1836,6 +1978,7 @@ public struct AppSettings: Hashable, Sendable {
self.addProjectBaseDirectory = addProjectBaseDirectory
self.workflowModelRouting = workflowModelRouting

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: AppSettings adds new field tokenEfficiency with non-optional default. Verify JSON decoding/encoding round-trips correctly when the field is missing from persisted settings (e.g., old app versions). The default initializer provides AppTokenEfficiencySettings(), which should work if Codable is synthesized, but confirm integration tests cover settings load/save cycle.

self.autoReview = autoReview
self.tokenEfficiency = tokenEfficiency
self.autoArchiveSettledAfterMs = autoArchiveSettledAfterMs
}
}
Expand Down
Loading