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
+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
}