diff --git a/Sources/Halos/Models/LazuliEvent.swift b/Sources/Halos/Models/LazuliEvent.swift index eb61c9d..7c61944 100644 --- a/Sources/Halos/Models/LazuliEvent.swift +++ b/Sources/Halos/Models/LazuliEvent.swift @@ -241,8 +241,11 @@ public enum GatewayStreamFormatter { if lower.hasPrefix("edoras_safari__") || lower.contains("edoras") { return ToolDisplay(name: "Browser Control", category: .plugin) } + if lower.contains("veyra") { + return ToolDisplay(name: "Veyra", category: .plugin) + } if lower.hasPrefix("plugin__") || lower.hasPrefix("plugin_") || lower.hasPrefix("plugin:") { - return ToolDisplay(name: titleizedToolName(raw), category: .plugin) + return ToolDisplay(name: pluginSurfaceName(raw), category: .plugin) } if lower.contains("terminal") || lower == "bash" || lower == "shell" || lower == "exec" { return ToolDisplay(name: "Terminal", category: .tool) @@ -265,11 +268,13 @@ public enum GatewayStreamFormatter { private static func titleizedToolName(_ raw: String) -> String { raw - .replacingOccurrences(of: "plugin__", with: "") - .replacingOccurrences(of: "plugin_", with: "") - .replacingOccurrences(of: "plugin:", with: "") + .replacingOccurrences(of: "plugin__", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "plugin_", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "plugin:", with: "", options: .caseInsensitive) .replacingOccurrences(of: "__", with: " ") .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: ".", with: " ") .split(separator: " ") .map { part in part.prefix(1).uppercased() + part.dropFirst() @@ -277,6 +282,21 @@ public enum GatewayStreamFormatter { .joined(separator: " ") } + private static func pluginSurfaceName(_ raw: String) -> String { + let stripped = raw + .replacingOccurrences(of: "plugin__", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "plugin_", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "plugin:", with: "", options: .caseInsensitive) + let surface = stripped + .components(separatedBy: "__") + .first? + .components(separatedBy: ".") + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let surface, !surface.isEmpty else { return "Plugin" } + return titleizedToolName(surface) + } + public static func toolBody(phase: String?, name: String?, data: [String: Any]) -> String { let display = toolDisplay(name) if display.category == .plugin { @@ -462,14 +482,20 @@ public enum GatewayStreamFormatter { } private static func pluginActionName(_ name: String?) -> String { - let raw = (name ?? "action").lowercased() + let stripped = (name ?? "action").lowercased() .replacingOccurrences(of: "edoras_safari__", with: "") .replacingOccurrences(of: "lazuli_", with: "") + .replacingOccurrences(of: "veyra_", with: "") .replacingOccurrences(of: "plugin__", with: "") .replacingOccurrences(of: "plugin_", with: "") .replacingOccurrences(of: "plugin:", with: "") .replacingOccurrences(of: "safari_", with: "") + let raw = stripped + .components(separatedBy: "__") + .last? + .components(separatedBy: ".") + .last ?? stripped let normalized = raw.replacingOccurrences(of: "-", with: "_") switch normalized { case "click", "click_at", "double_click_at", "triple_click_at": diff --git a/Sources/Halos/Stores/MissionControlStore.swift b/Sources/Halos/Stores/MissionControlStore.swift index 82e40ba..2f3a80d 100644 --- a/Sources/Halos/Stores/MissionControlStore.swift +++ b/Sources/Halos/Stores/MissionControlStore.swift @@ -313,6 +313,10 @@ private struct LocalSessionTitleOverride { let updatedAt: Date } +private struct HalosSessionLocalState: Codable { + var deletedSessionKeys: Set = [] +} + @MainActor public final class MissionControlStore: ObservableObject { public static let halosSessionKey = "agent:main:halos" @@ -337,6 +341,7 @@ public final class MissionControlStore: ObservableObject { private let cronJobsURL: URL private let lazuliPortFileURL: URL private let veyraPortFileURL: URL + private let sessionStateURL: URL private var gatewayTask: URLSessionWebSocketTask? private var lazuliTask: URLSessionWebSocketTask? private var veyraTask: URLSessionWebSocketTask? @@ -363,18 +368,22 @@ public final class MissionControlStore: ObservableObject { private var sessionGeneration = 0 private var didSelectInitialHalosSession = false private var localSessionTitleOverrides: [String: LocalSessionTitleOverride] = [:] + private var deletedSessionKeys: Set = [] public init( openClawConfigURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".openclaw/openclaw.json"), cronJobsURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".openclaw/cron/jobs.json"), lazuliPortFileURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".lazuli/current-port"), - veyraPortFileURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".veyra/current-port") + veyraPortFileURL: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".veyra/current-port"), + sessionStateURL: URL? = nil ) { self.openClawConfigURL = openClawConfigURL self.cronJobsURL = cronJobsURL self.lazuliPortFileURL = lazuliPortFileURL self.veyraPortFileURL = veyraPortFileURL + self.sessionStateURL = sessionStateURL ?? HalosStorage.sessionStateURL self.slashCommands = Self.loadHalosSlashCommands(from: openClawConfigURL) + self.deletedSessionKeys = Self.loadSessionLocalState(from: self.sessionStateURL).deletedSessionKeys } public func start() { @@ -672,6 +681,8 @@ public final class MissionControlStore: ObservableObject { private func deleteHalosSessionLocally(_ key: String) { let remainingSessions = halosSessions.filter { $0.key != key } + deletedSessionKeys.insert(key) + saveSessionLocalState() halosSessions = remainingSessions localSessionTitleOverrides.removeValue(forKey: key) @@ -1288,6 +1299,9 @@ public final class MissionControlStore: ObservableObject { else { return nil } + guard !deletedSessionKeys.contains(key) else { + return nil + } let rawLabel = ((raw["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } let label = rawLabel ?? Self.defaultSessionLabel(for: key) @@ -1344,6 +1358,28 @@ public final class MissionControlStore: ObservableObject { key == Self.halosSessionKey || key.hasPrefix("\(Self.halosSessionKey):") } + private static func loadSessionLocalState(from url: URL) -> HalosSessionLocalState { + guard + let data = try? Data(contentsOf: url), + let state = try? JSONDecoder().decode(HalosSessionLocalState.self, from: data) + else { + return HalosSessionLocalState() + } + return state + } + + private func saveSessionLocalState() { + do { + try HalosStorage.ensureLayout() + let state = HalosSessionLocalState(deletedSessionKeys: deletedSessionKeys) + let data = try JSONEncoder().encode(state) + try FileManager.default.createDirectory(at: sessionStateURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: sessionStateURL, options: [.atomic]) + } catch { + // Session tombstones are a UI safety net; the gateway delete remains authoritative. + } + } + private static func defaultSessionLabel(for key: String) -> String { guard key != halosSessionKey else { return "Halos" } let suffix = key.replacingOccurrences(of: "\(halosSessionKey):", with: "") @@ -2257,12 +2293,15 @@ public final class MissionControlStore: ObservableObject { upsertWorkingSummary(runID: runID, latestSummary: body) } - private func closeToolSummarySegmentIfNeeded(runID: String) { - guard let rollup = runToolRollupsByRunID[runID], rollup.hasVisibleToolActivity else { return } - guard rollup.activeTools.isEmpty else { return } + @discardableResult + private func closeToolSummarySegmentIfNeeded(runID: String) -> Bool { + guard let rollup = runToolRollupsByRunID[runID], rollup.hasVisibleToolActivity else { return false } + guard rollup.activeTools.isEmpty else { return false } finalizeWorkingSummary(runID: runID) runToolRollupsByRunID.removeValue(forKey: runID) workingMessageIDsByRunID.removeValue(forKey: runID) + assistantMessageIDsByRunID.removeValue(forKey: runID) + return true } private func handleGatewayDisconnect() { diff --git a/Sources/Halos/Support/HalosStorage.swift b/Sources/Halos/Support/HalosStorage.swift index a85a1cc..c3e90d0 100644 --- a/Sources/Halos/Support/HalosStorage.swift +++ b/Sources/Halos/Support/HalosStorage.swift @@ -20,6 +20,10 @@ enum HalosStorage { rootURL.appending(path: "State", directoryHint: .isDirectory) } + static var sessionStateURL: URL { + stateURL.appending(path: "sessions.json") + } + static var gatewayURL: URL { stateURL.appending(path: "Gateway", directoryHint: .isDirectory) } diff --git a/Sources/Halos/Views/HalosControlWindowView.swift b/Sources/Halos/Views/HalosControlWindowView.swift index 8d27157..3c5a3fc 100644 --- a/Sources/Halos/Views/HalosControlWindowView.swift +++ b/Sources/Halos/Views/HalosControlWindowView.swift @@ -152,7 +152,7 @@ private struct CollapsingHeaderTitle: View { private var compactWidth: CGFloat { switch title { case "Automations": - return 78 + return 76 case "Settings": return 48 default: @@ -168,7 +168,8 @@ private struct CollapsingHeaderTitle: View { .fixedSize() .scaleEffect(isCompact ? compactScale : 1, anchor: .leading) .frame(width: isCompact ? compactWidth : nil, height: isCompact ? 24 : 29, alignment: .leading) - .matchedGeometryEffect(id: id, in: namespace, properties: .position, anchor: .leading) + .matchedGeometryEffect(id: id, in: namespace, properties: .frame, anchor: .leading) + .animation(.smooth(duration: 0.22), value: isCompact) } } @@ -304,6 +305,7 @@ private struct ClosedSidebarSurface: View { closedBreadcrumb .padding(.leading, 2) } + .frame(height: 29, alignment: .leading) .animation(.smooth(duration: 0.18), value: closedBreadcrumbText) } @@ -1190,9 +1192,10 @@ private struct WorkbenchColumnView: View { draggedMode: $draggedMode, layout: $layout ) + .frame(maxHeight: .infinity) } else if let firstMode = modes.first, modes.count > 1 { GeometryReader { proxy in - let handleHeight: CGFloat = 8 + let handleHeight: CGFloat = 14 let availableHeight = max(0, proxy.size.height - handleHeight) let minimumRatio = min(0.45, 180 / max(availableHeight, 360)) let effectiveRatio = max(minimumRatio, min(1 - minimumRatio, splitRatio)) @@ -1249,6 +1252,7 @@ private struct WorkbenchColumnView: View { .frame(height: secondHeight) } } + .frame(maxHeight: .infinity) } } } @@ -1380,7 +1384,7 @@ private struct WorkbenchHorizontalResizeHandle: View { .background(Color.clear) .overlay { Capsule() - .fill(isHovering ? HalosTheme.secondaryText.opacity(0.62) : HalosTheme.separator.opacity(0.82)) + .fill(isHovering ? HalosTheme.secondaryText.opacity(0.62) : Color.clear) .frame(width: 46, height: 2) } .contentShape(Rectangle()) @@ -2515,6 +2519,8 @@ private enum ComposerFileSearch { private enum ComposerAttachment { static let acceptedTypes: [UTType] = [ .fileURL, + .url, + .plainText, .png, .jpeg, .tiff, @@ -2540,14 +2546,26 @@ private enum ComposerAttachment { static func load(provider: NSItemProvider, completion: @escaping @Sendable (URL) -> Void) { if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in - if let url = item as? URL { + if let url = url(from: item), url.isFileURL { completion(url) return } - if let data = item as? Data, - let url = URL(dataRepresentation: data, relativeTo: nil) { - completion(url) - } + } + return + } + + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in + guard let url = url(from: item), url.isFileURL else { return } + completion(url) + } + return + } + + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in + guard let url = fileURLFromPlainTextItem(item) else { return } + completion(url) } return } @@ -2576,6 +2594,43 @@ private enum ComposerAttachment { ] } + private static func url(from item: NSSecureCoding?) -> URL? { + if let url = item as? URL { + return url + } + if let data = item as? Data { + return URL(dataRepresentation: data, relativeTo: nil) + } + if let string = item as? String { + return URL(string: string) ?? existingFileURL(from: string) + } + return nil + } + + private static func fileURLFromPlainTextItem(_ item: NSSecureCoding?) -> URL? { + if let data = item as? Data, + let string = String(data: data, encoding: .utf8) { + return existingFileURL(from: string) + } + if let string = item as? String { + return existingFileURL(from: string) + } + return nil + } + + private static func existingFileURL(from raw: String) -> URL? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let url: URL + if let parsed = URL(string: trimmed), parsed.isFileURL { + url = parsed + } else { + url = URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath) + } + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return url + } + private static func save(data: Data, contentType: UTType) -> URL? { let directory = HalosStorage.attachmentsURL do { diff --git a/Tests/HalosUITests/GatewayStreamingTests.swift b/Tests/HalosUITests/GatewayStreamingTests.swift index f9cca23..8bcf737 100644 --- a/Tests/HalosUITests/GatewayStreamingTests.swift +++ b/Tests/HalosUITests/GatewayStreamingTests.swift @@ -292,6 +292,7 @@ final class GatewayStreamingTests: XCTestCase { var summary = store.messages.first(where: { $0.kind == .workSummary }) XCTAssertEqual(summary?.title, "Using Browser Control") XCTAssertEqual(summary?.body.contains("Opening"), true) + XCTAssertEqual(summary?.streamState, .streaming) store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-edoras", name: "edoras_safari__open_url")) summary = store.messages.first(where: { $0.kind == .workSummary }) @@ -307,6 +308,36 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertEqual(summary?.body.contains("Browser Control completed"), false) } + func testPluginPanelsCloseBeforeFinalAssistantText() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "I’ll check the browser.")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-browser", name: "plugin__browser_control__open_url")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-browser", name: "plugin__browser_control__open_url")) + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "final", text: "Done. The page is open.")) + + let rows = store.messages.map { ($0.kind, $0.title, $0.body, $0.streamState) } + XCTAssertEqual(rows.count, 3) + XCTAssertEqual(rows[0].0, .assistant) + XCTAssertEqual(rows[0].2, "I’ll check the browser.") + XCTAssertEqual(rows[1].0, .workSummary) + XCTAssertEqual(rows[1].1, "Browser Control ended") + XCTAssertEqual(rows[1].2.contains("Opened"), true) + XCTAssertEqual(rows[1].2.contains("completed"), false) + XCTAssertEqual(rows[1].3, .final) + XCTAssertEqual(rows[2].0, .assistant) + XCTAssertEqual(rows[2].2, "Done. The page is open.") + XCTAssertEqual(rows[2].3, .final) + } + + func testFuturePluginNamesUseSurfaceAndActionCopy() { + XCTAssertEqual(GatewayStreamFormatter.toolDisplay("plugin__browser_control__open_url").name, "Browser Control") + XCTAssertEqual(GatewayStreamFormatter.toolBody(phase: "start", name: "plugin__browser_control__open_url", data: [:]), "Opening") + XCTAssertEqual(GatewayStreamFormatter.toolBody(phase: "result", name: "plugin__browser_control__open_url", data: [:]), "Opened") + XCTAssertEqual(GatewayStreamFormatter.toolDisplay("plugin:veyra.publish").name, "Veyra") + XCTAssertEqual(GatewayStreamFormatter.toolDisplay("plugin:future_writer.compose").name, "Future Writer") + } + func testVeyraApprovalEventsRenderAndUpdateOneCard() { let store = makeStore() @@ -615,6 +646,41 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertTrue(store.isCodeSessionBrowserPresented) } + func testDeletedHalosSessionStaysHiddenAfterReload() { + let sessionStateURL = FileManager.default.temporaryDirectory + .appending(path: "halos-session-state-\(UUID().uuidString).json") + let deleted = "\(MissionControlStore.halosSessionKey):deleted" + let kept = "\(MissionControlStore.halosSessionKey):kept" + let now = Date().timeIntervalSince1970 * 1000 + let payload: [String: Any] = [ + "sessions": [ + [ + "key": deleted, + "label": "Deleted", + "status": "idle", + "lastUserMessagePreview": "Deleted message", + "updatedAt": now, + ], + [ + "key": kept, + "label": "Kept", + "status": "idle", + "lastUserMessagePreview": "Kept message", + "updatedAt": now - 1_000, + ], + ] + ] + let firstStore = makeStore(sessionStateURL: sessionStateURL) + firstStore.loadHalosSessionsForTesting(payload: payload) + + firstStore.deleteHalosSessionLocallyForTesting(deleted) + + let reloadedStore = makeStore(sessionStateURL: sessionStateURL) + reloadedStore.loadHalosSessionsForTesting(payload: payload) + + XCTAssertEqual(reloadedStore.halosSessions.map(\.key), [kept]) + } + func testSlashCommandResultStaysOutOfTranscript() { let store = makeStore() let command = SlashCommand(command: "reasoning", description: "Show reasoning mode") @@ -681,12 +747,14 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertNil(store.slashCommandPanelState) } - private func makeStore() -> MissionControlStore { + private func makeStore(sessionStateURL: URL? = nil) -> MissionControlStore { MissionControlStore( openClawConfigURL: URL(fileURLWithPath: "/tmp/missing-openclaw.json"), cronJobsURL: URL(fileURLWithPath: "/tmp/missing-cron.json"), lazuliPortFileURL: URL(fileURLWithPath: "/tmp/missing-lazuli-port"), - veyraPortFileURL: URL(fileURLWithPath: "/tmp/missing-veyra-port") + veyraPortFileURL: URL(fileURLWithPath: "/tmp/missing-veyra-port"), + sessionStateURL: sessionStateURL ?? FileManager.default.temporaryDirectory + .appending(path: "halos-session-state-\(UUID().uuidString).json") ) } diff --git a/assets/AppIcon.icns b/assets/AppIcon.icns index cf9d971..7e9276c 100644 Binary files a/assets/AppIcon.icns and b/assets/AppIcon.icns differ diff --git a/assets/AppIcon.icon/Assets/halos-dots.png b/assets/AppIcon.icon/Assets/halos-dots.png index 5c9a1b3..3cef241 100644 Binary files a/assets/AppIcon.icon/Assets/halos-dots.png and b/assets/AppIcon.icon/Assets/halos-dots.png differ diff --git a/assets/AppIcon.iconset/icon_128x128.png b/assets/AppIcon.iconset/icon_128x128.png index 3e26f52..64a6558 100644 Binary files a/assets/AppIcon.iconset/icon_128x128.png and b/assets/AppIcon.iconset/icon_128x128.png differ diff --git a/assets/AppIcon.iconset/icon_128x128@2x.png b/assets/AppIcon.iconset/icon_128x128@2x.png index 012dc18..0982ac6 100644 Binary files a/assets/AppIcon.iconset/icon_128x128@2x.png and b/assets/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/assets/AppIcon.iconset/icon_16x16.png b/assets/AppIcon.iconset/icon_16x16.png index bb57156..043ed7b 100644 Binary files a/assets/AppIcon.iconset/icon_16x16.png and b/assets/AppIcon.iconset/icon_16x16.png differ diff --git a/assets/AppIcon.iconset/icon_16x16@2x.png b/assets/AppIcon.iconset/icon_16x16@2x.png index 8241d47..8f4d061 100644 Binary files a/assets/AppIcon.iconset/icon_16x16@2x.png and b/assets/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/assets/AppIcon.iconset/icon_256x256.png b/assets/AppIcon.iconset/icon_256x256.png index 012dc18..0982ac6 100644 Binary files a/assets/AppIcon.iconset/icon_256x256.png and b/assets/AppIcon.iconset/icon_256x256.png differ diff --git a/assets/AppIcon.iconset/icon_256x256@2x.png b/assets/AppIcon.iconset/icon_256x256@2x.png index b08bdb1..ab5f370 100644 Binary files a/assets/AppIcon.iconset/icon_256x256@2x.png and b/assets/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/assets/AppIcon.iconset/icon_32x32.png b/assets/AppIcon.iconset/icon_32x32.png index 8241d47..8f4d061 100644 Binary files a/assets/AppIcon.iconset/icon_32x32.png and b/assets/AppIcon.iconset/icon_32x32.png differ diff --git a/assets/AppIcon.iconset/icon_32x32@2x.png b/assets/AppIcon.iconset/icon_32x32@2x.png index 7c18ebb..82ca616 100644 Binary files a/assets/AppIcon.iconset/icon_32x32@2x.png and b/assets/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/assets/AppIcon.iconset/icon_512x512.png b/assets/AppIcon.iconset/icon_512x512.png index b08bdb1..ab5f370 100644 Binary files a/assets/AppIcon.iconset/icon_512x512.png and b/assets/AppIcon.iconset/icon_512x512.png differ diff --git a/assets/AppIcon.iconset/icon_512x512@2x.png b/assets/AppIcon.iconset/icon_512x512@2x.png index 82770c2..f61373e 100644 Binary files a/assets/AppIcon.iconset/icon_512x512@2x.png and b/assets/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/script/generate_app_icon.swift b/script/generate_app_icon.swift index 6c47689..9669152 100755 --- a/script/generate_app_icon.swift +++ b/script/generate_app_icon.swift @@ -33,13 +33,13 @@ let markStep: CGFloat = 220 let markRadius: CGFloat = 82 let dotPalette: [NSColor] = [ - NSColor(calibratedRed: 0.270, green: 0.110, blue: 0.620, alpha: 1.0), - NSColor(calibratedRed: 0.330, green: 0.160, blue: 0.760, alpha: 1.0), - NSColor(calibratedRed: 0.200, green: 0.270, blue: 0.860, alpha: 1.0), - NSColor(calibratedRed: 0.365, green: 0.300, blue: 0.940, alpha: 1.0), - NSColor(calibratedRed: 0.285, green: 0.480, blue: 0.980, alpha: 1.0), - NSColor(calibratedRed: 0.520, green: 0.440, blue: 1.000, alpha: 1.0), - NSColor(calibratedRed: 0.630, green: 0.650, blue: 1.000, alpha: 1.0), + NSColor(calibratedRed: 0.500, green: 0.105, blue: 0.090, alpha: 1.0), + NSColor(calibratedRed: 0.650, green: 0.150, blue: 0.115, alpha: 1.0), + NSColor(calibratedRed: 0.790, green: 0.220, blue: 0.160, alpha: 1.0), + NSColor(calibratedRed: 0.900, green: 0.320, blue: 0.220, alpha: 1.0), + NSColor(calibratedRed: 0.980, green: 0.455, blue: 0.330, alpha: 1.0), + NSColor(calibratedRed: 1.000, green: 0.570, blue: 0.445, alpha: 1.0), + NSColor(calibratedRed: 1.000, green: 0.690, blue: 0.590, alpha: 1.0), ] func makeBitmap(pixels: Int) throws -> NSBitmapImageRep {