diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift index b610dd6..91b6a1d 100644 --- a/NotchBuddy/Sources/App/AppDelegate.swift +++ b/NotchBuddy/Sources/App/AppDelegate.swift @@ -34,11 +34,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { button.image?.isTemplate = true let menu = NSMenu() - menu.addItem(withTitle: "Open BroV", action: #selector(openIsland), keyEquivalent: "") + menu.addItem(withTitle: "Открыть BroV", action: #selector(openIsland), keyEquivalent: "") menu.addItem(.separator()) - menu.addItem(withTitle: "Settings…", action: #selector(openSettings), keyEquivalent: ",") + menu.addItem(withTitle: "Настройки…", action: #selector(openSettings), keyEquivalent: ",") menu.addItem(.separator()) - menu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + menu.addItem(withTitle: "Выйти", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") statusItem?.menu = menu } @@ -69,7 +69,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let win = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 560), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) - win.title = "Settings — BroV" + win.title = "Настройки — BroV" let host = NSHostingView(rootView: SettingsView()) host.sizingOptions = [.minSize] win.contentView = host diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 55ff5bd..b693a5c 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -150,8 +150,8 @@ final class AppState: ObservableObject { let normalised = LocalChat.normaliseURL(baseURL) guard !normalised.isEmpty else { providerModelFetchError[provider] = provider == .ollama - ? "Connect Ollama in Settings → Chat first." - : "Connect LM Studio in Settings → Chat first." + ? "Сначала подключите Ollama в Настройки → Чат." + : "Сначала подключите LM Studio в Настройки → Чат." return } loadingProviderModels.insert(provider) @@ -162,8 +162,8 @@ final class AppState: ObservableObject { switch result { case .success(let models) where models.isEmpty: providerModelFetchError[provider] = provider == .ollama - ? "No models yet. Download one in Ollama first." - : "No models yet. Download one in LM Studio first." + ? "Моделей пока нет. Скачайте модель в Ollama." + : "Моделей пока нет. Скачайте модель в LM Studio." case .success(let models): fetchedProviderModels[provider] = models let current = provider == .ollama ? ollamaChatModel : lmstudioChatModel @@ -173,14 +173,14 @@ final class AppState: ObservableObject { else { lmstudioChatModel = first } } case .failure: - providerModelFetchError[provider] = "Cannot reach \(normalised). Is the server running?" + providerModelFetchError[provider] = "Нет связи с \(normalised). Сервер запущен?" } } return } // Remote providers: require API key guard let apiKey = KeychainStore.shared.get(provider.keychainKey), !apiKey.isEmpty else { - providerModelFetchError[provider] = "No API key — add it in Settings." + providerModelFetchError[provider] = "Нет API-ключа — добавьте его в Настройки → Чат." return } loadingProviderModels.insert(provider) @@ -196,7 +196,7 @@ final class AppState: ObservableObject { } loadingProviderModels.remove(provider) if models.isEmpty { - providerModelFetchError[provider] = "Failed to load models. Check your API key." + providerModelFetchError[provider] = "Не удалось загрузить модели. Проверьте API-ключ." } else { fetchedProviderModels[provider] = models switch provider { @@ -680,13 +680,13 @@ struct VercelDeployment: Identifiable { let branch: String? var isSuccess: Bool { state == "READY" } - var statusLabel: String { isSuccess ? "Ready" : (state == "CANCELED" ? "Canceled" : "Error") } + var statusLabel: String { isSuccess ? "Готово" : (state == "CANCELED" ? "Отменено" : "Ошибка") } var timeAgo: String { let diff = Date().timeIntervalSince(createdAt) - if diff < 60 { return "just now" } - if diff < 3600 { return "\(Int(diff/60))m" } - if diff < 86400 { return "\(Int(diff/3600))h" } - return "\(Int(diff/86400))d" + if diff < 60 { return "только что" } + if diff < 3600 { return "\(Int(diff/60)) мин" } + if diff < 86400 { return "\(Int(diff/3600)) ч" } + return "\(Int(diff/86400)) д" } } @@ -705,10 +705,10 @@ struct ResendEmail: Identifiable { } var timeAgo: String { let diff = Date().timeIntervalSince(createdAt) - if diff < 60 { return "just now" } - if diff < 3600 { return "\(Int(diff/60))m" } - if diff < 86400 { return "\(Int(diff/3600))h" } - return "\(Int(diff/86400))d" + if diff < 60 { return "только что" } + if diff < 3600 { return "\(Int(diff/60)) мин" } + if diff < 86400 { return "\(Int(diff/3600)) ч" } + return "\(Int(diff/86400)) д" } var isDelivered: Bool { lastEvent == "delivered" } } @@ -734,10 +734,10 @@ struct StripePayment: Identifiable, Equatable { var isSuccess: Bool { status == "succeeded" } var timeAgo: String { let diff = Date().timeIntervalSince(createdAt) - if diff < 60 { return "just now" } - if diff < 3600 { return "\(Int(diff/60))m" } - if diff < 86400 { return "\(Int(diff/3600))h" } - return "\(Int(diff/86400))d" + if diff < 60 { return "только что" } + if diff < 3600 { return "\(Int(diff/60)) мин" } + if diff < 86400 { return "\(Int(diff/3600)) ч" } + return "\(Int(diff/86400)) д" } } @@ -781,10 +781,10 @@ struct NotionPage: Identifiable { var timeAgo: String { let diff = Date().timeIntervalSince(lastEditedAt) - if diff < 60 { return "now" } - if diff < 3600 { return "\(Int(diff/60))m" } - if diff < 86400 { return "\(Int(diff/3600))h" } - return "\(Int(diff/86400))d" + if diff < 60 { return "сейчас" } + if diff < 3600 { return "\(Int(diff/60)) мин" } + if diff < 86400 { return "\(Int(diff/3600)) ч" } + return "\(Int(diff/86400)) д" } } diff --git a/NotchBuddy/Sources/App/CalcomPoller.swift b/NotchBuddy/Sources/App/CalcomPoller.swift index 8f49b3d..9564b7e 100644 --- a/NotchBuddy/Sources/App/CalcomPoller.swift +++ b/NotchBuddy/Sources/App/CalcomPoller.swift @@ -42,9 +42,9 @@ final class CalcomPoller: @unchecked Sendable { let code = (response as? HTTPURLResponse)?.statusCode ?? 0 if code != 200 { let msg: String - if code == 401 { msg = "Invalid API key (401)" } - else if code == 0 { msg = error?.localizedDescription ?? "No connection" } - else { msg = "API error \(code)" } + if code == 401 { msg = "Неверный API-ключ (401)" } + else if code == 0 { msg = error?.localizedDescription ?? "Нет соединения" } + else { msg = "Ошибка API \(code)" } DispatchQueue.main.async { AppState.shared.calcomError = msg } return } diff --git a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift index d1d78df..2e6e657 100644 --- a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift +++ b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift @@ -18,7 +18,7 @@ enum ClaudeCodeCLIError: LocalizedError { var errorDescription: String? { switch self { case .notFound: - "Claude Code CLI not found. Install it, or set its path in Settings → Chat." + "Claude Code CLI не найден. Установите его или укажите путь в Настройки → Чат." case .failed(let message): message } @@ -31,7 +31,7 @@ final class ClaudeCodeCLI { /// Models offered in the picker. Empty id = whatever the CLI is configured to use. static let models: [(id: String, label: String)] = [ - ("", "Default"), + ("", "По умолчанию"), ("opus", "Opus"), ("sonnet", "Sonnet"), ("haiku", "Haiku"), @@ -149,7 +149,7 @@ final class ClaudeCodeCLI { if let error = parser.errorMessage { if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") { - throw ClaudeCodeCLIError.failed("Claude Code isn't logged in. Run `claude /login` in a terminal.") + throw ClaudeCodeCLIError.failed("Claude Code не авторизован. Выполните `claude /login` в терминале.") } // A resume that points at a gone session: start fresh next time. if error.localizedCaseInsensitiveContains("No conversation found") { sessionID = nil } @@ -158,7 +158,7 @@ final class ClaudeCodeCLI { if p.terminationReason == .uncaughtSignal { throw CancellationError() } if parser.finalText == nil, p.terminationStatus != 0 { let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let last = stderr.split(separator: "\n").last.map(String.init) ?? "exit code \(p.terminationStatus)" + let last = stderr.split(separator: "\n").last.map(String.init) ?? "код выхода \(p.terminationStatus)" throw ClaudeCodeCLIError.failed("claude: \(last)") } return parser.finalText ?? parser.visibleText @@ -238,7 +238,7 @@ struct StreamParser { let isError = json["is_error"] as? Bool ?? false let result = json["result"] as? String if isError { - if errorMessage == nil { errorMessage = result ?? "Claude Code returned an error." } + if errorMessage == nil { errorMessage = result ?? "Claude Code вернул ошибку." } } else { // Keep the tool trail when there was one; otherwise the clean final answer. finalText = committed.isEmpty ? (result ?? "") : committed diff --git a/NotchBuddy/Sources/App/ClaudePlanCardView.swift b/NotchBuddy/Sources/App/ClaudePlanCardView.swift index e17bbce..7a43d41 100644 --- a/NotchBuddy/Sources/App/ClaudePlanCardView.swift +++ b/NotchBuddy/Sources/App/ClaudePlanCardView.swift @@ -15,7 +15,7 @@ struct ClaudePlanCardView: View { Circle() .fill(Color(hex: ClaudePlanGauge.color(for: dominant))) .frame(width: 7, height: 7) - Text("Claude plan") + Text("План Claude") .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) Text(subtitleText) @@ -30,8 +30,8 @@ struct ClaudePlanCardView: View { // Gauge rows VStack(alignment: .leading, spacing: 5) { - GaugeRowView(label: "5 hours", window: usage?.fiveHour, now: now) - GaugeRowView(label: "Week", window: usage?.sevenDay, now: now, weekly: true) + GaugeRowView(label: "5 часов", window: usage?.fiveHour, now: now) + GaugeRowView(label: "Неделя", window: usage?.sevenDay, now: now, weekly: true) } .padding(.top, 8) .padding(.leading, 108) @@ -48,21 +48,21 @@ struct ClaudePlanCardView: View { } private var subtitleText: String { - guard let usage else { return "Waiting for a Claude Code reply" } + guard let usage else { return "Жду ответа Claude Code" } let diff = now.timeIntervalSince(usage.updatedAt) - if diff < 60 { return "just now" } + if diff < 60 { return "только что" } let mins = Int(diff / 60) - if mins < 60 { return "\(mins) min ago" } - return "\(mins / 60) h ago" + if mins < 60 { return "\(mins) мин назад" } + return "\(mins / 60) ч назад" } } // MARK: - Gauge Row -// DateFormatter created once, in English, for the weekly reset label +// DateFormatter created once, in Russian, for the weekly reset label private let weeklyResetFormatter: DateFormatter = { let fmt = DateFormatter() - fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.locale = Locale(identifier: "ru_RU") fmt.dateFormat = "EEE H:mm" return fmt }() @@ -78,7 +78,7 @@ private struct GaugeRowView: View { Text(label) .font(.system(size: 11)) .foregroundColor(Color(hex: "#6B7079")) - .frame(width: 40, alignment: .leading) + .frame(width: 46, alignment: .leading) if let w = window { let pct = ClaudePlanGauge.effectivePct(w) let accent = Color(hex: ClaudePlanGauge.color(for: pct)) @@ -115,14 +115,14 @@ private struct GaugeRowView: View { private func resetLabel(_ w: PlanWindow) -> String { let secs = w.resetsAt.timeIntervalSince(now) - guard secs > 0 else { return "Resetting…" } + guard secs > 0 else { return "Сброс…" } if weekly { return weeklyResetFormatter.string(from: w.resetsAt) } else { let h = Int(secs / 3600) let m = Int((secs.truncatingRemainder(dividingBy: 3600)) / 60) - if h > 0 { return "in \(h) h \(m)" } - return "in \(m) min" + if h > 0 { return "через \(h) ч \(m) мин" } + return "через \(m) мин" } } } diff --git a/NotchBuddy/Sources/App/ClaudePlanGauge.swift b/NotchBuddy/Sources/App/ClaudePlanGauge.swift index e9258ad..f95497a 100644 --- a/NotchBuddy/Sources/App/ClaudePlanGauge.swift +++ b/NotchBuddy/Sources/App/ClaudePlanGauge.swift @@ -74,7 +74,7 @@ enum ClaudePlanGauge { /// Label shown in the island pill chip. static func pillLabel(_ usage: PlanUsage?) -> String { - guard let usage, let pct = dominantPct(usage) else { return "Claude plan" } + guard let usage, let pct = dominantPct(usage) else { return "План Claude" } return "Claude \(Int(pct.rounded()))%" } } diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 3a71cd9..dacc00c 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -226,7 +226,7 @@ final class ClaudeService { return } guard let key = apiKey, !key.isEmpty else { - await showError("API key missing. Open settings.", state: state) + await showError("Нет API-ключа. Откройте Настройки.", state: state) return } @@ -350,7 +350,7 @@ final class ClaudeService { guard !baseURL.isEmpty else { if provider.isLocal { let name = provider == .ollama ? "Ollama" : "LM Studio" - await showError("Connect \(name) in Settings → Chat first.", state: state) + await showError("Сначала подключите \(name) в Настройки → Чат.", state: state) } return } @@ -362,7 +362,7 @@ final class ClaudeService { authHeader = "Bearer ollama" } else { guard let key = KeychainStore.shared.get(provider.keychainKey), !key.isEmpty else { - await showError("\(provider.displayName) API key missing. Configure it in Settings.", state: state) + await showError("Нет API-ключа \(provider.displayName). Добавьте его в Настройки → Чат.", state: state) return } authHeader = "Bearer \(key)" @@ -463,10 +463,10 @@ final class ClaudeService { switch e { case .serverUnreachable: msg = provider == .ollama - ? "Ollama isn't running. Open it, then ask again." - : "Start the local server in LM Studio, then ask again." + ? "Ollama не запущена. Откройте её и спросите снова." + : "Запустите локальный сервер в LM Studio и спросите снова." case .modelNotFound(let m): - msg = "\(m) isn't installed. Pick another model above the chat box." + msg = "Модель \(m) не установлена. Выберите другую над полем чата." case .serverError(let s): msg = s } @@ -492,7 +492,7 @@ final class ClaudeService { let choices = json["choices"] as? [[String: Any]], let message = choices.first?["message"] as? [String: Any], let content = message["content"] as? String else { - throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unexpected response format"]) + throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: "Неожиданный формат ответа"]) } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) conversationMessages.append(["role": "assistant", "content": trimmed]) @@ -511,7 +511,7 @@ final class ClaudeService { func search(query: String, context: PromptContext?, state: AppState) async { guard let key = apiKey, !key.isEmpty else { - await showError("Anthropic API key missing. Open settings to configure it.", state: state) + await showError("Нет API-ключа Anthropic. Добавьте его в Настройки → Чат.", state: state) return } @@ -582,12 +582,12 @@ final class ClaudeService { let id = AppState.shared.claudeModel throw NSError(domain: "Claude", code: 0, userInfo: [NSLocalizedDescriptionKey: - "Model not found: \(id). Pick another one in Settings."]) + "Модель не найдена: \(id). Выберите другую в Настройках."]) } throw NSError(domain: "Claude", code: 0, userInfo: [NSLocalizedDescriptionKey: errMsg]) } - let msg = String(data: data, encoding: .utf8) ?? "unknown error" + let msg = String(data: data, encoding: .utf8) ?? "неизвестная ошибка" throw NSError(domain: "Claude", code: 0, userInfo: [NSLocalizedDescriptionKey: msg]) } return data @@ -598,7 +598,7 @@ final class ClaudeService { private func handleChatResult(_ data: Data, state: AppState) async { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let content = json["content"] as? [[String: Any]] else { - await showError("Unexpected API response.", state: state) + await showError("Неожиданный ответ API.", state: state) return } @@ -607,7 +607,7 @@ final class ClaudeService { guard let textBlock = content.first(where: { $0["type"] as? String == "text" }), let text = textBlock["text"] as? String, !text.isEmpty else { - await showError("No response text.", state: state) + await showError("Ответ пустой.", state: state) return } @@ -627,7 +627,7 @@ final class ClaudeService { let content = json["content"] as? [[String: Any]], let textBlock = content.first(where: { $0["type"] as? String == "text" }), let text = textBlock["text"] as? String else { - await showError("Unexpected API response.", state: state) + await showError("Неожиданный ответ API.", state: state) return } @@ -642,7 +642,7 @@ final class ClaudeService { // Try to parse as our JSON format if let resultData = cleanText.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: resultData) as? [String: Any] { - let title = parsed["title"] as? String ?? "Result" + let title = parsed["title"] as? String ?? "Результат" let note = parsed["note"] as? String var items: [ResultItem] = [] if let rawItems = parsed["items"] as? [[String: Any]] { @@ -659,7 +659,7 @@ final class ClaudeService { // Fallback: show raw text in 3-line chunks let lines = cleanText.components(separatedBy: "\n").filter { !$0.isEmpty }.prefix(3) state.searchResult = SearchResult( - title: "Claude's response", + title: "Ответ Claude", items: lines.map { ResultItem(label: $0, detail: "", url: nil) }, note: nil ) diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index 69c5f39..af9c9dd 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -329,7 +329,7 @@ final class HookServer: @unchecked Sendable { ?? "unknown" let cwd = payload["cwd"] as? String ?? "" let rawName = URL(fileURLWithPath: cwd).lastPathComponent - let projectName = aliasProjectName(rawName.isEmpty ? "Session" : rawName) + let projectName = aliasProjectName(rawName.isEmpty ? "Сессия" : rawName) // Determine which pill this event belongs to. // coucou_agent must be lowercase, digits and hyphens, ≤ 24 chars. @@ -387,9 +387,9 @@ final class HookServer: @unchecked Sendable { if let pending = state.pendingApproval, agentId == pending.pillId { let handledNote: String switch pending.pillId { - case "agent_cursor": handledNote = "Handled in Cursor." - case "agent_codex": handledNote = "Handled in Codex." - default: handledNote = "Handled in VS Code." + case "agent_cursor": handledNote = "Решено в Cursor." + case "agent_codex": handledNote = "Решено в Codex." + default: handledNote = "Решено в VS Code." } var resolved = false switch name { @@ -461,7 +461,7 @@ final class HookServer: @unchecked Sendable { case "PostToolUseFailure": state.updateTask(id: agentId, state: .working) - appendStep(id: agentId, step: "⚠ failed") + appendStep(id: agentId, step: "⚠ ошибка") case "Notification": let message = payload["message"] as? String ?? "" @@ -522,10 +522,10 @@ final class HookServer: @unchecked Sendable { state.removeTask(id: agentId) case "SubagentStart": - appendStep(id: agentId, step: "+ subagent") + appendStep(id: agentId, step: "+ субагент") case "SubagentStop": - appendStep(id: agentId, step: "• subagent done") + appendStep(id: agentId, step: "• субагент готов") default: break @@ -613,7 +613,7 @@ final class HookServer: @unchecked Sendable { ?? "unknown" let cwd = payload["cwd"] as? String ?? "" let rawName = URL(fileURLWithPath: cwd).lastPathComponent - let projectName = aliasProjectName(rawName.isEmpty ? "Session" : rawName) + let projectName = aliasProjectName(rawName.isEmpty ? "Сессия" : rawName) let rawAgent = payload["coucou_agent"] as? String ?? "" let termProgram = payload["term_program"] as? String ?? "" @@ -710,9 +710,9 @@ final class HookServer: @unchecked Sendable { guard let self, self.pendingApprovalFD == fd else { return } let note: String switch capturedPillId { - case "agent_cursor": note = "Handled in Cursor." - case "agent_codex": note = "Handled in Codex." - default: note = "Handled in VS Code." + case "agent_cursor": note = "Решено в Cursor." + case "agent_codex": note = "Решено в Codex." + default: note = "Решено в VS Code." } self.dismissApprovalCard(note: note) } @@ -727,9 +727,9 @@ final class HookServer: @unchecked Sendable { guard let self, self.pendingApprovalFD == captured else { return } let note: String switch capturedPillId { - case "agent_cursor": note = "Still waiting in Cursor." - case "agent_codex": note = "Still waiting in Codex." - default: note = "Still waiting in VS Code." + case "agent_cursor": note = "Всё ещё ждёт в Cursor." + case "agent_codex": note = "Всё ещё ждёт в Codex." + default: note = "Всё ещё ждёт в VS Code." } self.dismissApprovalCard(note: note) } @@ -789,7 +789,7 @@ final class HookServer: @unchecked Sendable { ?? "unknown" let cwd = payload["cwd"] as? String ?? "" let rawName = URL(fileURLWithPath: cwd).lastPathComponent - let projectName = aliasProjectName(rawName.isEmpty ? "Session" : rawName) + let projectName = aliasProjectName(rawName.isEmpty ? "Сессия" : rawName) let rawAgent = payload["coucou_agent"] as? String ?? "" let termProgram = payload["term_program"] as? String ?? "" @@ -930,27 +930,27 @@ final class HookServer: @unchecked Sendable { return aliases[name.lowercased()] ?? name } - // MARK: - French step labels + // MARK: - Step labels (Russian) private func frenchStep(tool: String, input: [String: Any]) -> String { let labels: [String: String] = [ - "Bash": "Exécute", - "Read": "Lit", - "Write": "Écrit", - "Edit": "Modifie", - "Glob": "Cherche", - "Grep": "Recherche", - "WebSearch": "Recherche web", - "WebFetch": "Récupère", - "TodoWrite": "Tâches", - "Task": "Agent", - "LS": "Liste", - "MultiEdit": "Modifie", - "NotebookEdit": "Notebook", + "Bash": "Выполняет", + "Read": "Читает", + "Write": "Пишет", + "Edit": "Правит", + "Glob": "Ищет", + "Grep": "Ищет", + "WebSearch": "Веб-поиск", + "WebFetch": "Загружает", + "TodoWrite": "Задачи", + "Task": "Агент", + "LS": "Список", + "MultiEdit": "Правит", + "NotebookEdit": "Блокнот", // Codex tools - "apply_patch": "Modifie", - "update_plan": "Tâches", - "spawn_agent": "Agent", + "apply_patch": "Правит", + "update_plan": "Задачи", + "spawn_agent": "Агент", ] var label = labels[tool] ?? tool @@ -991,19 +991,19 @@ final class HookServer: @unchecked Sendable { return label } - /// Infers a French verb from a shell command's first word. + /// Infers a Russian verb from a shell command's first word. private func bashVerb(_ command: String) -> String { let first = command.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) ?? "" switch first { - case "cat", "bat", "head", "tail", "less", "more", "nl": return "Lit" - case "rg", "grep", "find", "fd", "ls", "tree", "wc": return "Cherche" + case "cat", "bat", "head", "tail", "less", "more", "nl": return "Читает" + case "rg", "grep", "find", "fd", "ls", "tree", "wc": return "Ищет" default: break } let testRunners = ["pytest", "vitest", "jest", "npm test", "npm run test", "cargo test", "go test", "swift test", "make test", "xcodebuild test", "unittest"] - if testRunners.contains(where: { command.contains($0) }) { return "Teste" } - return "Exécute" + if testRunners.contains(where: { command.contains($0) }) { return "Тестирует" } + return "Выполняет" } // MARK: - Live diff helpers @@ -1284,7 +1284,7 @@ final class HookServer: @unchecked Sendable { if clCheck.terminationStatus != 0 { throw NSError(domain: "Coucou", code: 1, userInfo: [NSLocalizedDescriptionKey: - "Command Line Tools are required but not installed. Run: xcode-select --install"]) + "Нужны Command Line Tools, но они не установлены. Выполните: xcode-select --install"]) } if let existing = oldSL, @@ -1332,13 +1332,13 @@ final class HookServer: @unchecked Sendable { // Build a compact diff: show only the statusLine key before → after func slJSON(_ val: [String: Any]?) throws -> String { - guard let v = val else { return "(none)" } + guard let v = val else { return "(нет)" } let d = try JSONSerialization.data(withJSONObject: v, options: [.prettyPrinted, .sortedKeys]) - return String(data: d, encoding: .utf8) ?? "(none)" + return String(data: d, encoding: .utf8) ?? "(нет)" } let before = try slJSON(oldSL) let after = try slJSON(newSL) - return "statusLine\nBefore:\n\(before)\n\nAfter:\n\(after)" + return "statusLine\nБыло:\n\(before)\n\nСтало:\n\(after)" } /// Writes settings.json and commits side effects (call after user confirms). @@ -1511,7 +1511,7 @@ final class HookServer: @unchecked Sendable { let exists = FileManager.default.fileExists(atPath: url.path) if !install && !exists { throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "No Gemini CLI hooks to remove." + NSLocalizedDescriptionKey: "Нет хуков Gemini CLI для удаления." ]) } let current = exists ? try Data(contentsOf: url) : Data() @@ -1527,7 +1527,7 @@ final class HookServer: @unchecked Sendable { let current = (try? Data(contentsOf: url)) ?? Data() guard sha256Hex(current) == fp else { throw NSError(domain: "Coucou", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "~/.gemini/settings.json changed since preview. Refresh and try again." + NSLocalizedDescriptionKey: "~/.gemini/settings.json изменился после предпросмотра. Обновите и попробуйте снова." ]) } try writeJSONFile(data, to: url, suffix: "settings.json") @@ -1540,7 +1540,7 @@ final class HookServer: @unchecked Sendable { label: "~/.gemini/settings.json") if let raw = settings["hooks"], !(raw is [String: Any]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — BroV has not touched it." + NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" имеет неожиданный тип — BroV его не трогал." ]) } let base = hookBase() @@ -1557,7 +1557,7 @@ final class HookServer: @unchecked Sendable { for (geminiEvent, normalizedEvent, timeout) in events { if let raw = hooks[geminiEvent], !(raw is [[String: Any]]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\"[\"\(geminiEvent)\"] has an unexpected type — BroV has not touched it." + NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\"[\"\(geminiEvent)\"] имеет неожиданный тип — BroV его не трогал." ]) } var groups = hooks[geminiEvent] as? [[String: Any]] ?? [] @@ -1581,7 +1581,7 @@ final class HookServer: @unchecked Sendable { label: "~/.gemini/settings.json") if let raw = settings["hooks"], !(raw is [String: Any]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — BroV has not touched it." + NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" имеет неожиданный тип — BroV его не трогал." ]) } if var hooks = settings["hooks"] as? [String: Any] { @@ -1607,7 +1607,7 @@ final class HookServer: @unchecked Sendable { let exists = FileManager.default.fileExists(atPath: url.path) if !install && !exists { throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "No Antigravity hooks to remove." + NSLocalizedDescriptionKey: "Нет хуков Antigravity для удаления." ]) } let current = exists ? try Data(contentsOf: url) : Data() @@ -1623,7 +1623,7 @@ final class HookServer: @unchecked Sendable { let current = (try? Data(contentsOf: url)) ?? Data() guard sha256Hex(current) == fp else { throw NSError(domain: "Coucou", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "~/.gemini/config/hooks.json changed since preview. Refresh and try again." + NSLocalizedDescriptionKey: "~/.gemini/config/hooks.json изменился после предпросмотра. Обновите и попробуйте снова." ]) } try writeJSONFile(data, to: url, suffix: "hooks.json") @@ -1679,12 +1679,12 @@ final class HookServer: @unchecked Sendable { do { data = try Data(contentsOf: url) } catch { throw NSError(domain: "Coucou", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "\(label) cannot be read — BroV has not touched it." + NSLocalizedDescriptionKey: "Не удалось прочитать \(label) — BroV его не трогал." ]) } guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { throw NSError(domain: "Coucou", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "\(label) is not valid JSON — BroV has not touched it." + NSLocalizedDescriptionKey: "\(label) — некорректный JSON, BroV его не трогал." ]) } return obj @@ -1702,7 +1702,7 @@ final class HookServer: @unchecked Sendable { do { try fm.copyItem(at: url, to: backupURL) } catch { throw NSError(domain: "Coucou", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "Could not back up \(url.lastPathComponent): \(error.localizedDescription)" + NSLocalizedDescriptionKey: "Не удалось сделать резервную копию \(url.lastPathComponent): \(error.localizedDescription)" ]) } } @@ -1762,7 +1762,7 @@ final class HookServer: @unchecked Sendable { let exists = FileManager.default.fileExists(atPath: url.path) if !install && !exists { throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "No Codex hooks to remove." + NSLocalizedDescriptionKey: "Нет хуков Codex для удаления." ]) } let current = exists ? try Data(contentsOf: url) : Data() @@ -1778,7 +1778,7 @@ final class HookServer: @unchecked Sendable { let current = (try? Data(contentsOf: url)) ?? Data() guard sha256Hex(current) == fp else { throw NSError(domain: "Coucou", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "~/.codex/hooks.json changed since preview. Refresh and try again." + NSLocalizedDescriptionKey: "~/.codex/hooks.json изменился после предпросмотра. Обновите и попробуйте снова." ]) } try writeJSONFile(data, to: url, suffix: "hooks.json") @@ -1790,7 +1790,7 @@ final class HookServer: @unchecked Sendable { var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json") if let raw = root["hooks"], !(raw is [String: Any]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — BroV has not touched it." + NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" имеет неожиданный тип — BroV его не трогал." ]) } let base = hookBase() @@ -1812,7 +1812,7 @@ final class HookServer: @unchecked Sendable { for (event, timeout, statusMsg) in events { if let raw = hooks[event], !(raw is [[String: Any]]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\"[\"\(event)\"] has an unexpected type — BroV has not touched it." + NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\"[\"\(event)\"] имеет неожиданный тип — BroV его не трогал." ]) } var groups = hooks[event] as? [[String: Any]] ?? [] @@ -1836,7 +1836,7 @@ final class HookServer: @unchecked Sendable { var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json") if let raw = root["hooks"], !(raw is [String: Any]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — BroV has not touched it." + NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" имеет неожиданный тип — BroV его не трогал." ]) } if var hooks = root["hooks"] as? [String: Any] { diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index a8d31fd..63be229 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -66,7 +66,7 @@ struct OverviewView: View { Text({ () -> String in switch agent.source { case .claudeCode: return "Claude Code" - case .agent: return "Agent" + case .agent: return "Агент" case .n8n: return "n8n" } }()) @@ -272,14 +272,14 @@ struct EmptyStateView: View { CardBackground(wash: nil) HStack(spacing: 16) { VStack(alignment: .leading, spacing: 5) { - Text("Nothing running right now.") + Text("Сейчас ничего не запущено.") .font(.system(size: 15, weight: .semibold)) - Text("Drop a file or window, or ask me anything.") + Text("Перетащи файл или окно — или спроси что угодно.") .font(.system(size: 13)) .foregroundColor(Color(hex: "#9398A1")) } Spacer() - PrimaryButton("Ask Claude") { + PrimaryButton("Спросить Claude") { state.view = .prompt } } @@ -300,18 +300,18 @@ struct ApprovalView: View { ZStack { CardBackground(wash: .amber) VStack(alignment: .leading, spacing: 5) { - AgentWho(task: state.focusTask, label: "needs permission") + AgentWho(task: state.focusTask, label: "просит разрешение") CodeBlock(text: approval?.command ?? approval?.tool ?? "…") HStack(spacing: 8) { - SecondaryButton("Deny") { + SecondaryButton("Запретить") { HookServer.shared.sendApprovalDecision("deny") } - PrimaryButton("Allow") { + PrimaryButton("Разрешить") { HookServer.shared.sendApprovalDecision("allow") } // Codex rejects updatedPermissions, so "Always" is not offered if approval?.pillId != "agent_codex" { - SecondaryButton("Always") { + SecondaryButton("Всегда") { HookServer.shared.sendApprovalDecision("always") } } @@ -356,14 +356,14 @@ struct QuestionView: View { VStack(alignment: .leading, spacing: 4) { // Header row: agent name + question counter + "Reply in terminal" link HStack(spacing: 4) { - AgentWho(task: nil, label: "Claude Code is asking") + AgentWho(task: nil, label: "Claude Code спрашивает") Spacer(minLength: 4) if q.questions.count > 1 { Text("\(qi + 1)/\(q.questions.count)") .font(.system(size: 10)) .foregroundColor(Color(hex: "#6B7079")) } - Button("Reply in terminal") { HookServer.shared.sendQuestionAsk() } + Button("Ответить в терминале") { HookServer.shared.sendQuestionAsk() } .buttonStyle(.plain) .font(.system(size: 10)) .foregroundColor(Color(hex: "#6B7079")) @@ -382,7 +382,7 @@ struct QuestionView: View { // Options (wrapping) or "Other…" compact inline row if curOther { HStack(spacing: 6) { - TextField("Your answer…", text: Binding( + TextField("Твой ответ…", text: Binding( get: { qi < otherTexts.count ? otherTexts[qi] : "" }, set: { v in if qi < otherTexts.count { otherTexts[qi] = v } } )) @@ -396,7 +396,7 @@ struct QuestionView: View { .padding(.horizontal, 8).padding(.vertical, 5) .background(Color.white.opacity(0.07)) .clipShape(RoundedRectangle(cornerRadius: 8)) - Button(isLast ? "Send" : "Next") { + Button(isLast ? "Отправить" : "Далее") { commitOtherAndProceed(q: q, qi: qi, isLast: isLast) } .buttonStyle(.plain) @@ -438,14 +438,14 @@ struct QuestionView: View { } } // "Other…" implicit free-text option - SecondaryButton("Other…") { + SecondaryButton("Другое…") { if qi < showOther.count { showOther[qi] = true } } } } // Send/Next — only for multi-select (and not while "Other…" field is open) if isMulti && !curOther { - PrimaryButton(isLast ? "Send" : "Next") { + PrimaryButton(isLast ? "Отправить" : "Далее") { proceedFromQuestion(q: q, qi: qi, isLast: isLast) } .disabled(!canProceed) @@ -521,14 +521,14 @@ struct ErrorView: View { CardBackground(wash: .red) VStack(alignment: .leading, spacing: 5) { AgentWho(task: state.focusTask, label: "n8n") - Text("Workflow stopped.") + Text("Сценарий остановлен.") .font(.system(size: 15, weight: .semibold)) - Text("Gmail node timed out after 30s. Retry or open n8n.") + Text("Узел Gmail не ответил за 30 с. Повтори или открой n8n.") .font(.system(size: 12)) .foregroundColor(Color(hex: "#FF8D97")) HStack(spacing: 8) { - PrimaryButton("Retry") { /* retry */ } - SecondaryButton("Open in n8n") { /* open */ } + PrimaryButton("Повторить") { /* retry */ } + SecondaryButton("Открыть в n8n") { /* open */ } } } .padding(.leading, 116) @@ -548,18 +548,18 @@ struct FinishedView: View { ZStack { CardBackground(wash: .green) VStack(alignment: .leading, spacing: 5) { - AgentWho(task: state.focusTask, label: "Claude Code finished") + AgentWho(task: state.focusTask, label: "Claude Code закончил") Text({ if let fl = state.focusTask?.finalLine { return fl } if let s = state.focusTask?.steps.last(where: { !$0.isDiffStep }) { return s } - return "Session finished" + return "Сессия завершена" }()) .font(.system(size: 15, weight: .semibold)) .lineLimit(1) .truncationMode(.tail) HStack(spacing: 8) { #if !APPSTORE - PrimaryButton("Open terminal") { + PrimaryButton("Открыть терминал") { let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2", "net.kovidgoyal.kitty", "com.mitchellh.ghostty"] let activated = terminalBundleIds.compactMap { id in NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id } @@ -570,7 +570,7 @@ struct FinishedView: View { NotificationCenter.default.post(name: .islandCollapse, object: nil) } #endif - SecondaryButton("OK") { + SecondaryButton("ОК") { NotificationCenter.default.post(name: .islandCollapse, object: nil) } } @@ -632,11 +632,11 @@ struct DiffCardView: View { // Content if diff.tooLarge { - Text("Diff too large") + Text("Слишком большой diff") .font(.system(size: 10.5)) .foregroundColor(Color(hex: "#6B7079")) } else if allLines.isEmpty { - Text("No changes") + Text("Нет изменений") .font(.system(size: 10.5)) .foregroundColor(Color(hex: "#6B7079")) } else { @@ -726,8 +726,8 @@ struct ConfusedView: View { ZStack { CardBackground(wash: .pink) VStack(alignment: .leading, spacing: 5) { - Text("Too many hits at once.").font(.system(size: 15, weight: .semibold)) - Text("Give me a sec — back to work in three seconds.") + Text("Слишком много тычков разом.").font(.system(size: 15, weight: .semibold)) + Text("Дай секунду — вернусь к работе через три секунды.") .font(.system(size: 13)).foregroundColor(Color(hex: "#9398A1")) } .padding(.leading, 128) @@ -768,11 +768,11 @@ struct UploadView: View { center: .bottom, startRadius: 0, endRadius: 200 )) VStack(alignment: .leading, spacing: 8) { - Text("Drop your files here") + Text("Перетащи файлы сюда") .font(.system(size: 13, weight: .medium)) .foregroundColor(state.fileDragOver ? Color(hex: "#34D399") : Color(hex: "#D5D7DB")) HStack(spacing: 6) { - ForEach(["PDF", "Images", "Code", "Docs"], id: \.self) { label in + ForEach(["PDF", "Фото", "Код", "Документы"], id: \.self) { label in Text(label) .font(.system(size: 11)) .padding(.horizontal, 8).padding(.vertical, 3) @@ -879,12 +879,12 @@ struct UploadingView: View { Image(systemName: "checkmark.circle.fill") .font(.system(size: 12)) .foregroundColor(Color(hex: "#34D399")) - Text(" \(state.droppedFile?.name ?? "File")") + Text(" \(state.droppedFile?.name ?? "Файл")") .font(.system(size: 12.5, weight: .semibold)) .foregroundColor(Color(hex: "#34D399")) .lineLimit(1).truncationMode(.middle) } else { - Text("Uploading \(state.droppedFile?.name ?? "file")") + Text("Загружаю \(state.droppedFile?.name ?? "файл")") .font(.system(size: 12.5)) .foregroundColor(Color(hex: "#A9ADB5")) .lineLimit(1).truncationMode(.middle) @@ -915,12 +915,12 @@ struct ChooseView: View { ZStack(alignment: .leading) { CardBackground(wash: nil) VStack(alignment: .leading, spacing: 8) { - let fileName = state.droppedFile?.name ?? "file" - (Text(fileName).font(.system(size: 14, weight: .semibold)) + Text(" is ready.").font(.system(size: 14, weight: .semibold))) - Text("What do you want to do with it?").font(.system(size: 12.5)).foregroundColor(Color(hex: "#9398A1")) + let fileName = state.droppedFile?.name ?? "Файл" + (Text(fileName).font(.system(size: 14, weight: .semibold)) + Text(" готов.").font(.system(size: 14, weight: .semibold))) + Text("Что с ним сделать?").font(.system(size: 12.5)).foregroundColor(Color(hex: "#9398A1")) HStack(spacing: 8) { - PrimaryButton("Ask a question") { state.view = .prompt } - SecondaryButton("Send by email") { state.view = .mail } + PrimaryButton("Задать вопрос") { state.view = .prompt } + SecondaryButton("Отправить почтой") { state.view = .mail } } } .padding(.leading, 98) @@ -944,7 +944,7 @@ struct MailView: View { CardBackground(wash: nil) VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { - Text("New email").font(.system(size: 12, weight: .semibold)) + Text("Новое письмо").font(.system(size: 12, weight: .semibold)) if let name = state.droppedFile?.name { Text("with").font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C")) Text(name).font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C")) @@ -952,8 +952,8 @@ struct MailView: View { } } - MailField(label: "To", placeholder: "address@example.com", text: $to) - MailField(label: "Subject", placeholder: state.droppedFile?.name ?? "Subject", text: $subject) + MailField(label: "Кому", placeholder: "address@example.com", text: $to) + MailField(label: "Тема", placeholder: state.droppedFile?.name ?? "Тема", text: $subject) // Body — TextEditor scrolls internally when text overflows TextEditor(text: $bodyText) @@ -971,11 +971,11 @@ struct MailView: View { } HStack(spacing: 8) { - PrimaryButton(isSending ? "Sending…" : "Send") { + PrimaryButton(isSending ? "Отправка…" : "Отправить") { guard !isSending else { return } sendMail() } - SecondaryButton("Cancel") { state.view = .choose } + SecondaryButton("Отмена") { state.view = .choose } } } .padding(.leading, 92) @@ -986,8 +986,8 @@ struct MailView: View { } private func sendMail() { - guard !to.isEmpty else { statusMsg = "Missing recipient."; return } - let subj = subject.isEmpty ? (state.droppedFile?.name ?? "File") : subject + guard !to.isEmpty else { statusMsg = "Не указан получатель."; return } + let subj = subject.isEmpty ? (state.droppedFile?.name ?? "Файл") : subject // Prefer Resend if API key + sender address are configured let apiKey = KeychainStore.shared.get("resend-api-key") @@ -1006,12 +1006,12 @@ struct MailView: View { await MainActor.run { isSending = false if ok { onSuccess(recipient: recipient) } - else { statusMsg = "Resend error — check API key & sender." } + else { statusMsg = "Ошибка Resend — проверь API-ключ и отправителя." } } } } else if apiKey != nil && fromAddr == nil { // API key set but no sender — guide user instead of silent fallback - statusMsg = "Set sender address in Settings." + statusMsg = "Укажи адрес отправителя в настройках." } else { // No Resend — fallback to Mail sendViaAppleMail(to: to, subject: subj) @@ -1051,7 +1051,7 @@ struct MailView: View { #if APPSTORE // App Store: no AppleScript — use NSSharingService to compose (user sends manually) guard let service = NSSharingService(named: .composeEmail) else { - statusMsg = "Mail not available." + statusMsg = "Mail недоступен." return } var items: [Any] = [bodyText.isEmpty ? " " : bodyText] @@ -1097,14 +1097,14 @@ struct MailView: View { var err: NSDictionary? NSAppleScript(source: script)?.executeAndReturnError(&err) if err == nil { onSuccess(recipient: to) } - else { statusMsg = "Mail error: \(err?["NSAppleScriptErrorMessage"] as? String ?? "unknown")" } + else { statusMsg = "Ошибка Mail: \(err?["NSAppleScriptErrorMessage"] as? String ?? "неизвестно")" } #endif } private func onSuccess(recipient: String) { SoundEngine.shared.play("send") NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.wink) - state.noteMessage = "Email sent to \(recipient)." + state.noteMessage = "Письмо отправлено: \(recipient)." state.view = .note DispatchQueue.main.asyncAfter(deadline: .now() + 2) { NotificationCenter.default.post(name: .islandCollapse, object: nil) @@ -1198,7 +1198,7 @@ struct PromptView: View { .padding(.horizontal, 10) HStack(spacing: 8) { - TextField(state.chatHistory.isEmpty ? "Ask me anything…" : "Continue…", text: $text) + TextField(state.chatHistory.isEmpty ? "Спроси что угодно…" : "Продолжить…", text: $text) .textFieldStyle(.plain) .font(.system(size: 13)) .focused($focused) @@ -1368,7 +1368,7 @@ struct ModelPickerView: View { if state.loadingProviderModels.contains(state.chatProvider) { HStack(spacing: 8) { ProgressView().scaleEffect(0.7) - Text("Loading models…") + Text("Загрузка моделей…") .font(.system(size: 12)) .foregroundColor(Color(hex: "#8A8F98")) } @@ -1476,9 +1476,9 @@ struct SearchingView: View { var label: String { switch state.promptContext { - case .window(_, let title, _): return "Claude is reading \(title)…" - case .file(let name, _): return "Claude is reading \(name)…" - case nil: return "Claude is searching…" + case .window(_, let title, _): return "Claude читает \(title)…" + case .file(let name, _): return "Claude читает \(name)…" + case nil: return "Claude ищет…" } } @@ -1534,17 +1534,17 @@ struct ResultView: View { // The URL comes from the model, which may have read attacker-controlled // files or pages: only plain web links may leave the app. let openURL = safeWebURL(result.items.first?.url) - PrimaryButton("Open") { + PrimaryButton("Открыть") { if let openURL { NSWorkspace.shared.open(openURL) } } .disabled(openURL == nil) .help(openURL?.absoluteString ?? "") - SecondaryButton("Copy") { + SecondaryButton("Копировать") { let text = result.items.map { "\($0.label): \($0.detail)" }.joined(separator: "\n") NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } - SecondaryButton("Close") { state.view = state.tasks.isEmpty ? .empty : .overview } + SecondaryButton("Закрыть") { state.view = state.tasks.isEmpty ? .empty : .overview } } } .padding(.leading, 84) @@ -1726,12 +1726,12 @@ struct IntegrationCardView: View { private var statusLabel: String { #if !APPSTORE if task.id == "integration_music" { - if appState.musicAutomationDenied { return "Automation not allowed" } - if appState.musicPlaying { return "Playing · \(MusicController.shared.trackTitle ?? "Unknown")" } - return "Not playing" + if appState.musicAutomationDenied { return "Автоматизация запрещена" } + if appState.musicPlaying { return "Играет · \(MusicController.shared.trackTitle ?? "Неизвестно")" } + return "Не играет" } #endif - if PillCatalog.definition(for: task.id)?.comingSoon == true { return "Coming soon" } + if PillCatalog.definition(for: task.id)?.comingSoon == true { return "Скоро" } let svcErr = task.id == "integration_stripe" ? appState.stripeError : task.id == "integration_calcom" ? appState.calcomError : nil @@ -1739,12 +1739,12 @@ struct IntegrationCardView: View { let isHooks = task.id == "agent_gemini" || task.id == "agent_antigravity" let isAI = ChatProvider(pillID: task.id) != nil if isConfigured { - if isHooks { return "Hooks installed" } + if isHooks { return "Хуки установлены" } if isAI { let provider = ChatProvider(pillID: task.id)! if provider.isLocal { let model = provider == .ollama ? appState.ollamaChatModel : appState.lmstudioChatModel - return "Connected · \(model)" + return "Подключено · \(model)" } let model: String switch task.id { @@ -1753,16 +1753,16 @@ struct IntegrationCardView: View { case "ai_openai": model = appState.openAIChatModel default: model = "" } - return "Key configured · \(model)" + return "Ключ задан · \(model)" } - return "Connected · loading…" + return "Подключено · загрузка…" } else { - if isHooks { return "Hooks not installed" } + if isHooks { return "Хуки не установлены" } if isAI { let provider = ChatProvider(pillID: task.id)! - return provider.isLocal ? "Not connected" : "Key not configured" + return provider.isLocal ? "Не подключено" : "Ключ не задан" } - return "Key not configured" + return "Ключ не задан" } } @@ -1836,7 +1836,7 @@ struct IntegrationCardView: View { .foregroundColor(Color(hex: "#F5F6F8")) .lineLimit(1).truncationMode(.tail) .layoutPriority(1) - Text(PillCatalog.definition(for: task.id)?.sessionSubtitle ?? "Agent") + Text(PillCatalog.definition(for: task.id)?.sessionSubtitle ?? "Агент") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) .lineLimit(1).truncationMode(.tail) @@ -1870,7 +1870,7 @@ struct IntegrationCardView: View { Text(PillCatalog.definition(for: task.id)?.name ?? task.name) .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) - Text(PillCatalog.definition(for: task.id)?.subtitle ?? "Integration") + Text(PillCatalog.definition(for: task.id)?.subtitle ?? "Интеграция") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) Spacer(minLength: 2) @@ -1890,7 +1890,7 @@ struct IntegrationCardView: View { HStack(spacing: 8) { if task.id == "integration_claude" { - Button("Open Visual Studio Code") { openVSCode() } + Button("Открыть Visual Studio Code") { openVSCode() } .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(hex: task.color).opacity(0.7)) .buttonStyle(.plain) @@ -1898,7 +1898,7 @@ struct IntegrationCardView: View { #if !APPSTORE if let url = NSWorkspace.shared.urlForApplication( withBundleIdentifier: "com.todesktop.230313mzl4w4u92") { - Button("Open Cursor") { + Button("Открыть Cursor") { NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil) } @@ -1911,7 +1911,7 @@ struct IntegrationCardView: View { #if !APPSTORE if let url = NSWorkspace.shared.urlForApplication( withBundleIdentifier: "com.openai.codex") { - Button("Open Codex") { + Button("Открыть Codex") { NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil) } @@ -1922,7 +1922,7 @@ struct IntegrationCardView: View { #endif } else if let provider = ChatProvider(pillID: task.id) { if isConfigured { - Button("Chat with \(task.name)") { + Button("Чат с \(task.name)") { switchChatProvider(provider) } .font(.system(size: 11, weight: .medium)) @@ -1931,12 +1931,12 @@ struct IntegrationCardView: View { } } else if task.id == "integration_music" { #if !APPSTORE - Button("Open Music") { MusicController.shared.openMusic() } + Button("Открыть Музыку") { MusicController.shared.openMusic() } .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(hex: task.color).opacity(0.85)) .buttonStyle(.plain) if appState.musicAutomationDenied { - Button("Open Settings…") { MusicController.shared.openAutomationSettings() } + Button("Открыть настройки…") { MusicController.shared.openAutomationSettings() } .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) .buttonStyle(.plain) @@ -1951,7 +1951,7 @@ struct IntegrationCardView: View { }) { HStack(spacing: 5) { Circle().fill(accent).frame(width: 5, height: 5) - Text(task.steps.first ?? "Workflow") + Text(task.steps.first ?? "Сценарий") .font(.system(size: 11)) .foregroundColor(Color(hex: "#C5C8CD")) .lineLimit(1).truncationMode(.tail) @@ -1966,19 +1966,19 @@ struct IntegrationCardView: View { } .buttonStyle(.plain) } else if let url = openURL { - Button("Open \(task.name)") { NSWorkspace.shared.open(url) } + Button("Открыть \(task.name)") { NSWorkspace.shared.open(url) } .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(hex: task.color).opacity(0.85)) .buttonStyle(.plain) } if task.id == "integration_stripe" && isConfigured { - Button("Refresh") { Task { @MainActor in StripePoller.shared.pollNow() } } + Button("Обновить") { Task { @MainActor in StripePoller.shared.pollNow() } } .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(hex: "#0570DE").opacity(0.85)) .buttonStyle(.plain) } if task.id == "integration_calcom" && isConfigured { - Button("Refresh") { Task { @MainActor in CalcomPoller.shared.pollNow() } } + Button("Обновить") { Task { @MainActor in CalcomPoller.shared.pollNow() } } .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(hex: "#C9956A").opacity(0.85)) .buttonStyle(.plain) @@ -1988,7 +1988,7 @@ struct IntegrationCardView: View { && task.id != "agent_cursor" && task.id != "agent_codex" && task.id != "integration_music" { - Button("Settings…") { + Button("Настройки…") { let section: String switch PillCatalog.definition(for: task.id)?.category { case .workspace, .agent: section = "agents" @@ -2056,7 +2056,7 @@ struct VercelDeploymentListView: View { Text("Vercel") .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) - Text("Deployments") + Text("Деплои") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) } @@ -2171,7 +2171,7 @@ struct VercelDetailView: View { .font(.system(size: 10)) .foregroundColor(Color(hex: "#6B7079")) } - Text(deployment.timeAgo + " ago") + Text(deployment.timeAgo == "только что" ? deployment.timeAgo : deployment.timeAgo + " назад") .font(.system(size: 10)) .foregroundColor(Color(hex: "#6B7079")) } @@ -2225,7 +2225,7 @@ struct ResendCardView: View { Text("Resend") .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) - Text("Emails") + Text("Письма") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) if let total { @@ -2367,7 +2367,7 @@ struct GitHubPulseCardView: View { } .buttonStyle(.plain) } else { - Text("Overview") + Text("Обзор") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) } @@ -2385,13 +2385,13 @@ struct GitHubPulseCardView: View { if n == 0 { return "0" } let failing = pulse.myPRs.filter { $0.ci == .failure }.count let pending = pulse.myPRs.filter { $0.ci == .pending }.count - if failing > 0 { return "\(n) · \(failing) failing" } - if pending > 0 { return "\(n) · running" } + if failing > 0 { return "\(n) · \(failing) с ошибкой" } + if pending > 0 { return "\(n) · идёт" } return "\(n)" }() GitHubStatRow( icon: "arrow.triangle.pull", iconColor: ciColor(prWorst), - label: "My PRs", value: prValue + label: "Мои PR", value: prValue ) { onTapSection(.myPRs) } // To review @@ -2399,7 +2399,7 @@ struct GitHubPulseCardView: View { GitHubStatRow( icon: "eye", iconColor: reviewCount > 0 ? "#8AB4F8" : "#6B7079", - label: "To review", + label: "На ревью", value: "\(reviewCount)" ) { onTapSection(.toReview) } @@ -2409,18 +2409,18 @@ struct GitHubPulseCardView: View { switch mainWorst { case .failure: let n = pulse.mainCI.filter { $0.ci == .failure }.count - return ("xmark.octagon.fill", "#F4505E", "\(n) failing") + return ("xmark.octagon.fill", "#F4505E", "\(n) с ошибкой") case .pending: - return ("checkmark.seal.fill", "#F5A524", "running") + return ("checkmark.seal.fill", "#F5A524", "идёт") case .success: - return ("checkmark.seal.fill", "#22C55E", "all green") + return ("checkmark.seal.fill", "#22C55E", "всё зелёное") case .unknown: - return ("checkmark.seal.fill", "#6B7079", pulse.mainCI.isEmpty ? "no repos" : "unknown") + return ("checkmark.seal.fill", "#6B7079", pulse.mainCI.isEmpty ? "нет репо" : "неизвестно") } }() GitHubStatRow( icon: ciIcon, iconColor: ciIconColor, - label: "Default branch CI", value: ciValue + label: "CI основной ветки", value: ciValue ) { onTapSection(.mainCI) } } .padding(.top, 6) @@ -2479,10 +2479,10 @@ struct GitHubDetailView: View { private var title: String { switch section { - case .myPRs: return "My PRs" - case .toReview: return "To review" - case .mainCI: return "Default branch CI" - case .activity: return "Activity" + case .myPRs: return "Мои PR" + case .toReview: return "На ревью" + case .mainCI: return "CI основной ветки" + case .activity: return "Активность" } } @@ -2530,7 +2530,7 @@ struct GitHubDetailView: View { // List if items.isEmpty && repoItems.isEmpty { - Text("Nothing here") + Text("Пусто") .font(.system(size: 10.5)) .foregroundColor(Color(hex: "#6B7079")) .padding(.top, 8) @@ -2619,16 +2619,15 @@ private struct GitHubActivityDetailContent: View { if let day = hoveredDay { let label: String switch day.count { - case 0: label = "No contributions" - case 1: label = "1 contribution" - default: label = "\(day.count) contributions" + case 0: label = "Нет вкладов" + default: label = "\(day.count) \(ruPlural(day.count, "вклад", "вклада", "вкладов"))" } return "\(activityDateLabel(day.date)) · \(label)" } guard let act = activity else { return "" } let total = activityTotalLabel(act.total) - if let s = stats { return "\(total) past year · \(s.totalRepos) repos" } - return "\(total) past year" + if let s = stats { return "\(total) за год · \(s.totalRepos) репо" } + return "\(total) за год" } var body: some View { @@ -2639,7 +2638,7 @@ private struct GitHubActivityDetailContent: View { HStack(spacing: 3) { Image(systemName: "chevron.left") .font(.system(size: 8, weight: .medium)) - Text("Activity") + Text("Активность") .font(.system(size: 12, weight: .semibold)) } .foregroundColor(Color(hex: "#F5F6F8")) @@ -2691,7 +2690,7 @@ private struct GitHubActivityDetailContent: View { .padding(.leading, 108) .padding(.trailing, 12) } else { - Text("Loading…") + Text("Загрузка…") .font(.system(size: 10.5)) .foregroundColor(Color(hex: "#6B7079")) .padding(.top, 8) @@ -2710,14 +2709,14 @@ private struct GitHubActivityDetailContent: View { guard parts.count == 3, let month = Int(parts[1]), month >= 1 && month <= 12, let day = Int(parts[2]) else { return dateStr } - let months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] - return "\(months[month - 1]) \(day)" + let months = ["янв","фев","мар","апр","мая","июн","июл","авг","сен","окт","ноя","дек"] + return "\(day) \(months[month - 1])" } private func activityTotalLabel(_ n: Int) -> String { let nf = NumberFormatter() nf.numberStyle = .decimal - nf.locale = Locale(identifier: "en_US") + nf.locale = Locale(identifier: "ru_RU") return nf.string(from: NSNumber(value: n)) ?? "\(n)" } } @@ -2771,7 +2770,7 @@ private struct GitHubPRRowView: View { .lineLimit(1) .truncationMode(.tail) if pr.isDraft { - Text("Draft") + Text("Черновик") .font(.system(size: 9.5)) .foregroundColor(Color(hex: "#6B7079")) } @@ -2857,7 +2856,7 @@ struct GitHubStatsCardView: View { Text("GitHub") .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) - Text("Overview") + Text("Обзор") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) } @@ -2868,9 +2867,9 @@ struct GitHubStatsCardView: View { // Stats rows VStack(alignment: .leading, spacing: 5) { StatRow(icon: "star.fill", color: "#F5A524", - label: "Total stars", value: formatCount(stats.totalStars)) + label: "Всего звёзд", value: formatCount(stats.totalStars)) StatRow(icon: "square.stack.fill", color: "#6B7079", - label: "Repositories", value: "\(stats.totalRepos)") + label: "Репозитории", value: "\(stats.totalRepos)") } .padding(.top, 8) .padding(.leading, 108) @@ -2926,7 +2925,7 @@ struct StripeCardView: View { Text("Stripe") .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) - Text("Payments") + Text("Платежи") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) } @@ -2981,7 +2980,7 @@ private struct StripePaymentRow: View { let accent = payment.isSuccess ? Color(hex: "#22C55E") : Color(hex: "#F4505E") HStack(spacing: 5) { Circle().fill(accent).frame(width: 5, height: 5) - Text(payment.description ?? "Payment") + Text(payment.description ?? "Платёж") .font(.system(size: 11)) .foregroundColor(Color(hex: "#C5C8CD")) .lineLimit(1).truncationMode(.tail) @@ -3053,7 +3052,7 @@ struct CalcomCalendarView: View { private let cal = Calendar.current private var navLabel: String { - let f = DateFormatter(); f.dateFormat = "MMMM yyyy" + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "LLLL yyyy" return "\(f.string(from: displayMonth)) Q\(displayHalf)" } @@ -3111,7 +3110,7 @@ struct CalcomCalendarView: View { HStack(spacing: 6) { Circle().fill(Color(hex: "#C9956A")).frame(width: 7, height: 7) Text("Cal.com").font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8")) - Text("Schedule").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C")) + Text("Расписание").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C")) } .padding(.top, 6).padding(.leading, 108).padding(.trailing, 36) @@ -3150,7 +3149,7 @@ private struct CalcomWeekRow: View { private var weekLabel: String { guard let first = week.compactMap({ $0 }).first else { return "" } - let f = DateFormatter(); f.dateFormat = "dd/MM" + let f = DateFormatter(); f.dateFormat = "dd.MM" return f.string(from: first) } @@ -3207,7 +3206,7 @@ struct CalcomDayView: View { .padding(.top, 6).padding(.trailing, 12) if bookings.isEmpty { - Text("No calls scheduled").font(.system(size: 11)).foregroundColor(Color(hex: "#6B7079")) + Text("Звонков нет").font(.system(size: 11)).foregroundColor(Color(hex: "#6B7079")) .padding(.leading, 116).padding(.top, 8) } else { VStack(alignment: .leading, spacing: 3) { @@ -3237,7 +3236,7 @@ struct CalcomDayView: View { .transition(.opacity) } private var dayLabel: String { - let f = DateFormatter(); f.dateFormat = "EEEE d MMMM"; return f.string(from: date) + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "EEEE, d MMMM"; return f.string(from: date) } } @@ -3304,7 +3303,7 @@ struct NotionCardView: View { HStack(spacing: 6) { Circle().fill(Color(hex: "#E8E8E8")).frame(width: 7, height: 7) Text("Notion").font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8")) - Text("Recent").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C")) + Text("Недавние").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C")) } .padding(.top, 6).padding(.leading, 108).padding(.trailing, 36) @@ -3348,7 +3347,7 @@ struct N8nDetailView: View { private var success: Bool { task.state == .finished } private var accent: Color { success ? Color(hex: "#22C55E") : Color(hex: "#F4505E") } - private var statusLabel: String { success ? "Success" : "Failed" } + private var statusLabel: String { success ? "Успех" : "Ошибка" } private var detail: String? { task.steps.dropFirst().first } var body: some View { @@ -3367,7 +3366,7 @@ struct N8nDetailView: View { Circle().fill(accent).frame(width: 6, height: 6) - Text(task.steps.first ?? "Workflow") + Text(task.steps.first ?? "Сценарий") .font(.system(size: 11, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) .lineLimit(1).truncationMode(.middle) @@ -3395,7 +3394,7 @@ struct N8nDetailView: View { } .frame(maxHeight: 88) } else { - Text(success ? "Completed successfully." : "No error details available.") + Text(success ? "Успешно завершено." : "Нет подробностей об ошибке.") .font(.system(size: 11)) .foregroundColor(Color(hex: "#6B7079")) } @@ -3891,13 +3890,13 @@ struct MusicCardView: View { .padding(.leading, 108) .padding(.trailing, 36) - Text("Allow BroV to control Music") + Text("Разреши BroV управлять Музыкой") .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) .padding(.leading, 108) .padding(.trailing, 12) - Button("Open Settings…") { MusicController.shared.openAutomationSettings() } + Button("Открыть настройки…") { MusicController.shared.openAutomationSettings() } .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(hex: "#FA2D48").opacity(0.85)) .buttonStyle(.plain) @@ -4032,8 +4031,8 @@ struct WardrobeView: View { if let h = hoveredOutfit { if h == .auto { let seasonal = Outfit.seasonal(for: Date(), calendar: .current) - let name = seasonal == .none ? "None" : seasonal.displayName - return "Auto · follows the seasons (now: \(name))" + let name = seasonal == .none ? "Нет" : seasonal.displayName + return "Авто · по сезону (сейчас: \(name))" } return h.displayName } @@ -4041,8 +4040,8 @@ struct WardrobeView: View { let sel = state.mochiOutfitSelection if sel == .auto { let seasonal = Outfit.seasonal(for: Date(), calendar: .current) - let name = seasonal == .none ? "None" : seasonal.displayName - return "Auto · \(name)" + let name = seasonal == .none ? "Нет" : seasonal.displayName + return "Авто · \(name)" } return sel.displayName } @@ -4051,7 +4050,7 @@ struct WardrobeView: View { VStack(alignment: .leading, spacing: 0) { // Header HStack(spacing: 6) { - Text("Wardrobe") + Text("Гардероб") .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) Spacer(minLength: 4) @@ -4211,7 +4210,7 @@ private func drawOutfitIcon(context: GraphicsContext, size: CGSize, outfit: Outf badge.addRoundedRect(in: CGRect(x: -bw/2, y: -bh/2, width: bw, height: bh), cornerSize: CGSize(width: bh/2, height: bh/2)) badgeCtx.fill(badge, with: .color(Color.black.opacity(0.60))) - badgeCtx.draw(Text("AUTO").font(.system(size: 4.2, weight: .semibold)).foregroundColor(.white), + badgeCtx.draw(Text("АВТО").font(.system(size: 4.2, weight: .semibold)).foregroundColor(.white), at: .zero) case .none: @@ -4620,7 +4619,7 @@ struct SettingsIslandView: View { .labelsHidden() .scaleEffect(0.75) .frame(width: 44) - Text("Sound") + Text("Звук") .font(.system(size: 12.5)) .foregroundColor(Color(hex: "#C5C8CD")) Slider(value: $state.soundVolume, in: 0...0.2) @@ -4634,13 +4633,13 @@ struct SettingsIslandView: View { .font(.system(size: 12)) .foregroundColor(Color(hex: "#8E939C")) .frame(width: 16) - Text("Auto-close · \(Int(state.autoCloseInterval))s") + Text("Автозакрытие · \(Int(state.autoCloseInterval)) с") .font(.system(size: 12)) .foregroundColor(Color(hex: "#C5C8CD")) Spacer() HStack(spacing: 6) { ForEach([10, 15, 30], id: \.self) { s in - Button("\(s)s") { + Button("\(s) с") { state.autoCloseInterval = Double(s) } .font(.system(size: 11)) @@ -4658,7 +4657,7 @@ struct SettingsIslandView: View { StatusBadge(label: "Claude Code", ok: claudeConnected) StatusBadge(label: "API", ok: apiConnected) Spacer() - Button("Settings…") { + Button("Настройки…") { NotificationCenter.default.post(name: .openFullSettings, object: nil) } .font(.system(size: 11.5)) @@ -4718,3 +4717,11 @@ extension Color { ) } } + +/// Russian plural form picker: 1 вклад, 2 вклада, 5 вкладов. +fileprivate func ruPlural(_ n: Int, _ one: String, _ few: String, _ many: String) -> String { + let n10 = abs(n) % 10, n100 = abs(n) % 100 + if n10 == 1 && n100 != 11 { return one } + if (2...4).contains(n10) && !(12...14).contains(n100) { return few } + return many +} diff --git a/NotchBuddy/Sources/App/LocalChat.swift b/NotchBuddy/Sources/App/LocalChat.swift index 0bb3e0a..cdaaad7 100644 --- a/NotchBuddy/Sources/App/LocalChat.swift +++ b/NotchBuddy/Sources/App/LocalChat.swift @@ -12,8 +12,8 @@ enum LocalChatError: Error { var localizedDescription: String { switch self { - case .serverUnreachable(let url): return "Cannot reach \(url). Is the server running?" - case .modelNotFound(let model): return "Model '\(model)' is not installed." + case .serverUnreachable(let url): return "Нет связи с \(url). Сервер запущен?" + case .modelNotFound(let model): return "Модель «\(model)» не установлена." case .serverError(let msg): return msg } } diff --git a/NotchBuddy/Sources/App/N8nPoller.swift b/NotchBuddy/Sources/App/N8nPoller.swift index 3ce76e4..859806d 100644 --- a/NotchBuddy/Sources/App/N8nPoller.swift +++ b/NotchBuddy/Sources/App/N8nPoller.swift @@ -195,7 +195,7 @@ final class N8nPoller: @unchecked Sendable { let items = main.first else { return nil } let count = items.count - let header = "→ \(lastNode) · \(count) item\(count == 1 ? "" : "s")" + let header = "→ \(lastNode) · \(count) \(ruItems(count))" // Preview first item's JSON keys (up to 4) if let firstItem = items.first, @@ -257,3 +257,11 @@ final class N8nPoller: @unchecked Sendable { appendAppLog("n8n.log", message, timestampFormat: "HH:mm:ss") } } + +/// Russian plural for "элемент": 1 элемент, 2 элемента, 5 элементов. +fileprivate func ruItems(_ n: Int) -> String { + let mod10 = n % 10, mod100 = n % 100 + if mod10 == 1 && mod100 != 11 { return "элемент" } + if (2...4).contains(mod10) && !(12...14).contains(mod100) { return "элемента" } + return "элементов" +} diff --git a/NotchBuddy/Sources/App/NotionPoller.swift b/NotchBuddy/Sources/App/NotionPoller.swift index 1e6b7c3..975dbc7 100644 --- a/NotchBuddy/Sources/App/NotionPoller.swift +++ b/NotchBuddy/Sources/App/NotionPoller.swift @@ -35,9 +35,9 @@ final class NotionPoller: @unchecked Sendable { let code = (response as? HTTPURLResponse)?.statusCode ?? 0 if code != 200 { let msg: String - if code == 401 { msg = "Invalid API key (401)" } - else if code == 0 { msg = error?.localizedDescription ?? "No connection" } - else { msg = "API error \(code)" } + if code == 401 { msg = "Неверный API-ключ (401)" } + else if code == 0 { msg = error?.localizedDescription ?? "Нет соединения" } + else { msg = "Ошибка API \(code)" } DispatchQueue.main.async { AppState.shared.notionError = msg } return } @@ -58,7 +58,7 @@ final class NotionPoller: @unchecked Sendable { guard let id = obj["id"] as? String else { return nil } let objType = obj["object"] as? String ?? "page" - var title = "Untitled" + var title = "Без названия" if objType == "database" { if let arr = obj["title"] as? [[String: Any]], let text = arr.first?["plain_text"] as? String, !text.isEmpty { diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index dbe70f4..81c8f05 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -31,6 +31,8 @@ struct SettingsView: View { @State private var showDiff: Bool = false @State private var pendingHookJSON: String = "" @State private var hookNeedsUpdate: Bool = HookServer.hooksNeedUpdate() + /// Inline success text shown right under each hooks GroupBox's buttons (key: "claude", "gemini", "agy", "codex", "statusline"). + @State private var hookResult: [String: String] = [:] #if !APPSTORE @State private var showStatusLineDiff: Bool = false @@ -134,12 +136,12 @@ struct SettingsView: View { get: { Optional(selectedSection) }, set: { if let v = $0 { selectedSection = v; statusMessage = "" } } )) { - SettingsSidebarRow(title: "General", icon: "gearshape.fill", color: "#8E939C").tag("general") - SettingsSidebarRow(title: "Active pills", icon: "square.grid.2x2.fill", color: "#F5A524").tag("activepills") - SettingsSidebarRow(title: "Agents", icon: "terminal.fill", color: "#3B9EFF").tag("agents") - SettingsSidebarRow(title: "Chat", icon: "bubble.left.and.bubble.right.fill", color: "#E07950").tag("chat") - SettingsSidebarRow(title: "Integrations", icon: "puzzlepiece.extension.fill", color: "#7C5CFF").tag("integrations") - SettingsSidebarRow(title: "Shortcuts", icon: "keyboard.fill", color: "#6366F1").tag("shortcuts") + SettingsSidebarRow(title: "Основные", icon: "gearshape.fill", color: "#8E939C").tag("general") + SettingsSidebarRow(title: "Плашки", icon: "square.grid.2x2.fill", color: "#F5A524").tag("activepills") + SettingsSidebarRow(title: "Агенты", icon: "terminal.fill", color: "#3B9EFF").tag("agents") + SettingsSidebarRow(title: "Чат", icon: "bubble.left.and.bubble.right.fill", color: "#E07950").tag("chat") + SettingsSidebarRow(title: "Интеграции", icon: "puzzlepiece.extension.fill", color: "#7C5CFF").tag("integrations") + SettingsSidebarRow(title: "Сочетания", icon: "keyboard.fill", color: "#6366F1").tag("shortcuts") } .listStyle(.sidebar) .scrollContentBackground(.hidden) @@ -202,13 +204,13 @@ struct SettingsView: View { private var sectionTitle: String { switch selectedSection { - case "general": return "General" - case "activepills": return "Active pills" - case "agents": return "Agents" - case "chat": return "Chat" - case "integrations": return "Integrations" - case "shortcuts": return "Shortcuts" - default: return "General" + case "general": return "Основные" + case "activepills": return "Плашки" + case "agents": return "Агенты" + case "chat": return "Чат" + case "integrations": return "Интеграции" + case "shortcuts": return "Сочетания клавиш" + default: return "Основные" } } @@ -226,11 +228,11 @@ struct SettingsView: View { // MARK: - General section @ViewBuilder private var generalSection: some View { - GroupBox("Sound") { + GroupBox("Звук") { VStack(alignment: .leading, spacing: 10) { - Toggle("Enable sounds", isOn: $state.soundEnabled) + Toggle("Включить звуки", isOn: $state.soundEnabled) HStack(spacing: 8) { - Text("Volume") + Text("Громкость") .frame(width: 56, alignment: .leading) Slider(value: $state.soundVolume, in: 0...0.2) .disabled(!state.soundEnabled) @@ -242,35 +244,35 @@ struct SettingsView: View { .padding(6) } - GroupBox("Behavior") { + GroupBox("Поведение") { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { - Text("Close after") + Text("Закрывать через") TextField("60", value: $state.autoCloseInterval, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 64) - Text("s inactive") + Text("с бездействия") } HStack(spacing: 8) { - Text("Hide after") + Text("Скрывать через") TextField("3", value: absenceMinutes, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 48) - Text("min without movement") + Text("мин без движения") } } .padding(6) } - GroupBox("Hotkey") { + GroupBox("Горячая клавиша") { VStack(alignment: .leading, spacing: 10) { - Toggle("Show island with shortcut", isOn: $state.hotkeyEnabled) + Toggle("Показывать остров по сочетанию", isOn: $state.hotkeyEnabled) .onChange(of: state.hotkeyEnabled) { _, _ in HotKeyCenter.shared.reregister(.toggleIsland) } if state.hotkeyEnabled { HStack(spacing: 8) { - Text("Shortcut") + Text("Сочетание") .frame(width: 70, alignment: .leading) ShortcutRecorderButton(flags: $hotkeyFlags, code: $hotkeyCode) .onChange(of: hotkeyFlags) { _, v in @@ -281,7 +283,7 @@ struct SettingsView: View { state.hotkeyCode = v HotKeyCenter.shared.reregister(.toggleIsland) } - Text("presses this → island opens") + Text("нажатие → остров открывается") .font(.system(size: 11)) .foregroundColor(.secondary) } @@ -290,8 +292,8 @@ struct SettingsView: View { .padding(6) } - GroupBox("Startup") { - Toggle("Launch at Mac startup", isOn: $launchAtStartup) + GroupBox("Запуск") { + Toggle("Запускать при входе в систему", isOn: $launchAtStartup) .onChange(of: launchAtStartup) { _, on in toggleStartup(on) } .padding(6) } @@ -299,24 +301,24 @@ struct SettingsView: View { #if PHONE_LINK GroupBox("iPhone") { VStack(alignment: .leading, spacing: 6) { - Toggle("Show my agent sessions on my iPhone", isOn: $iPhoneSyncEnabled) + Toggle("Показывать сессии агентов на iPhone", isOn: $iPhoneSyncEnabled) .onChange(of: iPhoneSyncEnabled) { _, on in CloudProbe.shared.setEnabled(on) } - Text("Sends your sessions to your private iCloud for the BroV iPhone app. Project names, commands and questions are encrypted with your iCloud keys. Turning it off deletes them from iCloud.") + Text("Отправляет сессии в личный iCloud для приложения BroV на iPhone. Названия проектов, команды и вопросы шифруются ключами iCloud. При отключении они удаляются из iCloud.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - Toggle("Move Mochi to my iPhone's Dynamic Island when my Mac is locked", isOn: $iPhoneLiveActivityEnabled) + Toggle("Переносить BroV в Dynamic Island на iPhone, когда Mac заблокирован", isOn: $iPhoneLiveActivityEnabled) .disabled(!iPhoneSyncEnabled) .onChange(of: iPhoneLiveActivityEnabled) { _, on in LiveActivityRelay.shared.setEnabled(on) } - Text("Goes through the BroV relay to Apple's push service. Only the agent's name and state are sent: no project name, command or path.") + Text("Идёт через ретранслятор BroV в push-сервис Apple. Передаются только имя и состояние агента: без названия проекта, команд и путей.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) #if !APPSTORE - Toggle("Let my iPhone send instructions to Claude Code", isOn: $iPhoneInstructionsEnabled) + Toggle("Разрешить iPhone отправлять инструкции в Claude Code", isOn: $iPhoneInstructionsEnabled) .disabled(!iPhoneSyncEnabled) .onChange(of: iPhoneInstructionsEnabled) { _, on in InstructionRunner.shared.setEnabled(on) } - Text("An instruction sent from the iPhone (Face ID required) continues your last Claude Code session in the background, in its folder, with claude --resume. This Mac checks for one every 15 s while this is on.") + Text("Инструкция с iPhone (нужен Face ID) продолжает последнюю сессию Claude Code в фоне, в её папке, через claude --resume. Пока опция включена, Mac проверяет новые инструкции каждые 15 с.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -332,15 +334,15 @@ struct SettingsView: View { @ViewBuilder private var activePillsSection: some View { GroupBox { VStack(alignment: .leading, spacing: 10) { - Text("Choose the tools you use. BroV only shows what you declare here.") + Text("Выберите инструменты, которыми пользуетесь. BroV показывает только то, что отмечено здесь.") .font(.system(size: 11)) .foregroundColor(.secondary) - Text("\(state.activeIntegrations.count)/4 slots used") + Text("Занято слотов: \(state.activeIntegrations.count)/4") .font(.system(size: 11)) .foregroundColor(state.activeIntegrations.count >= 4 ? .orange : .secondary) - Picker("Main", selection: $state.mainPillId) { + Picker("Основная", selection: $state.mainPillId) { ForEach(PillCatalog.available.filter { $0.category == .workspace && !$0.comingSoon }, id: \.id) { def in Text(def.name).tag(def.id) } @@ -371,20 +373,20 @@ struct SettingsView: View { // MARK: - Agents section @ViewBuilder private var agentsSection: some View { - GroupBox("Claude Code Hooks") { + GroupBox("Хуки Claude Code") { VStack(alignment: .leading, spacing: 10) { if hookNeedsUpdate { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) - Text("Hooks outdated — update them to answer Claude's questions from the notch") + Text("Хуки устарели — обновите их, чтобы отвечать на вопросы Claude из выреза") .font(.system(size: 11)) .foregroundColor(.orange) } #if APPSTORE - Button("Update hooks") { installHooksAppStore() } + Button("Обновить хуки") { installHooksAppStore() } #else - Button("Update hooks") { installHooks() } + Button("Обновить хуки") { installHooks() } #endif } #if APPSTORE @@ -392,9 +394,9 @@ struct SettingsView: View { .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { - Button("Install hooks") { installHooksAppStore() } + Button("Установить хуки") { installHooksAppStore() } .buttonStyle(.borderedProminent) - Button("Uninstall") { uninstallHooksAppStore() } + Button("Удалить") { uninstallHooksAppStore() } .buttonStyle(.bordered) } #else @@ -402,12 +404,13 @@ struct SettingsView: View { .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { - Button("Install hooks") { installHooks() } + Button("Установить хуки") { installHooks() } .buttonStyle(.borderedProminent) - Button("Uninstall") { uninstallHooks() } + Button("Удалить") { uninstallHooks() } .buttonStyle(.bordered) } #endif + hookResultText("claude") #if !APPSTORE if showDiff { @@ -421,9 +424,9 @@ struct SettingsView: View { .cornerRadius(6) HStack { - Button("Confirm & write") { confirmInstall() } + Button("Подтвердить и записать") { confirmInstall() } .buttonStyle(.borderedProminent) - Button("Cancel") { showDiff = false; pendingHookJSON = "" } + Button("Отмена") { showDiff = false; pendingHookJSON = "" } .buttonStyle(.bordered) } } @@ -433,19 +436,20 @@ struct SettingsView: View { } #if !APPSTORE - GroupBox("Gemini CLI Hooks") { + GroupBox("Хуки Gemini CLI") { VStack(alignment: .leading, spacing: 10) { Text(geminiHooksInstalled - ? "Hooks installed — restart Gemini CLI to activate" + ? "Хуки установлены — перезапустите Gemini CLI" : "~/.gemini/settings.json") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { - Button("Install hooks") { triggerGeminiPreview(install: true) } + Button("Установить хуки") { triggerGeminiPreview(install: true) } .buttonStyle(.borderedProminent) - Button("Uninstall") { triggerGeminiPreview(install: false) } + Button("Удалить") { triggerGeminiPreview(install: false) } .buttonStyle(.bordered) } + hookResultText("gemini") if showGeminiDiff { ScrollView { Text(pendingGeminiJSON) @@ -456,9 +460,9 @@ struct SettingsView: View { .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { - Button("Confirm & write") { confirmGeminiOp() } + Button("Подтвердить и записать") { confirmGeminiOp() } .buttonStyle(.borderedProminent) - Button("Cancel") { showGeminiDiff = false; pendingGeminiJSON = "" } + Button("Отмена") { showGeminiDiff = false; pendingGeminiJSON = "" } .buttonStyle(.bordered) } } @@ -466,19 +470,20 @@ struct SettingsView: View { .padding(6) } - GroupBox("Antigravity Hooks") { + GroupBox("Хуки Antigravity") { VStack(alignment: .leading, spacing: 10) { Text(agyHooksInstalled - ? "Hooks installed — restart Antigravity to activate" + ? "Хуки установлены — перезапустите Antigravity" : "~/.gemini/config/hooks.json") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { - Button("Install hooks") { triggerAgyPreview(install: true) } + Button("Установить хуки") { triggerAgyPreview(install: true) } .buttonStyle(.borderedProminent) - Button("Uninstall") { triggerAgyPreview(install: false) } + Button("Удалить") { triggerAgyPreview(install: false) } .buttonStyle(.bordered) } + hookResultText("agy") if showAgyDiff { ScrollView { Text(pendingAgyJSON) @@ -489,9 +494,9 @@ struct SettingsView: View { .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { - Button("Confirm & write") { confirmAgyOp() } + Button("Подтвердить и записать") { confirmAgyOp() } .buttonStyle(.borderedProminent) - Button("Cancel") { showAgyDiff = false; pendingAgyJSON = "" } + Button("Отмена") { showAgyDiff = false; pendingAgyJSON = "" } .buttonStyle(.bordered) } } @@ -499,19 +504,20 @@ struct SettingsView: View { .padding(6) } - GroupBox("Codex Hooks") { + GroupBox("Хуки Codex") { VStack(alignment: .leading, spacing: 10) { Text(codexHooksInstalled - ? "Hooks installed — open Codex and run /hooks or open Hooks in the app's settings to trust them" + ? "Хуки установлены — в Codex выполните /hooks или откройте Hooks в настройках, чтобы доверить им" : "~/.codex/hooks.json") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { - Button("Install hooks") { triggerCodexPreview(install: true) } + Button("Установить хуки") { triggerCodexPreview(install: true) } .buttonStyle(.borderedProminent) - Button("Uninstall") { triggerCodexPreview(install: false) } + Button("Удалить") { triggerCodexPreview(install: false) } .buttonStyle(.bordered) } + hookResultText("codex") if showCodexDiff { ScrollView { Text(pendingCodexJSON) @@ -522,9 +528,9 @@ struct SettingsView: View { .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { - Button("Confirm & write") { confirmCodexOp() } + Button("Подтвердить и записать") { confirmCodexOp() } .buttonStyle(.borderedProminent) - Button("Cancel") { showCodexDiff = false; pendingCodexJSON = "" } + Button("Отмена") { showCodexDiff = false; pendingCodexJSON = "" } .buttonStyle(.bordered) } } @@ -532,13 +538,13 @@ struct SettingsView: View { .padding(6) } - GroupBox("Plan usage") { + GroupBox("Лимиты плана") { VStack(alignment: .leading, spacing: 10) { - Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch header. BroV adds a status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.") + Text("Показывает расход плана Claude (5-часовой и недельный лимиты) в шапке выреза. BroV добавляет ретранслятор строки состояния в ~/.claude/settings.json. Если строка состояния уже есть, она продолжит работать как раньше. Только для планов Pro и Max.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - Toggle("Show in the notch", isOn: Binding( + Toggle("Показывать в вырезе", isOn: Binding( get: { state.showPlanInNotch || planTogglePending }, set: { on in if on { @@ -556,19 +562,20 @@ struct SettingsView: View { )) HStack(spacing: 10) { if state.planRelayInstalled { - Text("Relay: installed") + Text("Ретранслятор: установлен") .font(.system(size: 11)) .foregroundColor(.secondary) - Button("Uninstall relay") { uninstallStatusLine() } + Button("Удалить ретранслятор") { uninstallStatusLine() } .buttonStyle(.bordered) } else { - Text("Relay: not installed") + Text("Ретранслятор: не установлен") .font(.system(size: 11)) .foregroundColor(.secondary) - Button("Install relay") { installStatusLine() } + Button("Установить ретранслятор") { installStatusLine() } .buttonStyle(.borderedProminent) } } + hookResultText("statusline") if showStatusLineDiff { ScrollView { Text(pendingStatusLineJSON) @@ -579,9 +586,9 @@ struct SettingsView: View { .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { - Button("Confirm & write") { confirmStatusLine() } + Button("Подтвердить и записать") { confirmStatusLine() } .buttonStyle(.borderedProminent) - Button("Cancel") { + Button("Отмена") { showStatusLineDiff = false pendingStatusLineJSON = "" planTogglePending = false @@ -598,12 +605,12 @@ struct SettingsView: View { // MARK: - Chat section @ViewBuilder private var chatSection: some View { - GroupBox("Claude Code (your subscription, no API key)") { + GroupBox("Claude Code (ваша подписка, без API-ключа)") { VStack(alignment: .leading, spacing: 8) { HStack { - TextField("Folder the chat runs in", text: $state.chatWorkingDirectory) + TextField("Папка, в которой работает чат", text: $state.chatWorkingDirectory) .textFieldStyle(.roundedBorder) - Button("Choose…") { + Button("Выбрать…") { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false @@ -614,24 +621,24 @@ struct SettingsView: View { } } } - Picker("Model", selection: $state.claudeCodeModel) { + Picker("Модель", selection: $state.claudeCodeModel) { ForEach(ClaudeCodeCLI.models, id: \.id) { m in Text(m.label).tag(m.id) } } - Picker("Permissions", selection: $state.claudePermissionMode) { - Text("Ask in the notch").tag("default") - Text("Accept edits").tag("acceptEdits") - Text("Plan only").tag("plan") - Text("Bypass (dangerous)").tag("bypassPermissions") + Picker("Разрешения", selection: $state.claudePermissionMode) { + Text("Спрашивать в вырезе").tag("default") + Text("Принимать правки").tag("acceptEdits") + Text("Только план").tag("plan") + Text("Без проверок (опасно)").tag("bypassPermissions") } - TextField("Path to claude (empty = auto)", text: $state.claudeBinaryPath) + TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath) .textFieldStyle(.roundedBorder) let found = ClaudeCodeCLI.resolveBinary(custom: state.claudeBinaryPath) - Text(found.map { "✓ Using \($0)" } ?? "✗ claude not found — install Claude Code or set the path.") + Text(found.map { "✓ Используется \($0)" } ?? "✗ claude не найден — установите Claude Code или укажите путь.") .font(.system(size: 11)) .foregroundColor(found == nil ? .red : .secondary) - Text("The chat runs `claude -p` in this folder and keeps one conversation until you start a new one. Your MCP servers, skills and CLAUDE.md come along.") + Text("Чат запускает `claude -p` в этой папке и ведёт один диалог, пока вы не начнёте новый. Ваши MCP-серверы, скиллы и CLAUDE.md подключаются автоматически.") .font(.system(size: 11)) .foregroundColor(.secondary) } @@ -640,21 +647,21 @@ struct SettingsView: View { GroupBox("Anthropic API") { VStack(alignment: .leading, spacing: 8) { - SecureField("API key (sk-ant-…)", text: $apiKey) + SecureField("API-ключ (sk-ant-…)", text: $apiKey) .textFieldStyle(.roundedBorder) - Button("Save") { + Button("Сохранить") { KeychainStore.shared.set("anthropic-api-key", value: apiKey) - statusMessage = "✓ Key saved." + statusMessage = "✓ Ключ сохранён." } .buttonStyle(.borderedProminent) Divider().padding(.vertical, 2) - Picker("Model", selection: $modelChoice) { + Picker("Модель", selection: $modelChoice) { ForEach(displayModels, id: \.id) { preset in Text(preset.label).tag(preset.id) } - Text("Custom…").tag(Self.customModelTag) + Text("Своя…").tag(Self.customModelTag) } .onChange(of: modelChoice) { _, choice in if choice != Self.customModelTag { @@ -665,21 +672,21 @@ struct SettingsView: View { } if modelChoice == Self.customModelTag { - TextField("Model ID (e.g. claude-sonnet-4-6)", text: $customModel) + TextField("ID модели (напр. claude-sonnet-4-6)", text: $customModel) .textFieldStyle(.roundedBorder) .onChange(of: customModel) { _, value in applyCustomModel(value) } } - Text("Used by the chat. The list comes from your Anthropic account.") + Text("Используется в чате. Список загружается из аккаунта Anthropic.") .font(.system(size: 11)) .foregroundColor(.secondary) } .padding(6) } - GroupBox("Chat — other providers") { + GroupBox("Чат — другие провайдеры") { VStack(alignment: .leading, spacing: 12) { - Text("To use Google Gemini or OpenAI from the chat. Keys are stored in the Keychain.") + Text("Для работы с Google Gemini или OpenAI из чата. Ключи хранятся в Связке ключей.") .font(.system(size: 12)) .foregroundColor(.secondary) @@ -687,11 +694,11 @@ struct SettingsView: View { Circle().fill(Color(hex: "#4285F4")).frame(width: 8, height: 8) Text("Google AI").font(.system(size: 12, weight: .semibold)) } - SecureField("API key (AI Studio)", text: $googleKey) + SecureField("API-ключ (AI Studio)", text: $googleKey) .textFieldStyle(.roundedBorder) - Button("Save") { + Button("Сохранить") { KeychainStore.shared.set("google-api-key", value: googleKey) - statusMessage = "✓ Google key saved." + statusMessage = "✓ Ключ Google сохранён." } .buttonStyle(.borderedProminent) @@ -701,20 +708,20 @@ struct SettingsView: View { Circle().fill(Color(hex: "#10A37F")).frame(width: 8, height: 8) Text("OpenAI").font(.system(size: 12, weight: .semibold)) } - SecureField("API key (sk-…)", text: $openAIKey) + SecureField("API-ключ (sk-…)", text: $openAIKey) .textFieldStyle(.roundedBorder) - Button("Save") { + Button("Сохранить") { KeychainStore.shared.set("openai-api-key", value: openAIKey) - statusMessage = "✓ OpenAI key saved." + statusMessage = "✓ Ключ OpenAI сохранён." } .buttonStyle(.borderedProminent) } .padding(.vertical, 4) } - GroupBox("Local models") { + GroupBox("Локальные модели") { VStack(alignment: .leading, spacing: 12) { - Text("Connect to a local model server. No API key needed.") + Text("Подключение к локальному серверу моделей. API-ключ не нужен.") .font(.system(size: 12)) .foregroundColor(.secondary) @@ -723,7 +730,7 @@ struct SettingsView: View { Circle().fill(Color(hex: "#FACC15")).frame(width: 8, height: 8) Text("Ollama").font(.system(size: 12, weight: .semibold)) if !state.ollamaServerURL.isEmpty { - Text("Connected") + Text("Подключено") .font(.system(size: 10)) .foregroundColor(Color(hex: "#22C55E")) } @@ -731,7 +738,7 @@ struct SettingsView: View { if state.ollamaServerURL.isEmpty { TextField("http://127.0.0.1:11434", text: $ollamaURL) .textFieldStyle(.roundedBorder) - Button(connectingOllama ? "Connecting…" : "Connect") { + Button(connectingOllama ? "Подключение…" : "Подключить") { Task { await connectLocal(provider: .ollama) } } .buttonStyle(.borderedProminent) @@ -740,13 +747,13 @@ struct SettingsView: View { Text(state.ollamaServerURL) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) - Button("Disconnect") { + Button("Отключить") { state.ollamaServerURL = "" ollamaURL = "" state.fetchedProviderModels[.ollama] = nil state.providerModelFetchError[.ollama] = nil if state.chatProvider == .ollama { state.chatProvider = .claudeCode } - statusMessage = "Ollama disconnected." + statusMessage = "Ollama отключена." } .buttonStyle(.bordered) } @@ -758,7 +765,7 @@ struct SettingsView: View { Circle().fill(Color(hex: "#A3E635")).frame(width: 8, height: 8) Text("LM Studio").font(.system(size: 12, weight: .semibold)) if !state.lmstudioServerURL.isEmpty { - Text("Connected") + Text("Подключено") .font(.system(size: 10)) .foregroundColor(Color(hex: "#22C55E")) } @@ -766,7 +773,7 @@ struct SettingsView: View { if state.lmstudioServerURL.isEmpty { TextField("http://127.0.0.1:1234", text: $lmstudioURL) .textFieldStyle(.roundedBorder) - Button(connectingLMStudio ? "Connecting…" : "Connect") { + Button(connectingLMStudio ? "Подключение…" : "Подключить") { Task { await connectLocal(provider: .lmstudio) } } .buttonStyle(.borderedProminent) @@ -775,13 +782,13 @@ struct SettingsView: View { Text(state.lmstudioServerURL) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) - Button("Disconnect") { + Button("Отключить") { state.lmstudioServerURL = "" lmstudioURL = "" state.fetchedProviderModels[.lmstudio] = nil state.providerModelFetchError[.lmstudio] = nil if state.chatProvider == .lmstudio { state.chatProvider = .claudeCode } - statusMessage = "LM Studio disconnected." + statusMessage = "LM Studio отключена." } .buttonStyle(.bordered) } @@ -802,9 +809,9 @@ struct SettingsView: View { Circle().fill(Color(hex: "#22C55E")).frame(width: 8, height: 8) Text("Resend").font(.system(size: 12, weight: .semibold)) } - SecureField("API key (re_…)", text: $resendKey) + SecureField("API-ключ (re_…)", text: $resendKey) .textFieldStyle(.roundedBorder) - TextField("From address (you@yourdomain.com)", text: $resendFrom) + TextField("Адрес отправителя (you@yourdomain.com)", text: $resendFrom) .textFieldStyle(.roundedBorder) } @@ -814,12 +821,12 @@ struct SettingsView: View { Circle().fill(Color(hex: "#F29B38")).frame(width: 8, height: 8) Text("n8n").font(.system(size: 12, weight: .semibold)) } - TextField("Instance URL (https://…)", text: $n8nUrl) + TextField("URL инстанса (https://…)", text: $n8nUrl) .textFieldStyle(.roundedBorder) - SecureField("API key", text: $n8nKey) + SecureField("API-ключ", text: $n8nKey) .textFieldStyle(.roundedBorder) IntegrationFilterRow( - label: "Workflows", + label: "Воркфлоу", items: n8nWorkflows, filter: $state.n8nWorkflowFilter, loading: loadingN8n, @@ -833,10 +840,10 @@ struct SettingsView: View { Circle().fill(Color(hex: "#7C5CFF")).frame(width: 8, height: 8) Text("Vercel").font(.system(size: 12, weight: .semibold)) } - SecureField("Token", text: $vercelToken) + SecureField("Токен", text: $vercelToken) .textFieldStyle(.roundedBorder) IntegrationFilterRow( - label: "Projects", + label: "Проекты", items: vercelProjects, filter: $state.vercelProjectFilter, loading: loadingVercel, @@ -850,9 +857,9 @@ struct SettingsView: View { Circle().fill(Color(hex: "#F4505E")).frame(width: 8, height: 8) Text("GitHub").font(.system(size: 12, weight: .semibold)) } - SecureField("Personal Access Token", text: $githubToken) + SecureField("Персональный токен доступа", text: $githubToken) .textFieldStyle(.roundedBorder) - Text("Classic token with repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions.") + Text("Классический токен с правом repo или fine-grained с доступом на чтение к Pull requests, Commit statuses и Actions.") .font(.system(size: 10)) .foregroundColor(Color(hex: "#8E939C")) } @@ -863,7 +870,7 @@ struct SettingsView: View { Circle().fill(Color(hex: "#0570DE")).frame(width: 8, height: 8) Text("Stripe").font(.system(size: 12, weight: .semibold)) } - SecureField("Secret key (sk_live_… or sk_test_…)", text: $stripeKey) + SecureField("Секретный ключ (sk_live_… или sk_test_…)", text: $stripeKey) .textFieldStyle(.roundedBorder) } @@ -873,7 +880,7 @@ struct SettingsView: View { Circle().fill(Color(hex: "#C9956A")).frame(width: 8, height: 8) Text("Cal.com").font(.system(size: 12, weight: .semibold)) } - SecureField("API key (cal_live_…)", text: $calcomKey) + SecureField("API-ключ (cal_live_…)", text: $calcomKey) .textFieldStyle(.roundedBorder) } @@ -883,17 +890,26 @@ struct SettingsView: View { Circle().fill(Color(hex: "#E8E8E8")).frame(width: 8, height: 8) Text("Notion").font(.system(size: 12, weight: .semibold)) } - SecureField("Integration token (secret_…)", text: $notionKey) + SecureField("Токен интеграции (secret_…)", text: $notionKey) .textFieldStyle(.roundedBorder) } - Button("Save integrations") { saveIntegrations() } + Button("Сохранить интеграции") { saveIntegrations() } .buttonStyle(.borderedProminent) } .padding(6) } } + @ViewBuilder + private func hookResultText(_ key: String) -> some View { + if let text = hookResult[key] { + Text(text) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.green) + } + } + // MARK: - Actions private func applyCustomModel(_ value: String) { @@ -906,7 +922,7 @@ struct SettingsView: View { if on { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } } catch { - statusMessage = "❌ Startup: \(error.localizedDescription)" + statusMessage = "❌ Автозапуск: \(error.localizedDescription)" launchAtStartup = !on } } @@ -916,7 +932,7 @@ struct SettingsView: View { #if APPSTORE private func pickClaudeFolder(prompt: String) -> URL? { let panel = NSOpenPanel() - panel.message = "Select your .claude folder (press ⇧⌘. to show hidden files)" + panel.message = "Выберите папку .claude (⇧⌘. — показать скрытые файлы)" panel.prompt = prompt panel.canChooseFiles = false panel.canChooseDirectories = true @@ -927,35 +943,37 @@ struct SettingsView: View { panel.directoryURL = URL(fileURLWithPath: realHomePath) guard panel.runModal() == .OK, let url = panel.url else { return nil } guard url.lastPathComponent == ".claude" else { - statusMessage = "❌ Select the .claude folder (hidden, in your Home directory)." + statusMessage = "❌ Выберите папку .claude (скрытая, в домашней папке)." return nil } return url } private func installHooksAppStore() { - guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return } + guard let claudeURL = pickClaudeFolder(prompt: "Выбрать") else { return } let alert = NSAlert() - alert.messageText = "Install BroV hooks in ~/.claude?" - alert.informativeText = "Will write:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup created first)" - alert.addButton(withTitle: "Install") - alert.addButton(withTitle: "Cancel") + alert.messageText = "Установить хуки BroV в ~/.claude?" + alert.informativeText = "Будут записаны:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (сначала создаётся резервная копия)" + alert.addButton(withTitle: "Установить") + alert.addButton(withTitle: "Отмена") alert.alertStyle = .informational guard alert.runModal() == .alertFirstButtonReturn else { return } do { try HookServer.shared.installAndWriteClaudeHooksAppStore(claudeURL: claudeURL) hookNeedsUpdate = false - statusMessage = "✓ Hooks installed — restart VS Code to activate." + hookResult["claude"] = "✓ Хуки установлены" + statusMessage = "✓ Хуки установлены — перезапустите VS Code." } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func uninstallHooksAppStore() { - guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return } + guard let claudeURL = pickClaudeFolder(prompt: "Выбрать") else { return } do { try HookServer.shared.uninstallClaudeHooksAppStore(claudeURL: claudeURL) - statusMessage = "✓ Hooks removed." + hookResult["claude"] = "✓ Хуки удалены" + statusMessage = "✓ Хуки удалены." } catch { statusMessage = "❌ \(error.localizedDescription)" } @@ -969,7 +987,7 @@ struct SettingsView: View { : rawURL let normalised = LocalChat.normaliseURL(candidate) guard normalised.hasPrefix("http://") || normalised.hasPrefix("https://") else { - statusMessage = "Only http:// and https:// URLs are supported." + statusMessage = "Поддерживаются только адреса http:// и https://." return } if provider == .ollama { connectingOllama = true } else { connectingLMStudio = true } @@ -979,7 +997,7 @@ struct SettingsView: View { let name = provider == .ollama ? "Ollama" : "LM Studio" switch result { case .success(let models) where models.isEmpty: - statusMessage = "No models yet — download one in \(name) first." + statusMessage = "Моделей пока нет — сначала скачайте модель в \(name)." case .success(let models): if provider == .ollama { state.ollamaServerURL = normalised @@ -992,17 +1010,18 @@ struct SettingsView: View { state.fetchedProviderModels[.lmstudio] = nil state.providerModelFetchError[.lmstudio] = nil } - statusMessage = "✓ Connected · \(models.count) model\(models.count == 1 ? "" : "s")" + statusMessage = "✓ Подключено · моделей: \(models.count)" case .failure: - statusMessage = "Couldn't reach \(name) at \(normalised). Is it running?" + statusMessage = "Не удалось подключиться к \(name) по адресу \(normalised). Он запущен?" } } private func installHooks() { do { pendingHookJSON = try HookServer.shared.previewClaudeHooks() + hookResult["claude"] = nil showDiff = true - statusMessage = "Review the JSON below before confirming." + statusMessage = "Проверьте JSON ниже перед подтверждением." } catch { statusMessage = "❌ \(error.localizedDescription)" } @@ -1012,18 +1031,21 @@ struct SettingsView: View { do { try HookServer.shared.writeClaudeHooks() showDiff = false - statusMessage = "✓ Hooks installed in ~/.claude/settings.json" + statusMessage = "✓ Хуки установлены в ~/.claude/settings.json" pendingHookJSON = "" hookNeedsUpdate = false + hookResult["claude"] = "✓ Хуки установлены" } catch { - statusMessage = "❌ Write error: \(error.localizedDescription)" + hookResult["claude"] = nil + statusMessage = "❌ Ошибка записи: \(error.localizedDescription)" } } private func uninstallHooks() { do { try HookServer.shared.uninstallClaudeHooks() - statusMessage = "✓ Hooks removed." + hookResult["claude"] = "✓ Хуки удалены" + statusMessage = "✓ Хуки удалены." } catch { statusMessage = "❌ \(error.localizedDescription)" } @@ -1035,7 +1057,8 @@ struct SettingsView: View { geminiPendingInstall = install pendingGeminiJSON = try HookServer.shared.previewGeminiHooks(install: install) showGeminiDiff = true - statusMessage = "Review the JSON below before confirming." + hookResult["gemini"] = nil + statusMessage = "Проверьте JSON ниже перед подтверждением." } catch let e as NSError where e.domain == "BroVNoop" { statusMessage = e.localizedDescription } catch { @@ -1049,9 +1072,10 @@ struct SettingsView: View { showGeminiDiff = false pendingGeminiJSON = "" geminiHooksInstalled = geminiPendingInstall + hookResult["gemini"] = geminiPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены" statusMessage = geminiPendingInstall - ? "✓ Gemini CLI hooks installed in ~/.gemini/settings.json" - : "✓ Gemini CLI hooks removed." + ? "✓ Хуки Gemini CLI установлены в ~/.gemini/settings.json" + : "✓ Хуки Gemini CLI удалены." } catch { statusMessage = "❌ \(error.localizedDescription)" } @@ -1062,7 +1086,8 @@ struct SettingsView: View { agyPendingInstall = install pendingAgyJSON = try HookServer.shared.previewAgyHooks(install: install) showAgyDiff = true - statusMessage = "Review the JSON below before confirming." + hookResult["agy"] = nil + statusMessage = "Проверьте JSON ниже перед подтверждением." } catch let e as NSError where e.domain == "BroVNoop" { statusMessage = e.localizedDescription } catch { @@ -1076,9 +1101,10 @@ struct SettingsView: View { showAgyDiff = false pendingAgyJSON = "" agyHooksInstalled = agyPendingInstall + hookResult["agy"] = agyPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены" statusMessage = agyPendingInstall - ? "✓ Antigravity hooks installed in ~/.gemini/config/hooks.json" - : "✓ Antigravity hooks removed." + ? "✓ Хуки Antigravity установлены в ~/.gemini/config/hooks.json" + : "✓ Хуки Antigravity удалены." } catch { statusMessage = "❌ \(error.localizedDescription)" } @@ -1089,7 +1115,8 @@ struct SettingsView: View { codexPendingInstall = install pendingCodexJSON = try HookServer.shared.previewCodexHooks(install: install) showCodexDiff = true - statusMessage = "Review the JSON below before confirming." + hookResult["codex"] = nil + statusMessage = "Проверьте JSON ниже перед подтверждением." } catch let e as NSError where e.domain == "BroVNoop" { statusMessage = e.localizedDescription } catch { @@ -1103,9 +1130,10 @@ struct SettingsView: View { showCodexDiff = false pendingCodexJSON = "" codexHooksInstalled = codexPendingInstall + hookResult["codex"] = codexPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены" statusMessage = codexPendingInstall - ? "✓ Codex hooks installed — run /hooks in Codex or open Hooks in the app's settings to trust them." - : "✓ Codex hooks removed." + ? "✓ Хуки Codex установлены — выполните /hooks в Codex или откройте Hooks в настройках, чтобы доверить им." + : "✓ Хуки Codex удалены." } catch { statusMessage = "❌ \(error.localizedDescription)" } @@ -1115,8 +1143,9 @@ struct SettingsView: View { do { pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: true) showStatusLineDiff = true + hookResult["statusline"] = nil statusLinePendingInstall = true - statusMessage = "Review the JSON below before confirming." + statusMessage = "Проверьте JSON ниже перед подтверждением." } catch { statusMessage = "❌ \(error.localizedDescription)" } @@ -1126,8 +1155,9 @@ struct SettingsView: View { do { pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: false) showStatusLineDiff = true + hookResult["statusline"] = nil statusLinePendingInstall = false - statusMessage = "Review the JSON below before confirming." + statusMessage = "Проверьте JSON ниже перед подтверждением." } catch { statusMessage = "❌ \(error.localizedDescription)" } @@ -1146,9 +1176,10 @@ struct SettingsView: View { if !statusLinePendingInstall { state.showPlanInNotch = false } + hookResult["statusline"] = statusLinePendingInstall ? "✓ Ретранслятор установлен" : "✓ Ретранслятор удалён" statusMessage = statusLinePendingInstall - ? "✓ Status line installed." - : "✓ Status line removed." + ? "✓ Строка состояния установлена." + : "✓ Строка состояния удалена." } catch { planTogglePending = false statusMessage = "❌ \(error.localizedDescription)" @@ -1180,7 +1211,7 @@ struct SettingsView: View { saveKey("stripe-api-key", value: stripeKey) saveKey("calcom-api-key", value: calcomKey) saveKey("notion-api-key", value: notionKey) - statusMessage = "✓ Integration keys saved." + statusMessage = "✓ Ключи интеграций сохранены." } private func saveKey(_ key: String, value: String) { @@ -1195,7 +1226,7 @@ struct SettingsView: View { private func loadVercelProjects() { guard let token = KeychainStore.shared.get("vercel-token") else { - statusMessage = "❌ Save Vercel token first." + statusMessage = "❌ Сначала сохраните токен Vercel." return } loadingVercel = true @@ -1214,7 +1245,7 @@ struct SettingsView: View { DispatchQueue.main.async { self.vercelProjects = names self.loadingVercel = false - if names.isEmpty { self.statusMessage = "❌ No Vercel projects found." } + if names.isEmpty { self.statusMessage = "❌ Проекты Vercel не найдены." } } }.resume() } @@ -1224,7 +1255,7 @@ struct SettingsView: View { private func loadN8nWorkflows() { guard let apiKey = KeychainStore.shared.get("n8n-api-key"), let rawBase = KeychainStore.shared.get("n8n-url") else { - statusMessage = "❌ Save n8n URL and API key first." + statusMessage = "❌ Сначала сохраните URL и API-ключ n8n." return } loadingN8n = true @@ -1235,7 +1266,7 @@ struct SettingsView: View { private func fetchN8nWorkflows(urls: [String], apiKey: String, idx: Int) { guard idx < urls.count, let url = URL(string: urls[idx]) else { - DispatchQueue.main.async { self.loadingN8n = false; self.statusMessage = "❌ No n8n workflows found." } + DispatchQueue.main.async { self.loadingN8n = false; self.statusMessage = "❌ Воркфлоу n8n не найдены." } return } var req = URLRequest(url: url, timeoutInterval: 10) @@ -1255,7 +1286,7 @@ struct SettingsView: View { DispatchQueue.main.async { self.n8nWorkflows = names self.loadingN8n = false - if names.isEmpty { self.statusMessage = "❌ No n8n workflows found." } + if names.isEmpty { self.statusMessage = "❌ Воркфлоу n8n не найдены." } } }.resume() } @@ -1267,20 +1298,20 @@ struct SettingsView: View { let atMax = state.activeIntegrations.count >= 4 && !isOn && !isMain let hint: String? = { if isMain { return nil } - if def.comingSoon { return "Coming soon" } + if def.comingSoon { return "Скоро" } #if !APPSTORE - if def.id == "agent_gemini" && !HookServer.geminiHooksInstalled() { return "Hooks not installed" } - if def.id == "agent_antigravity" && !HookServer.agyHooksInstalled() { return "Hooks not installed" } - if def.id == "agent_codex" && !HookServer.codexHooksInstalled() { return "Hooks not installed" } + if def.id == "agent_gemini" && !HookServer.geminiHooksInstalled() { return "Хуки не установлены" } + if def.id == "agent_antigravity" && !HookServer.agyHooksInstalled() { return "Хуки не установлены" } + if def.id == "agent_codex" && !HookServer.codexHooksInstalled() { return "Хуки не установлены" } #endif if def.category == .ai { if let provider = ChatProvider(pillID: def.id), provider.isLocal { let url = provider == .ollama ? state.ollamaServerURL : state.lmstudioServerURL - if url.isEmpty { return "Not connected" } + if url.isEmpty { return "Не подключено" } } else { let keyId = def.id == "ai_anthropic" ? "anthropic-api-key" : def.id == "ai_google" ? "google-api-key" : "openai-api-key" - if KeychainStore.shared.get(keyId) == nil { return "Key not configured" } + if KeychainStore.shared.get(keyId) == nil { return "Ключ не настроен" } } } return nil @@ -1294,7 +1325,7 @@ struct SettingsView: View { .foregroundColor(atMax ? .secondary : .primary) Spacer() if isMain { - Text("Main") + Text("Основная") .font(.system(size: 11)) .foregroundColor(.secondary) } else { @@ -1366,12 +1397,12 @@ struct IntegrationFilterRow: View { if loading { ProgressView().scaleEffect(0.6) } else { - Button(items.isEmpty ? "Load list" : "Refresh") { onLoad() } + Button(items.isEmpty ? "Загрузить список" : "Обновить") { onLoad() } .buttonStyle(.bordered) .controlSize(.mini) } if !filter.isEmpty { - Button("Clear") { filter = [] } + Button("Сбросить") { filter = [] } .buttonStyle(.bordered) .controlSize(.mini) .foregroundColor(.secondary) @@ -1397,7 +1428,7 @@ struct IntegrationFilterRow: View { } .padding(.leading, 4) if !filter.isEmpty { - Text("Watching \(filter.count) of \(items.count)") + Text("Отслеживается \(filter.count) из \(items.count)") .font(.system(size: 10)) .foregroundColor(.secondary) } @@ -1430,7 +1461,7 @@ struct ShortcutRecorderButton: View { return nil } } label: { - Text(isRecording ? "Press keys…" : shortcutLabel) + Text(isRecording ? "Нажмите клавиши…" : shortcutLabel) .font(.system(size: 11, design: .monospaced)) .padding(.horizontal, 8).padding(.vertical, 3) .background(isRecording ? Color.accentColor.opacity(0.12) : Color(NSColor.controlBackgroundColor)) @@ -1448,7 +1479,7 @@ struct ShortcutRecorderButton: View { if f.contains(.shift) { s += "⇧" } if f.contains(.command) { s += "⌘" } s += keyChar(code) - return s.isEmpty ? "None" : s + return s.isEmpty ? "Нет" : s } private func keyChar(_ c: UInt16) -> String { diff --git a/NotchBuddy/Sources/App/ShortcutLogic.swift b/NotchBuddy/Sources/App/ShortcutLogic.swift index 07f630a..29d9ed6 100644 --- a/NotchBuddy/Sources/App/ShortcutLogic.swift +++ b/NotchBuddy/Sources/App/ShortcutLogic.swift @@ -38,16 +38,16 @@ enum ShortcutAction: String, CaseIterable, Sendable { var displayName: String { switch self { - case .toggleIsland: return "Open / close island" - case .openChat: return "Open chat" - case .goToAlert: return "Go to alert" - case .jumpToTerminal: return "Jump to terminal" - case .attachFrontWindow: return "Attach front window" - case .nextPill: return "Next pill" - case .prevPill: return "Previous pill" - case .muteToggle: return "Mute / unmute sounds" - case .desktopToggle: return "BroV on / off desktop" - case .wardrobeToggle: return "Open / close wardrobe" + case .toggleIsland: return "Открыть / закрыть остров" + case .openChat: return "Открыть чат" + case .goToAlert: return "Перейти к уведомлению" + case .jumpToTerminal: return "Перейти в терминал" + case .attachFrontWindow: return "Прикрепить активное окно" + case .nextPill: return "Следующая плашка" + case .prevPill: return "Предыдущая плашка" + case .muteToggle: return "Выключить / включить звук" + case .desktopToggle: return "BroV на рабочий стол / обратно" + case .wardrobeToggle: return "Открыть / закрыть гардероб" } } @@ -222,15 +222,15 @@ enum ShortcutLogic { /// Descriptive table of island-local shortcuts for the Settings view. static let islandShortcuts: [(key: String, description: String)] = [ - ("⌘→ / ⌘←", "Next / previous pill"), - ("⌘1 – ⌘9", "Switch to pill by number"), - ("⌘↓ / ⌘↑", "Navigate list items"), - ("⌘O", "Open selected item"), - ("⌘E", "Open / close current diff"), - ("⌘↩", "Send chat message"), - ("⌘K", "New conversation"), - ("⌘,", "Open Settings"), - ("⌘P", "Pin / unpin island"), - ("⎋", "Close island (if not pinned)"), + ("⌘→ / ⌘←", "Следующая / предыдущая плашка"), + ("⌘1 – ⌘9", "Плашка по номеру"), + ("⌘↓ / ⌘↑", "Перемещение по списку"), + ("⌘O", "Открыть выбранный элемент"), + ("⌘E", "Открыть / закрыть текущий diff"), + ("⌘↩", "Отправить сообщение в чат"), + ("⌘K", "Новый диалог"), + ("⌘,", "Открыть настройки"), + ("⌘P", "Закрепить / открепить остров"), + ("⎋", "Закрыть остров (если не закреплён)"), ] } diff --git a/NotchBuddy/Sources/App/ShortcutsSettingsView.swift b/NotchBuddy/Sources/App/ShortcutsSettingsView.swift index 7856462..db62339 100644 --- a/NotchBuddy/Sources/App/ShortcutsSettingsView.swift +++ b/NotchBuddy/Sources/App/ShortcutsSettingsView.swift @@ -25,7 +25,7 @@ struct ShortcutsSettingsView: View { // MARK: - Global shortcuts private var globalSection: some View { - GroupBox("Global shortcuts — work from any app") { + GroupBox("Глобальные сочетания — работают в любом приложении") { VStack(alignment: .leading, spacing: 0) { ForEach(globalActions, id: \.self) { action in shortcutRow(action) @@ -36,7 +36,7 @@ struct ShortcutsSettingsView: View { Divider().padding(.top, 8) HStack { Spacer() - Button("Reset all to defaults") { resetAll() } + Button("Сбросить всё по умолчанию") { resetAll() } .buttonStyle(.bordered) .controlSize(.small) } @@ -82,9 +82,9 @@ struct ShortcutsSettingsView: View { .onChange(of: specs[action]) { _, _ in specChanged(action) } if hasSysConf { - conflictTag("System conflict", color: .orange) + conflictTag("Конфликт с системой", color: .orange) } else if hasIntConf { - conflictTag("Duplicate", color: .red) + conflictTag("Повтор", color: .red) } } .padding(.vertical, 5) @@ -104,7 +104,7 @@ struct ShortcutsSettingsView: View { // MARK: - Island-local shortcuts (read-only) private var islandSection: some View { - GroupBox("Island shortcuts — active when the island is focused") { + GroupBox("Сочетания острова — работают, когда остров в фокусе") { VStack(alignment: .leading, spacing: 0) { ForEach(Array(ShortcutLogic.islandShortcuts.enumerated()), id: \.offset) { _, pair in HStack(spacing: 10) { diff --git a/NotchBuddy/Sources/App/StripePoller.swift b/NotchBuddy/Sources/App/StripePoller.swift index 6aeec6c..2d4d069 100644 --- a/NotchBuddy/Sources/App/StripePoller.swift +++ b/NotchBuddy/Sources/App/StripePoller.swift @@ -44,10 +44,10 @@ final class StripePoller: @unchecked Sendable { let code = (response as? HTTPURLResponse)?.statusCode ?? 0 if code != 200 { let errMsg: String - if code == 401 { errMsg = "Invalid API key (401)" } - else if code == 403 { errMsg = "Use secret key (sk_live_… not pk_live_…)" } - else if code == 0 { errMsg = error?.localizedDescription ?? "No connection" } - else { errMsg = "API error \(code)" } + if code == 401 { errMsg = "Неверный API-ключ (401)" } + else if code == 403 { errMsg = "Нужен секретный ключ (sk_live_…, не pk_live_…)" } + else if code == 0 { errMsg = error?.localizedDescription ?? "Нет соединения" } + else { errMsg = "Ошибка API \(code)" } DispatchQueue.main.async { AppState.shared.stripeError = errMsg } return } diff --git a/NotchBuddy/Sources/App/UploadCanvasView.swift b/NotchBuddy/Sources/App/UploadCanvasView.swift index 0efebfe..ed50d50 100644 --- a/NotchBuddy/Sources/App/UploadCanvasView.swift +++ b/NotchBuddy/Sources/App/UploadCanvasView.swift @@ -160,12 +160,12 @@ struct UploadCanvasView: View { var tCtx = ctx tCtx.opacity = f.textAlpha - let label = Text("Drop your files here") + let label = Text("Перетащи файлы сюда") .font(.system(size:13, weight:.medium)) .foregroundColor(Color(hex:"#D5D7DB")) tCtx.draw(label, at: CGPoint(x: USC.TEXT_X, y: USC.TEXT_Y - 4), anchor: .leading) - let chips = ["PDF","Images","Code","Docs"] + let chips = ["PDF","Фото","Код","Документы"] var cx = USC.TEXT_X for chip in chips { let chipText = Text(chip).font(.system(size:11, weight:.medium)).foregroundColor(Color(hex:"#B9BDC4")) @@ -188,8 +188,8 @@ struct UploadCanvasView: View { let barLen = (x1-x0) * f.barReveal // Filename label - let name = state.droppedFile?.name ?? "file" - let label = Text("Uploading \(name)") + let name = state.droppedFile?.name ?? "файл" + let label = Text("Загружаю \(name)") .font(.system(size:12.5, weight:.medium)) .foregroundColor(Color(hex:"#A9ADB5")) pCtx.draw(label, at: CGPoint(x: x0, y: by-30), anchor: .leading) @@ -262,13 +262,13 @@ struct UploadCanvasView: View { // Slide up: translate down by (1-alpha)*4 cCtx.concatenate(CGAffineTransform(translationX: 0, y: CGFloat((1-f.chooseAlpha)*4))) - let name = state.droppedFile?.name ?? "file" - let titleText = Text("\(name) is ready.") + let name = state.droppedFile?.name ?? "Файл" + let titleText = Text("\(name) готов.") .font(.system(size:14, weight:.semibold)) .foregroundColor(Color(hex:"#F5F6F8")) cCtx.draw(titleText, at: CGPoint(x:114, y:80), anchor: .leading) - let subText = Text("What do you want to do with it?") + let subText = Text("Что с ним сделать?") .font(.system(size:12.5)) .foregroundColor(Color(hex:"#9398A1")) cCtx.draw(subText, at: CGPoint(x:114, y:100), anchor: .leading) @@ -276,7 +276,7 @@ struct UploadCanvasView: View { // Primary button (white fill) cCtx.fill(roundedRect(CGRect(x:114,y:113,width:168,height:26), r:13), with: .color(Color(hex:"#F5F6F8"))) - let btn1 = Text("Ask a question about it") + let btn1 = Text("Задать вопрос") .font(.system(size:12.5, weight:.medium)) .foregroundColor(Color(red:0.043,green:0.047,blue:0.055)) cCtx.draw(btn1, at: CGPoint(x:198, y:126), anchor: .center) @@ -284,7 +284,7 @@ struct UploadCanvasView: View { // Secondary button (dim fill) cCtx.fill(roundedRect(CGRect(x:290,y:113,width:120,height:26), r:13), with: .color(Color.white.opacity(0.09))) - let btn2 = Text("Send by email") + let btn2 = Text("Отправить почтой") .font(.system(size:12.5, weight:.medium)) .foregroundColor(Color(hex:"#F1F2F4")) cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center) diff --git a/NotchBuddy/Sources/CoucouKit/MochiActivityState.swift b/NotchBuddy/Sources/CoucouKit/MochiActivityState.swift index 4bb5130..64ed015 100644 --- a/NotchBuddy/Sources/CoucouKit/MochiActivityState.swift +++ b/NotchBuddy/Sources/CoucouKit/MochiActivityState.swift @@ -46,24 +46,24 @@ struct MochiActivityState: Codable, Hashable, Sendable { static func statusText(state: BotState, urgency: Int, stepIndex: Int, stepCount: Int) -> String { switch urgency { - case 0: return "waiting for your OK" - case 1: return "has a question" + case 0: return "ждёт вашего OK" + case 1: return "есть вопрос" default: break } switch state { case .working, .thinking, .searching: - return stepCount == 0 ? "working" : "working · \(min(stepIndex + 1, stepCount))/\(stepCount)" - case .finished: return "✓ done" - case .error: return "error" - case .ratelimit: return "rate limited" - case .sleeping: return "asleep" - case .dizzy: return "dizzy" - default: return "idle" + return stepCount == 0 ? "работает" : "работает · \(min(stepIndex + 1, stepCount))/\(stepCount)" + case .finished: return "✓ готово" + case .error: return "ошибка" + case .ratelimit: return "лимит запросов" + case .sleeping: return "спит" + case .dizzy: return "голова кружится" + default: return "отдыхает" } } static let placeholder = MochiActivityState( pillId: "integration_claude", agent: "VS Code", color: "#4A86E8", - state: "working", statusText: "working · 3/7", tone: "working", + state: "working", statusText: "работает · 3/7", tone: "working", stepIndex: 2, stepCount: 7, others: 1) } diff --git a/NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift b/NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift index 98b930e..bfa71c6 100644 --- a/NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift +++ b/NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift @@ -6,19 +6,19 @@ enum Outfit: String, CaseIterable { var displayName: String { switch self { - case .auto: return "Auto (seasons)" - case .none: return "None" - case .partyHat: return "Party hat" - case .beanie: return "Beanie" - case .crown: return "Crown" - case .sunglasses: return "Sunglasses" - case .roundGlasses: return "Round glasses" - case .bow: return "Bow" - case .scarf: return "Scarf" - case .witchHat: return "Witch hat" - case .pumpkin: return "Pumpkin" - case .santaHat: return "Santa hat" - case .bunnyEars: return "Bunny ears" + case .auto: return "Авто (по сезону)" + case .none: return "Без наряда" + case .partyHat: return "Праздничный колпак" + case .beanie: return "Шапка" + case .crown: return "Корона" + case .sunglasses: return "Солнечные очки" + case .roundGlasses: return "Круглые очки" + case .bow: return "Бантик" + case .scarf: return "Шарф" + case .witchHat: return "Шляпа ведьмы" + case .pumpkin: return "Тыква" + case .santaHat: return "Шапка Санты" + case .bunnyEars: return "Заячьи ушки" } } diff --git a/NotchBuddy/Sources/CoucouKit/PillCatalog.swift b/NotchBuddy/Sources/CoucouKit/PillCatalog.swift index 530b5ec..0a0bfb0 100644 --- a/NotchBuddy/Sources/CoucouKit/PillCatalog.swift +++ b/NotchBuddy/Sources/CoucouKit/PillCatalog.swift @@ -10,10 +10,10 @@ enum PillCategory: String, CaseIterable { var title: String { switch self { - case .workspace: return "Where you code" - case .agent: return "Agents" - case .ai: return "AI for the chat" - case .service: return "Services" + case .workspace: return "Где вы пишете код" + case .agent: return "Агенты" + case .ai: return "ИИ для чата" + case .service: return "Сервисы" } } } @@ -37,7 +37,7 @@ struct PillDefinition { case "integration_claude": return "Claude Code" case "agent_cursor": return "Cursor" case "agent_codex": return "Codex" - default: return "Agent" + default: return "Агент" } } } @@ -49,44 +49,44 @@ enum PillCatalog { static let all: [PillDefinition] = [ // ── Where you code ─────────────────────────────────────────────────── .init(id: "integration_claude", name: "VS Code", color: "#F5F6F8", - category: .workspace, subtitle: "Integration", source: .claudeCode), + category: .workspace, subtitle: "Интеграция", source: .claudeCode), .init(id: "agent_cursor", name: "Cursor", color: "#C0C4CC", - category: .workspace, subtitle: "Integration", source: .agent), + category: .workspace, subtitle: "Интеграция", source: .agent), .init(id: "agent_antigravity", name: "Antigravity", color: "#E879F9", - category: .workspace, subtitle: "Integration", source: .agent, githubOnly: true), + category: .workspace, subtitle: "Интеграция", source: .agent, githubOnly: true), .init(id: "agent_codex", name: "Codex", color: "#2DD4BF", - category: .workspace, subtitle: "Integration", source: .agent, githubOnly: true), + category: .workspace, subtitle: "Интеграция", source: .agent, githubOnly: true), // ── Agents ─────────────────────────────────────────────────────────── .init(id: "agent_gemini", name: "Gemini CLI", color: "#8AB4F8", - category: .agent, subtitle: "Agent", source: .agent, githubOnly: true), + category: .agent, subtitle: "Агент", source: .agent, githubOnly: true), // ── AI for the chat ────────────────────────────────────────────────── .init(id: "ai_anthropic", name: "Anthropic", color: ChatProvider.anthropic.accentHex, - category: .ai, subtitle: "Chat", source: .n8n), + category: .ai, subtitle: "Чат", source: .n8n), .init(id: "ai_google", name: "Google AI", color: ChatProvider.google.accentHex, - category: .ai, subtitle: "Chat", source: .n8n), + category: .ai, subtitle: "Чат", source: .n8n), .init(id: "ai_openai", name: "OpenAI", color: ChatProvider.openai.accentHex, - category: .ai, subtitle: "Chat", source: .n8n), + category: .ai, subtitle: "Чат", source: .n8n), .init(id: "ai_ollama", name: "Ollama", color: ChatProvider.ollama.accentHex, - category: .ai, subtitle: "Chat", source: .n8n), + category: .ai, subtitle: "Чат", source: .n8n), .init(id: "ai_lmstudio", name: "LM Studio", color: ChatProvider.lmstudio.accentHex, - category: .ai, subtitle: "Chat", source: .n8n), + category: .ai, subtitle: "Чат", source: .n8n), // ── Services ───────────────────────────────────────────────────────── .init(id: "integration_resend", name: "Resend", color: "#22C55E", - category: .service, subtitle: "Integration", source: .n8n), + category: .service, subtitle: "Интеграция", source: .n8n), .init(id: "integration_n8n", name: "n8n", color: "#F29B38", - category: .service, subtitle: "Integration", source: .n8n), + category: .service, subtitle: "Интеграция", source: .n8n), .init(id: "integration_vercel", name: "Vercel", color: "#7C5CFF", - category: .service, subtitle: "Integration", source: .n8n), + category: .service, subtitle: "Интеграция", source: .n8n), .init(id: "integration_github", name: "GitHub", color: "#F4505E", - category: .service, subtitle: "Integration", source: .n8n), + category: .service, subtitle: "Интеграция", source: .n8n), .init(id: "integration_notion", name: "Notion", color: "#8C8C8C", - category: .service, subtitle: "Integration", source: .n8n), + category: .service, subtitle: "Интеграция", source: .n8n), .init(id: "integration_calcom", name: "Cal.com", color: "#C9956A", - category: .service, subtitle: "Integration", source: .n8n), + category: .service, subtitle: "Интеграция", source: .n8n), .init(id: "integration_stripe", name: "Stripe", color: "#0570DE", - category: .service, subtitle: "Integration", source: .n8n), + category: .service, subtitle: "Интеграция", source: .n8n), .init(id: "integration_music", name: "Apple Music", color: "#FA2D48", - category: .service, subtitle: "Integration", source: .n8n, githubOnly: true), + category: .service, subtitle: "Интеграция", source: .n8n, githubOnly: true), ] /// Pills available in the current build target.