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
13 changes: 13 additions & 0 deletions apps/macos/App/HackDesktopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Sparkle
struct HackDesktopApp: App {
@State private var model = DashboardModel(client: HackCLIClient())
@State private var didSyncBundledCLI = false
@AppStorage("hackDesktop.preferences.theme") private var appearanceThemeRaw = "system"

#if RELEASE
private let updaterController = SPUStandardUpdaterController(
Expand All @@ -27,6 +28,7 @@ struct HackDesktopApp: App {
var body: some Scene {
WindowGroup {
DashboardView()
.preferredColorScheme(preferredColorScheme)
.environment(model)
#if RELEASE
.task {
Expand Down Expand Up @@ -54,6 +56,17 @@ struct HackDesktopApp: App {
#endif
}

private var preferredColorScheme: ColorScheme? {
switch appearanceThemeRaw {
case "light":
return .light
case "dark":
return .dark
default:
return nil
}
}

@MainActor
private func syncBundledCLIIfNeeded() async {
if didSyncBundledCLI {
Expand Down
41 changes: 41 additions & 0 deletions apps/macos/Package.resolved

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

12 changes: 11 additions & 1 deletion apps/macos/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ let package = Package(
.library(name: "GhosttyTerminal", targets: ["GhosttyTerminal"]),
.library(name: "DashboardFeature", targets: ["DashboardFeature"])
],
dependencies: [
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"),
.package(url: "https://github.com/raspu/Highlightr.git", from: "2.3.0")
],
targets: [
.target(
name: "HackDesktopModels",
Expand All @@ -26,7 +30,13 @@ let package = Package(
),
.target(
name: "DashboardFeature",
dependencies: ["HackCLIService", "HackDesktopModels", "GhosttyTerminal"],
dependencies: [
"HackCLIService",
"HackDesktopModels",
"GhosttyTerminal",
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "Highlightr", package: "Highlightr")
],
path: "Packages/Features/DashboardFeature/Sources/DashboardFeature"
),
.testTarget(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,32 +113,14 @@ extension View {
}
}

/// Adaptive window background - transparent on macOS 26+
@ViewBuilder
/// Adaptive window background with transparent toolbar chrome on macOS 26+
func adaptiveWindowBackground() -> some View {
if #available(macOS 26, *) {
self
.background(.clear)
// Keep the toolbar visible, but visually clear. We tune the underlying NSToolbar to avoid
// per-item "pill" backplates (see WindowToolbarTuner).
.toolbarBackground(.clear, for: .windowToolbar)
.toolbarBackgroundVisibility(.visible, for: .windowToolbar)
} else {
self
.background(.ultraThinMaterial)
.toolbarBackground(.ultraThinMaterial, for: .windowToolbar)
.toolbarBackground(.visible, for: .windowToolbar)
}
modifier(AdaptiveWindowBackgroundModifier())
}

/// Adaptive detail view background
@ViewBuilder
func adaptiveDetailBackground() -> some View {
if #available(macOS 26, *) {
self.background(.regularMaterial)
} else {
self.background(.ultraThinMaterial)
}
modifier(AdaptiveDetailBackgroundModifier())
}

/// Adaptive sidebar background
Expand All @@ -147,11 +129,19 @@ extension View {
if #available(macOS 26, *) {
self
.scrollContentBackground(.hidden)
.background(.regularMaterial)
.background(
Rectangle()
.fill(.regularMaterial)
.ignoresSafeArea(edges: .top)
)
} else {
self
.scrollContentBackground(.hidden)
.background(.ultraThinMaterial)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea(edges: .top)
)
}
}

Expand Down Expand Up @@ -187,6 +177,76 @@ extension View {
}
}

private struct AdaptiveWindowBackgroundModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme

func body(content: Content) -> some View {
if #available(macOS 26, *) {
content
.background(
ZStack {
Rectangle()
.fill(baseColor)
Rectangle()
.fill(.ultraThinMaterial)
.opacity(materialOpacity)
}
)
// Keep the toolbar visible, but visually clear. We tune the underlying NSToolbar to avoid
// per-item "pill" backplates (see WindowToolbarTuner).
.toolbarBackground(.clear, for: .windowToolbar)
.toolbarBackgroundVisibility(.visible, for: .windowToolbar)
} else {
content
.background(baseColor)
.toolbarBackground(.ultraThinMaterial, for: .windowToolbar)
.toolbarBackground(.visible, for: .windowToolbar)
}
}

private var baseColor: Color {
colorScheme == .dark ? .black : .white
}

private var materialOpacity: Double {
colorScheme == .dark ? 0.24 : 0.4
}
}

private struct AdaptiveDetailBackgroundModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme

func body(content: Content) -> some View {
if #available(macOS 26, *) {
content.background(
ZStack {
Rectangle()
.fill(baseColor)
Rectangle()
.fill(.regularMaterial)
.opacity(materialOpacity)
Rectangle()
.fill(edgeTint)
}
)
} else {
content.background(baseColor)
}
}

private var baseColor: Color {
colorScheme == .dark ? .black : .white
}

private var materialOpacity: Double {
colorScheme == .dark ? 0.2 : 0.3
}

private var edgeTint: Color {
colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.02)
}
}

struct PressableIconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import Foundation

enum CodingAgentIntegration {
enum AgentApp: String, CaseIterable, Identifiable {
case codex
case cursor
case gemini
case opencode

var id: String { rawValue }

var displayName: String {
switch self {
case .codex:
return "Codex"
case .cursor:
return "Cursor"
case .gemini:
return "Gemini CLI"
case .opencode:
return "OpenCode"
}
}

var executableCandidates: [String] {
switch self {
case .codex:
return ["codex"]
case .cursor:
return ["cursor-agent", "cursor"]
case .gemini:
return ["gemini", "gemini-cli"]
case .opencode:
return ["opencode"]
}
}
}

static func installedAgents() -> [AgentApp] {
AgentApp.allCases.filter { resolvedBinaryPath(for: $0, overridePath: nil) != nil }
}

static func resolvedBinaryPath(for agent: AgentApp, overridePath: String?) -> String? {
let normalizedOverride = overridePath?.trimmingCharacters(in: .whitespacesAndNewlines)
if let normalizedOverride, !normalizedOverride.isEmpty {
return normalizedOverride
}
let fileManager = FileManager.default
let envPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
for entry in envPath.split(separator: ":") {
for candidate in agent.executableCandidates {
let path = "\(entry)/\(candidate)"
if fileManager.isExecutableFile(atPath: path) {
return path
}
}
}

let fallbackDirectories = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
for directory in fallbackDirectories {
for candidate in agent.executableCandidates {
let path = "\(directory)/\(candidate)"
if fileManager.isExecutableFile(atPath: path) {
return path
}
}
}

return nil
}

static func launchCommand(
projectPath: String,
agent: AgentApp,
binaryOverridePath: String?
) -> String {
let quotedPath = shellQuote(projectPath)
let resolvedBinary = resolvedBinaryPath(for: agent, overridePath: binaryOverridePath)
let fallbackBinary = agent.executableCandidates.first ?? agent.rawValue
let quotedBinary = shellQuote(resolvedBinary ?? fallbackBinary)
return "cd \(quotedPath) && \(quotedBinary)"
}

private static func shellQuote(_ value: String) -> String {
if value.isEmpty {
return "''"
}
return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
}
}
Loading
Loading