diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 89f1691..18e71f9 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -6,7 +6,7 @@ "Quit Signal" = "Quit Signal"; "Signal – Status Light" = "Signal – Status Light"; "Status Control" = "Status Control"; -"Claude History" = "Claude History"; +"AI History" = "AI History"; "Breathing Effect" = "Breathing Effect"; "No conversation history" = "No conversation history"; "Loading..." = "Loading..."; @@ -27,4 +27,8 @@ "Could not locate the 'sgnl' binary inside the application bundle resources." = "Could not locate the 'sgnl' binary inside the application bundle resources."; "An error occurred during installation:\n%@" = "An error occurred during installation:\n%@"; "OK" = "OK"; - +"Screen Flash Effect" = "Screen Flash Effect"; +"Gaming-style overlay on color change" = "Gaming-style overlay on color change"; +"1 Flash" = "1 Flash"; +"3 Flashes" = "3 Flashes"; +"Continuous" = "Continuous"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index f183d46..0122178 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -6,7 +6,7 @@ "Quit Signal" = "退出"; "Signal – Status Light" = "Signal – 状态指示灯"; "Status Control" = "状态控制"; -"Claude History" = "Claude 历史"; +"AI History" = "AI 历史"; "Breathing Effect" = "呼吸效果"; "No conversation history" = "暂无对话历史"; "Loading..." = "加载中..."; @@ -27,4 +27,8 @@ "Could not locate the 'sgnl' binary inside the application bundle resources." = "无法在应用包资源中找到 'sgnl' 二进制文件。"; "An error occurred during installation:\n%@" = "安装过程中发生错误:\n%@"; "OK" = "确定"; - +"Screen Flash Effect" = "屏幕闪烁效果"; +"Gaming-style overlay on color change" = "颜色改变时显示游戏风格的屏幕边缘覆层"; +"1 Flash" = "闪烁 1 次"; +"3 Flashes" = "闪烁 3 次"; +"Continuous" = "持续呼吸"; diff --git a/Sources/Signal/AppDelegate.swift b/Sources/Signal/AppDelegate.swift index 3429349..b756d4f 100644 --- a/Sources/Signal/AppDelegate.swift +++ b/Sources/Signal/AppDelegate.swift @@ -30,6 +30,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { applyIcon() setupPopover() + // Start screen overlay manager + ScreenOverlayManager.shared.start() + // Listen for color-change commands from sgnl DistributedNotificationCenter.default().addObserver( self, @@ -55,6 +58,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func appearanceDidChange() { applyIcon() + popover?.appearance = NSApp.effectiveAppearance } // MARK: Popover Setup @@ -62,6 +66,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func setupPopover() { popover = NSPopover() popover.behavior = .transient + popover.appearance = NSApp.effectiveAppearance let hostingController = NSHostingController(rootView: MainView(viewModel: viewModel)) hostingController.preferredContentSize = NSSize(width: 350, height: 480) @@ -83,6 +88,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { viewModel.onColorChange = { [weak self] color in self?.switchColor(to: color) } + viewModel.onFlashModeChange = { [weak self] mode in + guard let self = self else { return } + if mode != .off { + ScreenOverlayManager.shared.triggerFlash(color: self.currentColor, mode: mode) + } else { + ScreenOverlayManager.shared.triggerFlash(color: .black, mode: .off) + } + } viewModel.onBreathingChange = { [weak self] enabled in guard let self = self else { return } if self.breathingEnabled != enabled { @@ -162,7 +175,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { try fileManager.copyItem(at: sourceURL, to: targetURL) // Make executable (chmod +x) - chmod(targetURL.path, 0o755) + if chmod(targetURL.path, 0o755) != 0 { + print("Warning: failed to chmod target binary at \(targetURL.path)") + } // Show success alert let alert = NSAlert() @@ -190,20 +205,26 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: Color Switching - private func switchColor(to newColor: LightColor) { - guard newColor != currentColor else { return } - currentColor = newColor - viewModel.currentColor = newColor - applyIcon() + private func switchColor(to newColor: LightColor, forceFlash: Bool = false) { + let changed = (newColor != currentColor) + if changed { + currentColor = newColor + viewModel.currentColor = newColor + applyIcon() + } + + if changed || forceFlash { + ScreenOverlayManager.shared.triggerFlash(color: newColor, mode: viewModel.screenFlashMode) + } } - @objc private func toggleBreathing() { + private func toggleBreathing() { breathingEnabled.toggle() viewModel.breathingEnabled = breathingEnabled applyIcon() } - // MARK: Distributed Notification Handler + // MARK: - Distributed Notification Handler @objc private func didReceiveColorChange(_ notification: Notification) { guard let userInfo = notification.userInfo, @@ -212,7 +233,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { return } DispatchQueue.main.async { [weak self] in - self?.switchColor(to: color) + self?.switchColor(to: color, forceFlash: true) } } diff --git a/Sources/Signal/Models/ClaudeHistoryModel.swift b/Sources/Signal/Models/ClaudeHistoryModel.swift index 8768bde..801d8eb 100644 --- a/Sources/Signal/Models/ClaudeHistoryModel.swift +++ b/Sources/Signal/Models/ClaudeHistoryModel.swift @@ -1,15 +1,28 @@ import Foundation struct HistoryLine: Codable, Identifiable, Hashable { - var id: String { sessionId + "-\(timestamp)" } + var id: String { + let hash = display.utf8.reduce(5381) { ($0 << 5) &+ $0 &+ Int($1) } + return "\(sessionId)-\(timestamp)-\(hash)" + } let display: String let timestamp: Int64 let project: String let sessionId: String } -struct ClaudeSession: Identifiable, Hashable { +enum ToolType: String, Codable, CaseIterable, Identifiable { + case claude = "Claude" + case openCode = "OpenCode" + case pi = "Pi" + case trae = "Trae" + + var id: String { self.rawValue } +} + +struct HistorySession: Identifiable, Hashable { var id: String { sessionId } + let toolType: ToolType let sessionId: String let projectPath: String let projectName: String @@ -17,8 +30,628 @@ struct ClaudeSession: Identifiable, Hashable { let prompts: [HistoryLine] } -class ClaudeHistoryLoader { - static func loadHistory(completion: @escaping ([ClaudeSession]) -> Void) { +typealias ClaudeSession = HistorySession + +struct AssistantResponse: Codable, Hashable { + let thinking: String? + let text: String +} + +struct SessionJSONLLine: Codable { + let type: String + let timestamp: String? + let message: SessionMessage? +} + +struct SessionMessage: Codable { + let id: String? + let role: String? + let content: SessionContent? +} + +enum SessionContent: Codable { + case string(String) + case array([SessionContentBlock]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let str = try? container.decode(String.self) { + self = .string(str) + } else if let arr = try? container.decode([SessionContentBlock].self) { + self = .array(arr) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid content format") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let str): + try container.encode(str) + case .array(let arr): + try container.encode(arr) + } + } +} + +struct SessionContentBlock: Codable { + let type: String + let text: String? + let thinking: String? +} + +enum ClaudeHistoryLoader { + static func loadPiHistory(completion: @escaping ([HistorySession]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let sessionsURL = home.appendingPathComponent(".pi/agent/sessions") + + guard FileManager.default.fileExists(atPath: sessionsURL.path) else { + DispatchQueue.main.async { completion([]) } + return + } + + var sessions: [HistorySession] = [] + let fileManager = FileManager.default + let decoder = JSONDecoder() + + struct PiSessionStart: Codable { + let type: String + let id: String + let timestamp: String + let cwd: String + } + + struct PiLine: Codable { + let type: String + let message: PiMessage? + } + + struct PiMessage: Codable { + let role: String + let content: SessionContent? + let timestamp: Int64? + } + + if let projectDirs = try? fileManager.contentsOfDirectory(at: sessionsURL, includingPropertiesForKeys: nil) { + for projectDir in projectDirs { + var isDir: ObjCBool = false + if fileManager.fileExists(atPath: projectDir.path, isDirectory: &isDir), isDir.boolValue { + if let files = try? fileManager.contentsOfDirectory(at: projectDir, includingPropertiesForKeys: nil) { + for file in files where file.pathExtension == "jsonl" { + do { + let content = try String(contentsOf: file, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + guard let firstLine = lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !firstLine.isEmpty, + let firstData = firstLine.data(using: .utf8), + let sessionStart = try? decoder.decode(PiSessionStart.self, from: firstData) else { + continue + } + + let sessionId = sessionStart.id + let projectPath = sessionStart.cwd + let projectName = URL(fileURLWithPath: projectPath).lastPathComponent + + let attrs = try? fileManager.attributesOfItem(atPath: file.path) + let creationDate = attrs?[.creationDate] as? Date ?? Date() + let fallbackTimestamp = Int64(creationDate.timeIntervalSince1970 * 1000) + + var prompts: [HistoryLine] = [] + + for line in lines.dropFirst() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + guard let lineData = trimmed.data(using: .utf8), + let parsedLine = try? decoder.decode(PiLine.self, from: lineData) else { + continue + } + + if parsedLine.type == "message", let msg = parsedLine.message, msg.role == "user" { + var text = "" + if let content = msg.content { + switch content { + case .string(let str): + text = str + case .array(let blocks): + text = blocks.compactMap { $0.text }.joined(separator: "\n") + } + } + + let timestamp: Int64 + if let ts = msg.timestamp { + timestamp = ts + } else { + timestamp = fallbackTimestamp + } + + let historyLine = HistoryLine( + display: text, + timestamp: timestamp, + project: projectPath, + sessionId: sessionId + ) + prompts.append(historyLine) + } + } + + if !prompts.isEmpty { + let lastTimestamp = prompts.last?.timestamp ?? Int64(Date().timeIntervalSince1970 * 1000) + let session = HistorySession( + toolType: .pi, + sessionId: sessionId, + projectPath: projectPath, + projectName: projectName, + lastTimestamp: lastTimestamp, + prompts: prompts + ) + sessions.append(session) + } + } catch { + // Ignore individual file parse errors + } + } + } + } + } + } + + sessions.sort(by: { $0.lastTimestamp > $1.lastTimestamp }) + DispatchQueue.main.async { + completion(sessions) + } + } + } + + static func loadPiReplies(projectPath: String, sessionId: String, completion: @escaping ([Int64: AssistantResponse]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let fm = FileManager.default + let escapedProject = projectPath + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .replacingOccurrences(of: "/", with: "-") + let projectDir = home.appendingPathComponent(".pi/agent/sessions/--\(escapedProject)--") + + guard fm.fileExists(atPath: projectDir.path) else { + DispatchQueue.main.async { completion([:]) } + return + } + + var replies: [Int64: AssistantResponse] = [:] + + if let files = try? fm.contentsOfDirectory(at: projectDir, includingPropertiesForKeys: nil) { + for file in files where file.pathExtension == "jsonl" && file.lastPathComponent.contains(sessionId) { + do { + let content = try String(contentsOf: file, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + let decoder = JSONDecoder() + + struct PiLine: Codable { + let type: String + let message: PiMessage? + } + + struct PiMessage: Codable { + let role: String + let content: SessionContent? + let timestamp: Int64? + } + + var events: [HistoryEvent] = [] + + for line in lines.dropFirst() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + guard let lineData = trimmed.data(using: .utf8), + let parsedLine = try? decoder.decode(PiLine.self, from: lineData) else { + continue + } + + if parsedLine.type == "message", let msg = parsedLine.message { + if msg.role == "user" { + events.append(.user(timestamp: msg.timestamp)) + } else if msg.role == "assistant" { + if let content = msg.content { + switch content { + case .string(let str): + events.append(.assistantText(str)) + case .array(let blocks): + for block in blocks { + if block.type == "text", let text = block.text { + events.append(.assistantText(text)) + } else if block.type == "thinking", let thinking = block.thinking { + events.append(.assistantThinking(thinking)) + } + } + } + } + } + } + } + + replies = accumulateReplies(from: events) + } catch { + // ignore + } + break + } + } + + DispatchQueue.main.async { + completion(replies) + } + } + } + + static func loadOpenCodeHistory(completion: @escaping ([HistorySession]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let dbPath = home.appendingPathComponent(".local/share/opencode/opencode.db").path + + guard FileManager.default.fileExists(atPath: dbPath), + let db = SQLiteDatabase(path: dbPath) else { + DispatchQueue.main.async { completion([]) } + return + } + + var sessions: [HistorySession] = [] + + let sessionsQuery = "select id, title, directory, time_created from session order by time_created desc" + let sessionRows = db.query(sql: sessionsQuery) + + for row in sessionRows { + guard let id = row["id"], !id.isEmpty, + let directory = row["directory"], + let timeCreatedStr = row["time_created"], + let timeCreated = Int64(timeCreatedStr) else { + continue + } + + let projectName = URL(fileURLWithPath: directory).lastPathComponent + + let promptsQuery = """ + select part.id, part.data, part.time_created + from part + join message on part.message_id = message.id + where part.session_id = ? + and json_extract(message.data, '$.role') = 'user' + and json_extract(part.data, '$.type') = 'text' + order by part.time_created + """ + let promptRows = db.query(sql: promptsQuery, parameters: [id]) + + var prompts: [HistoryLine] = [] + for pRow in promptRows { + guard let partDataStr = pRow["data"], + let timeCreatedStr = pRow["time_created"], + let timeCreated = Int64(timeCreatedStr) else { + continue + } + + struct OpenCodePartData: Codable { + let text: String? + } + if let data = partDataStr.data(using: .utf8), + let partData = try? JSONDecoder().decode(OpenCodePartData.self, from: data), + let text = partData.text { + let line = HistoryLine( + display: text, + timestamp: timeCreated, + project: directory, + sessionId: id + ) + prompts.append(line) + } + } + + if !prompts.isEmpty { + let lastTimestamp = prompts.last?.timestamp ?? timeCreated + let session = HistorySession( + toolType: .openCode, + sessionId: id, + projectPath: directory, + projectName: projectName, + lastTimestamp: lastTimestamp, + prompts: prompts + ) + sessions.append(session) + } + } + + sessions.sort(by: { $0.lastTimestamp > $1.lastTimestamp }) + DispatchQueue.main.async { + completion(sessions) + } + } + } + + static func loadOpenCodeReplies(sessionId: String, completion: @escaping ([Int64: AssistantResponse]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let dbPath = home.appendingPathComponent(".local/share/opencode/opencode.db").path + + guard FileManager.default.fileExists(atPath: dbPath), + let db = SQLiteDatabase(path: dbPath) else { + DispatchQueue.main.async { completion([:]) } + return + } + + var replies: [Int64: AssistantResponse] = [:] + + let querySql = """ + select message.id as msg_id, + json_extract(message.data, '$.role') as role, + json_extract(part.data, '$.type') as part_type, + json_extract(part.data, '$.text') as part_text, + part.time_created + from part + join message on part.message_id = message.id + where part.session_id = ? + and (part_type = 'text' or part_type = 'reasoning') + order by part.time_created + """ + let rows = db.query(sql: querySql, parameters: [sessionId]) + + var events: [HistoryEvent] = [] + + for row in rows { + guard let role = row["role"], + let partType = row["part_type"], + let partText = row["part_text"], + let timeCreatedStr = row["time_created"], + let timeCreated = Int64(timeCreatedStr) else { + continue + } + + if role == "user" { + events.append(.user(timestamp: timeCreated)) + } else if role == "assistant" { + if partType == "text" { + events.append(.assistantText(partText)) + } else if partType == "reasoning" { + events.append(.assistantThinking(partText)) + } + } + } + + replies = accumulateReplies(from: events) + + DispatchQueue.main.async { + completion(replies) + } + } + } + + static func loadTraeHistory(completion: @escaping ([HistorySession]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let base = home.appendingPathComponent("Library/Application Support/Trae/User/workspaceStorage") + + guard FileManager.default.fileExists(atPath: base.path) else { + DispatchQueue.main.async { completion([]) } + return + } + + var sessions: [HistorySession] = [] + let fm = FileManager.default + let decoder = JSONDecoder() + + struct TraeInputHistoryEntry: Codable { + let inputText: String + } + + if let subdirs = try? fm.contentsOfDirectory(at: base, includingPropertiesForKeys: nil) { + for dir in subdirs { + let dbPath = dir.appendingPathComponent("state.vscdb").path + let workspaceJsonPath = dir.appendingPathComponent("workspace.json").path + + guard fm.fileExists(atPath: dbPath) && fm.fileExists(atPath: workspaceJsonPath) else { + continue + } + + var projectPath = "" + if let wsData = try? Data(contentsOf: URL(fileURLWithPath: workspaceJsonPath)), + let json = try? JSONSerialization.jsonObject(with: wsData) as? [String: Any] { + if let folder = json["folder"] as? String { + if let url = URL(string: folder) { + projectPath = url.path + } + } else if let configuration = json["configuration"] as? String { + if let url = URL(string: configuration) { + projectPath = url.path + } + } else if let workspace = json["workspace"] as? String { + if let url = URL(string: workspace) { + projectPath = url.path + } + } + } + + let projectName: String + if !projectPath.isEmpty { + var name = URL(fileURLWithPath: projectPath).lastPathComponent + if name.hasSuffix(".code-workspace") { + name = String(name.dropLast(".code-workspace".count)) + } + projectName = name + } else { + projectName = dir.lastPathComponent + projectPath = dir.path + } + + let sessionId = dir.lastPathComponent + + guard let db = SQLiteDatabase(path: dbPath) else { continue } + let rows = db.query(sql: "select value from ItemTable where key = 'icube-ai-agent-storage-input-history'") + + if let row = rows.first, let value = row["value"], + let data = value.data(using: .utf8), + let entries = try? decoder.decode([TraeInputHistoryEntry].self, from: data) { + + var prompts: [HistoryLine] = [] + let attrs = try? fm.attributesOfItem(atPath: dbPath) + let modDate = attrs?[.modificationDate] as? Date ?? Date() + var timestamp = Int64(modDate.timeIntervalSince1970 * 1000) + + for entry in entries { + let cleanText = entry.inputText.trimmingCharacters(in: .whitespacesAndNewlines) + if cleanText.isEmpty { continue } + + let line = HistoryLine( + display: cleanText, + timestamp: timestamp, + project: projectPath, + sessionId: sessionId + ) + prompts.append(line) + timestamp -= 1000 + } + + if !prompts.isEmpty { + let session = HistorySession( + toolType: .trae, + sessionId: sessionId, + projectPath: projectPath, + projectName: projectName, + lastTimestamp: prompts.first?.timestamp ?? Int64(modDate.timeIntervalSince1970 * 1000), + prompts: prompts + ) + sessions.append(session) + } + } + } + } + + sessions.sort(by: { $0.lastTimestamp > $1.lastTimestamp }) + DispatchQueue.main.async { + completion(sessions) + } + } + } + + private enum HistoryEvent { + case user(timestamp: Int64?) + case assistantText(String) + case assistantThinking(String) + } + + private static func accumulateReplies(from events: [HistoryEvent]) -> [Int64: AssistantResponse] { + var replies: [Int64: AssistantResponse] = [:] + var currentPromptTimestamp: Int64? = nil + var accumulatedText: [String] = [] + var accumulatedThinking: [String] = [] + + for event in events { + switch event { + case .user(let timestamp): + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + accumulatedText = [] + accumulatedThinking = [] + currentPromptTimestamp = timestamp + + case .assistantText(let text): + accumulatedText.append(text) + + case .assistantThinking(let thinking): + accumulatedThinking.append(thinking) + } + } + + if let prevTimestamp = currentPromptTimestamp { + let text = accumulatedText.joined(separator: "\n") + let thinking = accumulatedThinking.isEmpty ? nil : accumulatedThinking.joined(separator: "\n") + replies[prevTimestamp] = AssistantResponse(thinking: thinking, text: text) + } + + return replies + } + + static func loadReplies(projectPath: String, sessionId: String, completion: @escaping ([Int64: AssistantResponse]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let home = FileManager.default.homeDirectoryForCurrentUser + let escapedProject = projectPath + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ".", with: "-") + let sessionURL = home.appendingPathComponent(".claude/projects/\(escapedProject)/\(sessionId).jsonl") + + guard FileManager.default.fileExists(atPath: sessionURL.path) else { + DispatchQueue.main.async { completion([:]) } + return + } + + do { + let content = try String(contentsOf: sessionURL, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + let decoder = JSONDecoder() + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let isoFormatterFallback = ISO8601DateFormatter() + isoFormatterFallback.formatOptions = [.withInternetDateTime] + + var events: [HistoryEvent] = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { continue } + + guard let data = trimmed.data(using: .utf8), + let parsedLine = try? decoder.decode(SessionJSONLLine.self, from: data) else { + continue + } + + if parsedLine.type == "user" { + var timestamp: Int64? = nil + if let timestampStr = parsedLine.timestamp { + let parsedDate = isoFormatter.date(from: timestampStr) ?? isoFormatterFallback.date(from: timestampStr) + if let date = parsedDate { + timestamp = Int64(date.timeIntervalSince1970 * 1000) + } + } + events.append(.user(timestamp: timestamp)) + } else if parsedLine.type == "assistant", let message = parsedLine.message { + if let contentBlocks = message.content { + switch contentBlocks { + case .string(let str): + events.append(.assistantText(str)) + case .array(let blocks): + for block in blocks { + if block.type == "text", let text = block.text { + events.append(.assistantText(text)) + } else if block.type == "thinking", let thinking = block.thinking { + events.append(.assistantThinking(thinking)) + } + } + } + } + } + } + + let replies = accumulateReplies(from: events) + + DispatchQueue.main.async { + completion(replies) + } + } catch { + print("Error loading Claude replies: \(error)") + DispatchQueue.main.async { + completion([:]) + } + } + } + } + + static func loadHistory(completion: @escaping ([HistorySession]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { let home = FileManager.default.homeDirectoryForCurrentUser let historyURL = home.appendingPathComponent(".claude/history.jsonl") @@ -57,14 +690,15 @@ class ClaudeHistoryLoader { let grouped = Dictionary(grouping: allLines, by: { $0.sessionId }) - var sessions: [ClaudeSession] = [] + var sessions: [HistorySession] = [] for (sessionId, lines) in grouped { let sortedLines = lines.sorted(by: { $0.timestamp < $1.timestamp }) guard let lastLine = sortedLines.last else { continue } let projectPath = lastLine.project let projectName = URL(fileURLWithPath: projectPath).lastPathComponent - let session = ClaudeSession( + let session = HistorySession( + toolType: .claude, sessionId: sessionId, projectPath: projectPath, projectName: projectName, diff --git a/Sources/Signal/Models/FlashMode.swift b/Sources/Signal/Models/FlashMode.swift new file mode 100644 index 0000000..aeff4c9 --- /dev/null +++ b/Sources/Signal/Models/FlashMode.swift @@ -0,0 +1,21 @@ +import Foundation + +enum FlashMode: String, CaseIterable { + case off + case alertOnce + case alertThree + case continuous + + var label: String { + switch self { + case .off: + return NSLocalizedString("Off", comment: "") + case .alertOnce: + return NSLocalizedString("1 Flash", comment: "") + case .alertThree: + return NSLocalizedString("3 Flashes", comment: "") + case .continuous: + return NSLocalizedString("Continuous", comment: "") + } + } +} diff --git a/Sources/Signal/Models/LightColor.swift b/Sources/Signal/Models/LightColor.swift index aa4cf5f..74585fe 100644 --- a/Sources/Signal/Models/LightColor.swift +++ b/Sources/Signal/Models/LightColor.swift @@ -33,23 +33,25 @@ enum LightColor: String, CaseIterable { /// Whether the light breathes (animates). var breathes: Bool { self == .red || self == .yellow } - /// Display label for the menu item. - var label: String { - let text: String + /// Localized display name without emoji. + var displayName: String { switch self { - case .black: text = NSLocalizedString("Off", comment: "") - case .red: text = NSLocalizedString("Red", comment: "") - case .yellow: text = NSLocalizedString("Yellow", comment: "") - case .green: text = NSLocalizedString("Green", comment: "") + case .black: return NSLocalizedString("Off", comment: "") + case .red: return NSLocalizedString("Red", comment: "") + case .yellow: return NSLocalizedString("Yellow", comment: "") + case .green: return NSLocalizedString("Green", comment: "") } + } + /// Display label for the menu item. + var label: String { switch self { case .black: let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - return isDark ? "⚪ \(text)" : "⚫ \(text)" - case .red: return "🔴 \(text)" - case .yellow: return "🟡 \(text)" - case .green: return "🟢 \(text)" + return isDark ? "⚪ \(displayName)" : "⚫ \(displayName)" + case .red: return "🔴 \(displayName)" + case .yellow: return "🟡 \(displayName)" + case .green: return "🟢 \(displayName)" } } } diff --git a/Sources/Signal/Models/SQLiteDatabase.swift b/Sources/Signal/Models/SQLiteDatabase.swift new file mode 100644 index 0000000..13f27a6 --- /dev/null +++ b/Sources/Signal/Models/SQLiteDatabase.swift @@ -0,0 +1,53 @@ +import Foundation +import SQLite3 + +class SQLiteDatabase { + private var db: OpaquePointer? + + init?(path: String) { + if sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) != SQLITE_OK { + if let db = db { + sqlite3_close(db) + } + return nil + } + } + + deinit { + if let db = db { + sqlite3_close(db) + } + } + + func query(sql: String, parameters: [String] = []) -> [[String: String]] { + var stmt: OpaquePointer? + if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK { + return [] + } + + for (index, param) in parameters.enumerated() { + sqlite3_bind_text(stmt, Int32(index + 1), (param as NSString).utf8String, -1, nil) + } + + var results: [[String: String]] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + var row: [String: String] = [:] + let columnCount = sqlite3_column_count(stmt) + for i in 0.. 0 { + window.orderFrontRegardless() + } + windows.append(window) + } + } + + private func showWindows() { + for window in windows { + window.orderFrontRegardless() + } + } + + private func hideWindows() { + for window in windows { + window.orderOut(nil) + } + } + + func triggerFlash(color: LightColor, mode: FlashMode) { + viewModel.triggerFlash(color: color, mode: mode) + } +} diff --git a/Sources/Signal/ViewModels/AppViewModel.swift b/Sources/Signal/ViewModels/AppViewModel.swift index 66c6bca..e836c53 100644 --- a/Sources/Signal/ViewModels/AppViewModel.swift +++ b/Sources/Signal/ViewModels/AppViewModel.swift @@ -5,15 +5,67 @@ class AppViewModel: ObservableObject { @Published var currentColor: LightColor = .black @Published var breathingEnabled: Bool = false @Published var startAtLogin: Bool = false - @Published var claudeSessions: [ClaudeSession] = [] + @Published var selectedTool: ToolType = .claude + @Published var historySessions: [ToolType: [HistorySession]] = [:] @Published var isLoadingSessions: Bool = false + @Published var screenFlashMode: FlashMode = .alertThree { + didSet { + UserDefaults.standard.set(screenFlashMode.rawValue, forKey: "screenFlashMode") + onFlashModeChange?(screenFlashMode) + } + } private var hasLoadedSessions = false + init() { + if let savedValue = UserDefaults.standard.string(forKey: "screenFlashMode"), + let mode = FlashMode(rawValue: savedValue) { + self.screenFlashMode = mode + } else { + self.screenFlashMode = .alertThree + } + } + + var claudeSessions: [HistorySession] { + return historySessions[selectedTool] ?? [] + } + func loadClaudeSessions(forceReload: Bool = false) { if hasLoadedSessions && !forceReload { return } isLoadingSessions = true - ClaudeHistoryLoader.loadHistory { [weak self] sessions in - self?.claudeSessions = sessions + + let group = DispatchGroup() + var loadedHistory: [ToolType: [HistorySession]] = [:] + + // Load Claude + group.enter() + ClaudeHistoryLoader.loadHistory { sessions in + loadedHistory[.claude] = sessions + group.leave() + } + + // Load OpenCode + group.enter() + ClaudeHistoryLoader.loadOpenCodeHistory { sessions in + loadedHistory[.openCode] = sessions + group.leave() + } + + // Load Pi + group.enter() + ClaudeHistoryLoader.loadPiHistory { sessions in + loadedHistory[.pi] = sessions + group.leave() + } + + // Load Trae + group.enter() + ClaudeHistoryLoader.loadTraeHistory { sessions in + loadedHistory[.trae] = sessions + group.leave() + } + + group.notify(queue: .main) { [weak self] in + self?.historySessions = loadedHistory self?.isLoadingSessions = false self?.hasLoadedSessions = true } @@ -21,6 +73,7 @@ class AppViewModel: ObservableObject { // Callback handlers to notify AppDelegate var onColorChange: ((LightColor) -> Void)? + var onFlashModeChange: ((FlashMode) -> Void)? var onBreathingChange: ((Bool) -> Void)? var onStartAtLoginChange: ((Bool) -> Void)? var onInstallCLI: (() -> Void)? diff --git a/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift b/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift new file mode 100644 index 0000000..e4a2e6f --- /dev/null +++ b/Sources/Signal/ViewModels/ScreenOverlayViewModel.swift @@ -0,0 +1,120 @@ +import SwiftUI + +private enum Timing { + static let cancelFadeOut: Double = 0.3 + static let alertOnceFadeIn: Double = 0.3 + static let alertOnceHold: Double = 0.45 + static let alertOnceFadeOut: Double = 0.4 + static let alertThreeFadeIn: Double = 0.25 + static let alertThreeHold: Double = 0.3 + static let alertThreeFadeOut: Double = 0.3 + static let alertThreeGap: Double = 0.35 + static let fallbackPulse: Double = 1.2 +} + +class ScreenOverlayViewModel: ObservableObject { + @Published var overlayColor: Color = .clear + @Published var opacity: Double = 0.0 + + var onAnimationStart: (() -> Void)? + var onAnimationEnd: (() -> Void)? + + private var currentAnimationId: UUID? + + func triggerFlash(color: LightColor, mode: FlashMode) { + let animationId = UUID() + self.currentAnimationId = animationId + + guard mode != .off, color != .black else { + // Cancel current overlay animation and fade out + withAnimation(.easeOut(duration: Timing.cancelFadeOut)) { + self.opacity = 0.0 + } + // After fade out, notify that animation ended to hide windows + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.cancelFadeOut) { [weak self] in + guard let self = self, animationId == self.currentAnimationId else { return } + self.onAnimationEnd?() + } + return + } + + let swiftUIColor = Color(color.color) + self.overlayColor = swiftUIColor + self.opacity = 0.0 + + // Notify animation started (which will order Front the windows) + onAnimationStart?() + + runAnimation(id: animationId, mode: mode, period: color.period) + } + + private func runAnimation(id: UUID, mode: FlashMode, period: Double) { + guard id == currentAnimationId else { return } + + switch mode { + case .alertOnce: + withAnimation(.easeOut(duration: Timing.alertOnceFadeIn)) { + self.opacity = 1.0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertOnceHold) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + withAnimation(.easeIn(duration: Timing.alertOnceFadeOut)) { + self.opacity = 0.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertOnceFadeOut) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + self.onAnimationEnd?() + } + } + + case .alertThree: + flashSequence(id: id, current: 1, total: 3) + + case .continuous: + // Continuous breathing/pulsing + pulseLoop(id: id, duration: period > 0 ? period / 2.0 : Timing.fallbackPulse, targetHigh: true) + + case .off: + break + } + } + + private func flashSequence(id: UUID, current: Int, total: Int) { + guard id == currentAnimationId else { return } + withAnimation(.easeOut(duration: Timing.alertThreeFadeIn)) { + self.opacity = 1.0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertThreeHold) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + withAnimation(.easeIn(duration: Timing.alertThreeFadeOut)) { + self.opacity = 0.0 + } + + if current < total { + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertThreeGap) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + self.flashSequence(id: id, current: current + 1, total: total) + } + } else { + // Done with all flashes + DispatchQueue.main.asyncAfter(deadline: .now() + Timing.alertThreeFadeOut) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + self.onAnimationEnd?() + } + } + } + } + + private func pulseLoop(id: UUID, duration: Double, targetHigh: Bool) { + guard id == currentAnimationId else { return } + withAnimation(.easeInOut(duration: duration)) { + self.opacity = targetHigh ? 0.75 : 0.15 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + guard let self = self, id == self.currentAnimationId else { return } + self.pulseLoop(id: id, duration: duration, targetHigh: !targetHigh) + } + } +} diff --git a/Sources/Signal/Views/ClaudeDetailView.swift b/Sources/Signal/Views/ClaudeDetailView.swift index c209682..5ad1f06 100644 --- a/Sources/Signal/Views/ClaudeDetailView.swift +++ b/Sources/Signal/Views/ClaudeDetailView.swift @@ -4,6 +4,10 @@ struct ClaudeDetailView: View { let session: ClaudeSession @Environment(\.dismiss) private var dismiss + @State private var replies: [Int64: AssistantResponse] = [:] + @State private var expandedPromptIds: Set = [] + @State private var isLoadingReplies: Bool = true + var body: some View { VStack(spacing: 0) { // Custom Navigation Header @@ -26,7 +30,7 @@ struct ClaudeDetailView: View { Spacer() - Text(NSLocalizedString("User Prompts", comment: "")) + Text(NSLocalizedString("Conversation Details", comment: "")) .font(.system(size: 13, weight: .bold)) Spacer() @@ -77,9 +81,25 @@ struct ClaudeDetailView: View { .background(Color(NSColor.controlBackgroundColor).opacity(0.3)) .cornerRadius(6) - // Chronological Prompts List - ForEach(session.prompts) { prompt in - PromptBubbleView(prompt: prompt) + // Chronological Prompts List (Time reversed, newest first) + let sortedPrompts = session.prompts.sorted(by: { $0.timestamp > $1.timestamp }) + ForEach(sortedPrompts) { prompt in + PromptBubbleView( + prompt: prompt, + toolType: session.toolType, + isExpanded: expandedPromptIds.contains(prompt.id), + isLoadingReplies: isLoadingReplies, + reply: findReply(for: prompt.timestamp), + onTap: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { + if expandedPromptIds.contains(prompt.id) { + expandedPromptIds.remove(prompt.id) + } else { + expandedPromptIds.insert(prompt.id) + } + } + } + ) } } .padding(.all, 12) @@ -87,31 +107,221 @@ struct ClaudeDetailView: View { } .navigationBarBackButtonHidden(true) .background(Color(NSColor.controlBackgroundColor)) + .onAppear { + isLoadingReplies = true + switch session.toolType { + case .claude: + ClaudeHistoryLoader.loadReplies(projectPath: session.projectPath, sessionId: session.sessionId) { loadedReplies in + self.replies = loadedReplies + self.isLoadingReplies = false + } + case .pi: + ClaudeHistoryLoader.loadPiReplies(projectPath: session.projectPath, sessionId: session.sessionId) { loadedReplies in + self.replies = loadedReplies + self.isLoadingReplies = false + } + case .openCode: + ClaudeHistoryLoader.loadOpenCodeReplies(sessionId: session.sessionId) { loadedReplies in + self.replies = loadedReplies + self.isLoadingReplies = false + } + case .trae: + self.replies = [:] + self.isLoadingReplies = false + } + } + } + + private func findReply(for promptTimestamp: Int64) -> AssistantResponse? { + let maxDiff: Int64 = 5000 // 5 seconds + var bestKey: Int64? = nil + var minDiff = maxDiff + + for key in replies.keys { + let diff = abs(key - promptTimestamp) + if diff < minDiff { + minDiff = diff + bestKey = key + } + } + + if let key = bestKey { + return replies[key] + } + return nil } } struct PromptBubbleView: View { let prompt: HistoryLine + let toolType: ToolType + let isExpanded: Bool + let isLoadingReplies: Bool + let reply: AssistantResponse? + let onTap: () -> Void + + @State private var isPromptHovered = false + @State private var showThinkingExpanded = false + @State private var isCopySuccess = false var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { // Prompt Card VStack(alignment: .leading, spacing: 0) { - Text(prompt.display) - .font(.system(size: 12)) - .foregroundColor(.primary) - .lineSpacing(4) - .textSelection(.enabled) - .multilineTextAlignment(.leading) + HStack(alignment: .top) { + Text(prompt.display) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineSpacing(4) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.secondary.opacity(0.5)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .padding(.top, 3) + } } .padding(.all, 12) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.accentColor.opacity(0.08)) + .background(Color.accentColor.opacity(isPromptHovered ? 0.12 : 0.08)) .overlay( RoundedRectangle(cornerRadius: 10) - .stroke(Color.accentColor.opacity(0.15), lineWidth: 1) + .stroke(Color.accentColor.opacity(0.2), lineWidth: 1) ) .cornerRadius(10) + .onHover { hovering in + isPromptHovered = hovering + } + .onTapGesture { + onTap() + } + + // Expanded Reply Content + if isExpanded { + VStack(alignment: .leading, spacing: 8) { + if isLoadingReplies { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(NSLocalizedString("Loading feedback...", comment: "")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .padding(.vertical, 6) + .padding(.leading, 12) + } else if let reply = reply { + // Thinking Process (if available) + if let thinking = reply.thinking, !thinking.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + VStack(alignment: .leading, spacing: 4) { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + showThinkingExpanded.toggle() + } + }) { + HStack(spacing: 4) { + Image(systemName: "brain.head.profile") + .font(.system(size: 10)) + Text(NSLocalizedString("Thinking Process", comment: "")) + .font(.system(size: 10, weight: .bold)) + Image(systemName: showThinkingExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 7)) + } + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + + if showThinkingExpanded { + Text(thinking) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary.opacity(0.8)) + .padding(.all, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.03)) + .cornerRadius(6) + .textSelection(.enabled) + } + } + .padding(.horizontal, 4) + } + + // Assistant Reply Bubble + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "sparkles") + .font(.system(size: 11)) + .foregroundColor(.accentColor) + Text(NSLocalizedString("Assistant Feedback", comment: "")) + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.accentColor) + Spacer() + + // Copy Button + Button(action: { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(reply.text, forType: .string) + withAnimation { + isCopySuccess = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + isCopySuccess = false + } + } + }) { + HStack(spacing: 3) { + Image(systemName: isCopySuccess ? "checkmark.circle.fill" : "doc.on.doc") + .font(.system(size: 9)) + Text(isCopySuccess ? NSLocalizedString("Copied", comment: "") : NSLocalizedString("Copy", comment: "")) + .font(.system(size: 9, weight: .semibold)) + } + .foregroundColor(isCopySuccess ? .green : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.primary.opacity(0.04)) + .cornerRadius(4) + } + .buttonStyle(.plain) + } + + Text(reply.text) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineSpacing(4) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + } + .padding(.all, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + .cornerRadius(10) + } else { + // No response found + HStack(spacing: 6) { + Image(systemName: "info.circle") + .font(.system(size: 11)) + Text(NSLocalizedString("No reply recorded for this prompt.", comment: "")) + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(.secondary) + .padding(.all, 10) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color.primary.opacity(0.03)) + .cornerRadius(8) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + .padding(.leading, 8) + .padding(.trailing, 4) + } // Timestamp below bubble HStack { diff --git a/Sources/Signal/Views/ClaudeHistoryView.swift b/Sources/Signal/Views/ClaudeHistoryView.swift index cf59e3e..0bc48d0 100644 --- a/Sources/Signal/Views/ClaudeHistoryView.swift +++ b/Sources/Signal/Views/ClaudeHistoryView.swift @@ -6,6 +6,19 @@ struct ClaudeHistoryView: View { var body: some View { NavigationStack { VStack(spacing: 0) { + // Segmented control switcher for different tools + Picker("", selection: $viewModel.selectedTool) { + ForEach(ToolType.allCases) { tool in + Text(tool.rawValue).tag(tool) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(NSColor.controlBackgroundColor).opacity(0.2)) + + Divider() + if viewModel.isLoadingSessions { VStack { Spacer() @@ -22,6 +35,9 @@ struct ClaudeHistoryView: View { Text(NSLocalizedString("No conversation history", comment: "")) .font(.system(size: 13, weight: .medium)) .foregroundColor(.secondary) + Text(String(format: NSLocalizedString("No history records found for %@", comment: ""), viewModel.selectedTool.rawValue)) + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.7)) Spacer() } } else { diff --git a/Sources/Signal/Views/MainView.swift b/Sources/Signal/Views/MainView.swift index fa96da9..d596021 100644 --- a/Sources/Signal/Views/MainView.swift +++ b/Sources/Signal/Views/MainView.swift @@ -12,7 +12,7 @@ struct MainView: View { selectedTab = 0 } - TabButton(title: NSLocalizedString("Claude History", comment: ""), icon: "bubble.left.and.bubble.right", isActive: selectedTab == 1) { + TabButton(title: NSLocalizedString("AI History", comment: ""), icon: "bubble.left.and.bubble.right", isActive: selectedTab == 1) { selectedTab = 1 } } diff --git a/Sources/Signal/Views/StatusControlView.swift b/Sources/Signal/Views/StatusControlView.swift index 7440758..4ac8442 100644 --- a/Sources/Signal/Views/StatusControlView.swift +++ b/Sources/Signal/Views/StatusControlView.swift @@ -88,6 +88,29 @@ struct StatusControlView: View { .controlSize(.small) .labelsHidden() } + + // Screen Flash Effect Selector + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("Screen Flash Effect", comment: "")) + .font(.system(size: 13, weight: .medium)) + Text(NSLocalizedString("Gaming-style overlay on color change", comment: "")) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + Spacer() + + Picker("", selection: $viewModel.screenFlashMode) { + ForEach(FlashMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.menu) + .labelsHidden() + .controlSize(.small) + .frame(width: 120) + } } Divider() @@ -127,7 +150,7 @@ struct StatusControlView: View { Spacer(minLength: 12) - // Section 3: Quit Button at bottom + // Quit Button at bottom Divider() Button(action: { @@ -173,7 +196,7 @@ struct ColorGridButton: View { ) .frame(width: 14, height: 14) - Text(cleanLabel(for: color)) + Text(color.displayName) .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) .foregroundColor(isSelected ? .primary : .secondary) @@ -198,16 +221,6 @@ struct ColorGridButton: View { isHovered = hovering } } - - private func cleanLabel(for color: LightColor) -> String { - // Strip out the leading emoji from the color label for cleaner grid display - let rawLabel = color.label - if rawLabel.contains("🔴") { return NSLocalizedString("Red", comment: "") } - if rawLabel.contains("🟡") { return NSLocalizedString("Yellow", comment: "") } - if rawLabel.contains("🟢") { return NSLocalizedString("Green", comment: "") } - if rawLabel.contains("⚪") || rawLabel.contains("⚫") { return NSLocalizedString("Off", comment: "") } - return rawLabel - } } // NSViewRepresentable wrapper to run breathing animations on the GPU (saving CPU) diff --git a/Sources/Signal/Views/VignetteView.swift b/Sources/Signal/Views/VignetteView.swift new file mode 100644 index 0000000..07285cc --- /dev/null +++ b/Sources/Signal/Views/VignetteView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct VignetteView: View { + let color: Color + + var body: some View { + GeometryReader { geo in + let maxDimension = max(geo.size.width, geo.size.height) + ZStack { + // Soft edge glow using radial gradient + RadialGradient( + gradient: Gradient(colors: [ + .clear, + color.opacity(0.12), + color.opacity(0.4) + ]), + center: .center, + startRadius: maxDimension * 0.35, + endRadius: maxDimension * 0.65 + ) + + // Intense border glow using thick stroked rectangle + Rectangle() + .stroke(color, lineWidth: 24) + .blur(radius: 20) + .padding(-12) // Keep the blur from getting cut off at the window bounds + .opacity(0.7) + } + } + .ignoresSafeArea() + } +} + +struct OverlayContentView: View { + @ObservedObject var viewModel: ScreenOverlayViewModel + + var body: some View { + VignetteView(color: viewModel.overlayColor) + .opacity(viewModel.opacity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +}