Russian UI: settings, notch views, agent steps, errors; visible success after installing hooks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 20:15:30 +03:00
parent 750e5ad0ad
commit 319cdc8b18
20 changed files with 565 additions and 519 deletions
+4 -4
View File
@@ -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
+24 -24
View File
@@ -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)) д"
}
}
+3 -3
View File
@@ -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
}
+5 -5
View File
@@ -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
+13 -13
View File
@@ -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) мин"
}
}
}
+1 -1
View File
@@ -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()))%"
}
}
+15 -15
View File
@@ -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
)
+56 -56
View File
@@ -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] {
+141 -134
View File
@@ -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
}
+2 -2
View File
@@ -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
}
}
+9 -1
View File
@@ -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 "элементов"
}
+4 -4
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -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", "Закрепить / открепить остров"),
("⎋", "Закрыть остров (если не закреплён)"),
]
}
@@ -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) {
+4 -4
View File
@@ -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
}
@@ -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)
@@ -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)
}
@@ -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 "Заячьи ушки"
}
}
+23 -23
View File
@@ -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.