Compare commits

..

10 Commits

Author SHA1 Message Date
maksarsanjeev d8a64ab5b5 Up-arrow always visible in the header
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:34:10 +03:00
maksarsanjeev a92bf02477 Up-arrow (and toggle hotkey) folds chat/terminal back into the notch even while held open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:28:42 +03:00
maksarsanjeev 56367ef9cd Tabs: '+' opens brov-chat or term.macOS (max 6, split around the camera); real zsh terminals via SwiftTerm; per-tab chat history and claude session; hover 3 s to close with confirmation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:27:02 +03:00
maksarsanjeev 0d40b78b26 Resilience: LaunchAgent restarts BroV on crash; crashed Claude Code/Codex sessions get a resume button (optional auto); login and waiting status in the Claude/ChatGPT pills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:56:39 +03:00
maksarsanjeev 37c01f991a Danger warning on approval cards (red card, reasons, no Always); file shelf in the top-left slot with 'На полку' after a drop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:51:35 +03:00
maksarsanjeev 3857776111 Header: collapse button folds the island (and chat size) back into the notch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:29:48 +03:00
maksarsanjeev ae394370bf Limits move into the Claude/ChatGPT pills (header ran under the camera); balanced home margins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:27:26 +03:00
maksarsanjeev a4d5032959 Chat: drag handle with detents (fit, 1/3, 1/2, full screen), chat width setting; tall transparent panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:19:27 +03:00
maksarsanjeev fbf90fd4fb Memoji character (11 expressions) on the engine's motion; Claude limit shows time to reset; ChatGPT weekly limit from Codex logs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:53 +03:00
maksarsanjeev 8a8dd98e06 Home: Claude and ChatGPT on the bottom floor, empty slots on top
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:08:29 +03:00
36 changed files with 1750 additions and 170 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+10
View File
@@ -16,7 +16,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Warm up Keychain cache on main thread BEFORE any poller or view touches it // Warm up Keychain cache on main thread BEFORE any poller or view touches it
_ = KeychainStore.shared _ = KeychainStore.shared
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
// One BroV at a time (launchd may start one while another is still quitting).
let me = ProcessInfo.processInfo.processIdentifier
if NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "")
.contains(where: { $0.processIdentifier != me }) {
exit(0)
}
BroVLaunchAgent.ensure()
setupMenuBarItem() setupMenuBarItem()
CodexUsageMonitor.shared.start()
AgentWatch.shared.start()
AgentAuth.shared.start()
setupIsland() setupIsland()
#if PHONE_LINK #if PHONE_LINK
CloudProbe.shared.startIfEnabled() CloudProbe.shared.startIfEnabled()
+123 -21
View File
@@ -3,8 +3,8 @@ import AppKit
// MARK: - BroV home: two floors of flat app pills // MARK: - BroV home: two floors of flat app pills
// //
// Top floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). // Top floor: two empty slots, to be filled later.
// Bottom floor: two empty slots, to be filled later. The bot keeps its spot on the left. // Bottom floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). The bot keeps its spot on the left.
struct DesktopAppSlot: Identifiable { struct DesktopAppSlot: Identifiable {
let id: String let id: String
@@ -28,20 +28,72 @@ enum DesktopApps {
struct AppGridView: View { struct AppGridView: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
private var claudeLimit: PillLimit? {
guard state.planRelayInstalled, let usage = state.claudePlanUsage,
let pct = ClaudePlanGauge.dominantPct(usage) else { return nil }
var text = "\(Int(pct.rounded()))%"
if let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() {
text += " · сброс через \(PlanTime.untilReset(w.resetsAt))"
}
return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct))
}
/// What Claude Code / Codex need from us, most urgent first:
/// a session fell over → login needed → waiting for an answer → working.
private func alert(for kind: AgentKind) -> PillAlert? {
if let crash = state.crashedSessions.last(where: { $0.kind == kind }) {
return PillAlert(text: "упала · \(crash.project) · поднять", color: "#F4505E") {
AgentWatch.shared.revive(crash)
}
}
if kind == .claude ? state.claudeNeedsLogin : state.codexNeedsLogin {
return PillAlert(text: "нужен вход · войти", color: "#F4505E") {
AgentAuth.shared.login(kind)
}
}
let pillIds: Set<String> = kind == .claude ? ["integration_claude", "agent_cursor"] : ["agent_codex"]
let tasks = state.tasks.filter { pillIds.contains($0.id) }
if let t = tasks.first(where: { $0.state == .approval || $0.state == .question }) {
let isApproval = t.state == .approval
return PillAlert(text: isApproval ? "ждёт разрешения · открыть" : "задал вопрос · открыть",
color: "#F5A524") {
state.setFocus(t.id)
state.view = isApproval ? .approval : .question
}
}
if let t = tasks.first(where: { [.working, .thinking, .searching].contains($0.state) }) {
return PillAlert(text: "работает · \(t.name)", color: "#3B9EFF")
}
if tasks.contains(where: { $0.state == .ratelimit }) {
return PillAlert(text: "упёрся в лимит", color: "#FB923C")
}
return nil
}
/// "23% · сброс сб 15:22" — ChatGPT weekly limit from the Codex logs.
private var chatGPTLimit: PillLimit? {
guard let w = state.codexWeekly else { return nil }
let pct = ClaudePlanGauge.effectivePct(w)
var text = "\(Int(pct.rounded()))%"
if w.resetsAt > Date() { text += " · сброс \(PlanTime.resetMoment(w.resetsAt))" }
return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct))
}
var body: some View { var body: some View {
ZStack { ZStack {
VStack(spacing: 8) { VStack(spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
AppPill(app: DesktopApps.claude) ShelfPill()
AppPill(app: DesktopApps.chatGPT) EmptyPill()
} }
HStack(spacing: 8) { HStack(spacing: 8) {
EmptyPill() AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
EmptyPill() AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit, alert: alert(for: .codex))
} }
} }
.padding(.leading, 108) .padding(.leading, 104)
.padding(.trailing, 14) .padding(.trailing, 20)
.padding(.vertical, 6) .padding(.vertical, 6)
#if !APPSTORE #if !APPSTORE
@@ -62,8 +114,22 @@ struct AppGridView: View {
// MARK: - App pill // MARK: - App pill
struct PillLimit {
let text: String
let color: String
}
/// Something the agent needs from us, shown instead of the limit line.
struct PillAlert {
let text: String
let color: String
var action: (() -> Void)? = nil
}
struct AppPill: View { struct AppPill: View {
let app: DesktopAppSlot let app: DesktopAppSlot
var limit: PillLimit? = nil
var alert: PillAlert? = nil
@State private var appURL: URL? @State private var appURL: URL?
@State private var isRunning = false @State private var isRunning = false
@State private var isHovered = false @State private var isHovered = false
@@ -74,28 +140,44 @@ struct AppPill: View {
} }
var body: some View { var body: some View {
Button(action: open) {
HStack(spacing: 10) { HStack(spacing: 10) {
icon icon
.frame(width: 26, height: 26) .frame(width: 26, height: 26)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(app.name) Text(app.name)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8")) .foregroundColor(Color(hex: "#F5F6F8"))
HStack(spacing: 5) {
Circle()
.fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
.frame(width: 5, height: 5)
Text(status)
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#8E939C"))
}
}
Spacer(minLength: 4) Spacer(minLength: 4)
Image(systemName: "arrow.up.right") Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold)) .font(.system(size: 9, weight: .semibold))
.foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D")) .foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D"))
} }
if let alert {
alertRow(alert)
} else {
HStack(spacing: 5) {
Circle()
.fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
.frame(width: 5, height: 5)
// With a limit shown, the green dot alone says "running".
if limit == nil || appURL == nil {
Text(status)
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1)
}
if let limit, appURL != nil {
Text(limit.text)
.font(.system(size: 10.5, weight: .semibold))
.foregroundColor(Color(hex: limit.color))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
}
}
}
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background( .background(
@@ -107,8 +189,7 @@ struct AppPill: View {
.stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1) .stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1)
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
} .onTapGesture(perform: open)
.buttonStyle(.plain)
.scaleEffect(isHovered ? 1.02 : 1) .scaleEffect(isHovered ? 1.02 : 1)
.onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } } .onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } }
.onAppear(perform: refresh) .onAppear(perform: refresh)
@@ -116,6 +197,27 @@ struct AppPill: View {
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() } .onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() }
} }
/// Status that needs attention: pulsing dot + text; tappable when it has an action.
@ViewBuilder private func alertRow(_ alert: PillAlert) -> some View {
let row = HStack(spacing: 5) {
Circle()
.fill(Color(hex: alert.color))
.frame(width: 6, height: 6)
.shadow(color: Color(hex: alert.color), radius: 3)
Text(alert.text)
.font(.system(size: 10.5, weight: .semibold))
.foregroundColor(Color(hex: alert.color))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
if let action = alert.action {
Button(action: action) { row.contentShape(Rectangle()) }
.buttonStyle(.plain)
} else {
row
}
}
@ViewBuilder private var icon: some View { @ViewBuilder private var icon: some View {
if let appURL { if let appURL {
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path)) Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
+108 -1
View File
@@ -9,7 +9,26 @@ final class AppState: ObservableObject {
// Island state // Island state
@Published var mode: IslandMode = .hidden @Published var mode: IslandMode = .hidden
@Published var view: IslandView = .overview @Published var view: IslandView = .overview {
didSet {
// Any way into the chat (drop → "Задать вопрос", shortcuts…) lands in a brov-chat tab.
if view == .prompt, tabs.first(where: { $0.id == activeTabId })?.kind != .chat {
if let chat = tabs.first(where: { $0.kind == .chat }) {
if let cur = activeTabId, tabs.first(where: { $0.id == cur })?.kind == .chat {
ChatTabs.shared.saveHistory(chatHistory, for: cur)
}
activeTabId = chat.id
chatHistory = ChatTabs.shared.history(for: chat.id)
} else if tabs.count < Self.maxTabs {
let n = (1...Self.maxTabs).first { k in !tabs.contains { $0.kind == .chat && $0.number == k } } ?? 1
let t = BroTab(kind: .chat, number: n)
tabs.append(t)
activeTabId = t.id
chatHistory = []
}
}
}
}
// Tasks // Tasks
@Published var tasks: [AgentTask] = [] @Published var tasks: [AgentTask] = []
@@ -243,6 +262,16 @@ final class AppState: ObservableObject {
// Dropped file (set during upload flow) // Dropped file (set during upload flow)
@Published var droppedFile: DroppedFile? = nil @Published var droppedFile: DroppedFile? = nil
/// Original URLs of the last drop (all of them), for "На полку".
var lastDroppedURLs: [URL] = []
/// Puts the last dropped files on the shelf and goes back home.
func shelveLastDrop() {
FileShelf.shared.add(lastDroppedURLs)
SoundEngine.shared.play("pop")
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud)
view = .overview
}
// Short note message (shown in NoteView) // Short note message (shown in NoteView)
@Published var noteMessage: String? = nil @Published var noteMessage: String? = nil
@@ -395,6 +424,82 @@ final class AppState: ObservableObject {
#endif #endif
// Claude plan gauge (from statusline hook) // Claude plan gauge (from statusline hook)
// MARK: Chat panel size (BroV)
// Detents: 0 = fits the messages (as before), 1 = ⅓ screen, 2 = ½ screen, 3 = full height.
@Published var chatDetent: Int = 0
/// Live height while the user drags the handle; nil otherwise.
@Published var chatDragHeight: CGFloat? = nil
@Published var chatWidth: CGFloat = 720 {
didSet { UserDefaults.standard.set(Double(chatWidth), forKey: "chatWidth") }
}
static let chatWidthOptions: [CGFloat] = [640, 720, 820, 960]
var screenHeight: CGFloat {
(IslandWindowController.notchScreen() ?? NSScreen.main)?.frame.height ?? 900
}
func chatDetentHeight(_ d: Int) -> CGFloat {
let auto = view == .terminal ? 380 : min(300, 240 + CGFloat(chatHistory.count) * 40)
switch d {
case 1: return max(320, screenHeight / 3)
case 2: return max(340, screenHeight / 2)
case 3: return screenHeight - 6
default: return auto
}
}
/// Current chat island height (drag, else detent).
var chatIslandHeight: CGFloat { chatDragHeight ?? chatDetentHeight(chatDetent) }
// MARK: Tabs (BroV): brov-chat dialogs and term.macOS terminals
static let maxTabs = 6
@Published var tabs: [BroTab] = [BroTab(kind: .chat, number: 1)]
@Published var activeTabId: UUID? = nil
func openTab(_ kind: TabKind) {
guard tabs.count < Self.maxTabs else { return }
let used = Set(tabs.filter { $0.kind == kind }.map(\.number))
let n = (1...Self.maxTabs).first { !used.contains($0) } ?? tabs.count + 1
let tab = BroTab(kind: kind, number: n)
tabs.append(tab)
SoundEngine.shared.play("pop")
activateTab(tab.id)
}
func activateTab(_ id: UUID) {
guard let tab = tabs.first(where: { $0.id == id }) else { return }
if let cur = activeTabId, cur != id, tabs.first(where: { $0.id == cur })?.kind == .chat {
ChatTabs.shared.saveHistory(chatHistory, for: cur)
}
if activeTabId != id {
activeTabId = id
if tab.kind == .chat { chatHistory = ChatTabs.shared.history(for: id) }
}
view = tab.kind == .chat ? .prompt : .terminal
}
func closeTab(_ id: UUID) {
guard let i = tabs.firstIndex(where: { $0.id == id }) else { return }
let tab = tabs.remove(at: i)
if tab.kind == .chat { ChatTabs.shared.close(id) } else { TerminalStore.shared.close(id) }
SoundEngine.shared.play("close")
guard activeTabId == id else { return }
activeTabId = nil
if tab.kind == .chat { chatHistory = [] }
if let next = tabs.indices.contains(i) ? tabs[i] : tabs.last {
activateTab(next.id)
} else {
view = .overview
}
}
// MARK: Agent health (BroV)
@Published var crashedSessions: [CrashedSession] = []
@Published var claudeNeedsLogin = false
@Published var codexNeedsLogin = false
/// ChatGPT (Codex) weekly plan window, read from ~/.codex/sessions by CodexUsageMonitor.
@Published var codexWeekly: PlanWindow? = nil
@Published var claudePlanUsage: PlanUsage? = nil { @Published var claudePlanUsage: PlanUsage? = nil {
didSet { didSet {
if let u = claudePlanUsage, if let u = claudePlanUsage,
@@ -422,6 +527,7 @@ final class AppState: ObservableObject {
// MARK: - Init (loads persisted settings) // MARK: - Init (loads persisted settings)
private init() { private init() {
activeTabId = tabs.first?.id
let ud = UserDefaults.standard let ud = UserDefaults.standard
if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v } if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v }
@@ -431,6 +537,7 @@ final class AppState: ObservableObject {
!v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v } !v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v }
if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p } if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p }
if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v } if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v }
if let v = ud.object(forKey: "chatWidth") as? Double, v >= 560 { chatWidth = CGFloat(v) }
if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v } if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v }
if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v } if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v }
if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v } if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v }
+11 -21
View File
@@ -11,6 +11,7 @@ struct BotCanvasView: View {
// One engine per view instance (main bot) // One engine per view instance (main bot)
@StateObject private var engine = BotEngine() @StateObject private var engine = BotEngine()
@State private var fader = MemojiFader()
var body: some View { var body: some View {
TimelineView(.animation(paused: state.mode == .hidden)) { timeline in TimelineView(.animation(paused: state.mode == .hidden)) { timeline in
@@ -69,26 +70,15 @@ struct BotCanvasView: View {
engine.update(dt: dt) engine.update(dt: dt)
var ctx = context var ctx = context
engine.applyDance(&ctx, size: size) engine.applyDance(&ctx, size: size)
// Rigid-roll: when Mochi wears an outfit (presence > 0.05) and is rolling, // BroV: the Memoji is the character. The engine's box (file upload morph)
// rotate the entire body+accessories context around the body center so the // fades in only while morphing; hands and outfits are not drawn.
// whole character genuinely turns. Particles/badge (drawHandsAndExtras) are if engine.morph > 0.02 {
// drawn outside the rotated context and do not spin. var boxCtx = ctx
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 { boxCtx.opacity = Double(min(1, engine.morph * 2))
let center = engine.bodyCenter(size: size) engine.draw(context: boxCtx, size: size)
var rigidCtx = ctx
rigidCtx.translateBy(x: center.x, y: center.y)
rigidCtx.rotate(by: .radians(engine.roll))
rigidCtx.translateBy(x: -center.x, y: -center.y)
engine.drawHandsBehind(context: rigidCtx, size: size)
engine.drawOutfitBehind(context: rigidCtx, size: size)
engine.draw(context: rigidCtx, size: size)
engine.drawOutfitFront(context: rigidCtx, size: size)
} else {
engine.drawHandsBehind(context: ctx, size: size)
engine.drawOutfitBehind(context: ctx, size: size)
engine.draw(context: ctx, size: size)
engine.drawOutfitFront(context: ctx, size: size)
} }
engine.drawMemoji(context: ctx, size: size, fader: fader,
opacity: Double(max(0, 1 - engine.morph * 2)))
engine.drawHandsAndExtras(context: ctx, size: size) engine.drawHandsAndExtras(context: ctx, size: size)
} }
} }
@@ -172,8 +162,8 @@ struct BotCanvasView: View {
let (islandW, islandH) = islandSize(mode: state.mode, view: state.view, let (islandW, islandH) = islandSize(mode: state.mode, view: state.view,
progress: state.uploadProgress, progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt) let actualH: CGFloat = (state.mode == .expanded && state.view.isTall)
? min(300, 240 + CGFloat(state.chatHistory.count) * 40) ? state.chatIslandHeight
: islandH : islandH
let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view, let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view,
islandW: islandW, islandH: actualH, islandW: islandW, islandH: actualH,
@@ -149,6 +149,7 @@ final class ClaudeCodeCLI {
if let error = parser.errorMessage { if let error = parser.errorMessage {
if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") { if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") {
AgentAuth.shared.reportAuthError(.claude)
throw ClaudeCodeCLIError.failed("Claude Code не авторизован. Выполните `claude /login` в терминале.") throw ClaudeCodeCLIError.failed("Claude Code не авторизован. Выполните `claude /login` в терминале.")
} }
// A resume that points at a gone session: start fresh next time. // A resume that points at a gone session: start fresh next time.
@@ -64,6 +64,16 @@ enum ClaudePlanGauge {
} }
} }
/// The window with the higher effective percentage (its reset is the one that matters).
static func dominantWindow(_ usage: PlanUsage) -> PlanWindow? {
switch (usage.fiveHour, usage.sevenDay) {
case (nil, nil): return nil
case (let a?, nil): return a
case (nil, let b?): return b
case (let a?, let b?): return effectivePct(b) > effectivePct(a) ? b : a
}
}
/// Hex color for a given percentage (nil → grey). /// Hex color for a given percentage (nil → grey).
static func color(for pct: Double?) -> String { static func color(for pct: Double?) -> String {
guard let p = pct else { return "#6B7079" } guard let p = pct else { return "#6B7079" }
+16 -6
View File
@@ -271,7 +271,10 @@ final class ClaudeService {
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key) // MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async { func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
let cli = ClaudeCodeCLI.shared // Each brov-chat tab has its own claude conversation and history.
let tab = state.activeTabId
let cli = ChatTabs.shared.cli(for: tab)
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
var prompt = query var prompt = query
// Context goes with the first message of a conversation, like the API chat. // Context goes with the first message of a conversation, like the API chat.
if cli.sessionID == nil, let context { if cli.sessionID == nil, let context {
@@ -292,7 +295,7 @@ final class ClaudeService {
let placeholder = ChatMessage(role: .assistant, content: "") let placeholder = ChatMessage(role: .assistant, content: "")
let msgId = placeholder.id let msgId = placeholder.id
state.chatHistory.append(placeholder) if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
state.stateOverride = .thinking state.stateOverride = .thinking
do { do {
@@ -306,25 +309,32 @@ final class ClaudeService {
if !visible.isEmpty, state.stateOverride == .thinking { if !visible.isEmpty, state.stateOverride == .thinking {
state.stateOverride = nil // hide typing dots on first visible text state.stateOverride = nil // hide typing dots on first visible text
} }
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { if let tab {
ChatTabs.shared.update(tab: tab, message: msgId) { $0.content = visible }
} else if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
state.chatHistory[idx].content = visible state.chatHistory[idx].content = visible
} }
} }
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { if let tab {
if trimmed.isEmpty { ChatTabs.shared.remove(tab: tab, message: msgId) }
else { ChatTabs.shared.update(tab: tab, message: msgId) { $0.content = trimmed } }
} else if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
if trimmed.isEmpty { state.chatHistory.remove(at: idx) } if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
else { state.chatHistory[idx].content = trimmed } else { state.chatHistory[idx].content = trimmed }
} }
state.stateOverride = nil state.stateOverride = nil
state.view = .prompt if isOnScreen() && state.view != .terminal { state.view = .prompt }
state.claudeNeedsLogin = false
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
} catch is CancellationError { } catch is CancellationError {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty } state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
state.stateOverride = nil state.stateOverride = nil
} catch { } catch {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty } state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
if let tab { ChatTabs.shared.remove(tab: tab, message: msgId) }
state.stateOverride = nil state.stateOverride = nil
await showError(error.localizedDescription, state: state) if isOnScreen() { await showError(error.localizedDescription, state: state) }
} }
} }
+109
View File
@@ -0,0 +1,109 @@
import Foundation
// MARK: - ChatGPT (Codex) plan limits
//
// The ChatGPT/Codex app writes its plan limits into every session log
// (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) as
// "rate_limits":{"primary":{"used_percent":23.0,"window_minutes":10080,"resets_at":1791634947},"secondary":…}
// We read the newest logs once a minute and keep the weekly window (10080 min).
// Nothing is sent anywhere; the numbers are only as fresh as the last Codex turn.
enum CodexUsage {
static let weekMinutes = 10080
/// Weekly window from the most recent `rate_limits` found in the newest session logs.
static func readWeekly(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> PlanWindow? {
let root = home.appendingPathComponent(".codex/sessions")
for file in newestLogs(in: root, limit: 4) {
if let w = lastWeekly(in: file) { return w }
}
return nil
}
/// Newest *.jsonl files under the date folders, newest first.
static func newestLogs(in root: URL, limit: Int) -> [URL] {
let fm = FileManager.default
guard let e = fm.enumerator(at: root, includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]) else { return [] }
var files: [(URL, Date)] = []
for case let url as URL in e where url.pathExtension == "jsonl" {
let d = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
files.append((url, d))
}
return files.sorted { $0.1 > $1.1 }.prefix(limit).map(\.0)
}
/// Reads the tail of a log and returns the weekly window of its last `rate_limits`.
static func lastWeekly(in file: URL) -> PlanWindow? {
guard let h = try? FileHandle(forReadingFrom: file) else { return nil }
defer { try? h.close() }
let size = (try? h.seekToEnd()) ?? 0
let chunk: UInt64 = 512 * 1024
try? h.seek(toOffset: size > chunk ? size - chunk : 0)
guard let data = try? h.readToEnd(), let text = String(data: data, encoding: .utf8) else { return nil }
for line in text.split(separator: "\n").reversed() where line.contains("\"rate_limits\"") {
if let w = parseLine(String(line)) { return w }
}
return nil
}
static func parseLine(_ line: String) -> PlanWindow? {
guard let obj = try? JSONSerialization.jsonObject(with: Data(line.utf8)),
let limits = findRateLimits(obj) else { return nil }
let windows = [limits["primary"], limits["secondary"]].compactMap { $0 as? [String: Any] }
let weekly = windows.first { ($0["window_minutes"] as? NSNumber)?.intValue == weekMinutes }
guard let w = weekly,
let pct = (w["used_percent"] as? NSNumber)?.doubleValue,
let resets = (w["resets_at"] as? NSNumber)?.doubleValue, resets > 0 else { return nil }
return PlanWindow(usedPct: min(100, max(0, pct)), resetsAt: Date(timeIntervalSince1970: resets))
}
/// `rate_limits` can sit at any depth of the event (payload.info…), so search for it.
private static func findRateLimits(_ obj: Any) -> [String: Any]? {
if let d = obj as? [String: Any] {
if let r = d["rate_limits"] as? [String: Any] { return r }
for v in d.values { if let r = findRateLimits(v) { return r } }
} else if let a = obj as? [Any] {
for v in a { if let r = findRateLimits(v) { return r } }
}
return nil
}
}
@MainActor
final class CodexUsageMonitor {
static let shared = CodexUsageMonitor()
private var task: Task<Void, Never>?
func start() {
guard task == nil else { return }
task = Task {
while !Task.isCancelled {
let w = await Task.detached(priority: .utility) { CodexUsage.readWeekly() }.value
AppState.shared.codexWeekly = w
try? await Task.sleep(for: .seconds(60))
}
}
}
}
// MARK: - Reset time wording
enum PlanTime {
/// "3 ч 20 мин", "2 д 5 ч", "12 мин", "<1 мин".
static func untilReset(_ date: Date, now: Date = Date()) -> String {
let s = max(0, Int(date.timeIntervalSince(now)))
let d = s / 86400, h = (s % 86400) / 3600, m = (s % 3600) / 60
if d > 0 { return h > 0 ? "\(d) д \(h) ч" : "\(d) д" }
if h > 0 { return m > 0 ? "\(h) ч \(m) мин" : "\(h) ч" }
return m > 0 ? "\(m) мин" : "<1 мин"
}
/// "пт 14:00" within a week, "12 окт 14:00" beyond.
static func resetMoment(_ date: Date, now: Date = Date()) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "ru_RU")
f.dateFormat = date.timeIntervalSince(now) < 6 * 86400 ? "EE HH:mm" : "d MMM HH:mm"
return f.string(from: date).replacingOccurrences(of: ".", with: "").lowercased()
}
}
+94
View File
@@ -0,0 +1,94 @@
import Foundation
// MARK: - Destructive command warning
//
// Looks at a permission request (tool + tool_input) and returns short Russian reasons
// when it can destroy data or history, e.g. ["удаляет файлы рекурсивно"]. The approval
// card shows them in red and hides "Всегда" so a dangerous rule is never saved by one click.
// Heuristics only: an empty result does not mean the command is safe.
enum DangerCheck {
struct Rule {
let pattern: String
let reason: String
}
/// Shell command rules (case-insensitive regex, matched against the whole command).
static let shellRules: [Rule] = [
Rule(pattern: #"\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|-r\b.*-f\b|-f\b.*-r\b|--recursive|-r\b|-R\b)"#,
reason: "удаляет файлы рекурсивно"),
Rule(pattern: #"\brm\s+.*(\s|^)(/|~|\$HOME|\*)(\s|$)"#, reason: "удаляет корень, домашнюю папку или всё подряд"),
Rule(pattern: #"\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b|\s\+\S)"#, reason: "перезаписывает историю на сервере (force push)"),
Rule(pattern: #"\bgit\s+reset\s+--hard\b"#, reason: "выбрасывает несохранённые изменения (reset --hard)"),
Rule(pattern: #"\bgit\s+clean\s+-[a-z]*f"#, reason: "удаляет неотслеживаемые файлы (git clean)"),
Rule(pattern: #"\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)"#, reason: "откатывает все изменения в рабочей папке"),
Rule(pattern: #"\bgit\s+branch\s+-D\b"#, reason: "удаляет ветку без проверки слияния"),
Rule(pattern: #"\bgit\s+(rebase|filter-branch|filter-repo)\b"#, reason: "переписывает историю git"),
Rule(pattern: #"\bdrop\s+(table|database|schema|index|view)\b"#, reason: "удаляет таблицу или базу (DROP)"),
Rule(pattern: #"\btruncate\s+(table\s+)?\w"#, reason: "очищает таблицу (TRUNCATE)"),
Rule(pattern: #"\bdelete\s+from\s+\w+(\s*;|\s*$|\s*"|\s*')"#, reason: "удаляет все строки (DELETE без WHERE)"),
Rule(pattern: #"\bupdate\s+\w+\s+set\b(?!.*\bwhere\b)"#, reason: "меняет все строки (UPDATE без WHERE)"),
Rule(pattern: #"\b(mkfs|diskutil\s+(erase|zero|reformat|partition)|fdisk|newfs)"#, reason: "форматирует диск"),
Rule(pattern: #"\bdd\s+.*\bof="#, reason: "пишет напрямую на устройство (dd)"),
Rule(pattern: #"\bchmod\s+(-R\s+)?(0?777|a\+rwx)"#, reason: "открывает файлы всем на запись"),
Rule(pattern: #"\b(chmod|chown)\s+-R\b.*(\s/|\s~)"#, reason: "меняет права рекурсивно в системной или домашней папке"),
Rule(pattern: #"\bsudo\b"#, reason: "выполняется с правами администратора (sudo)"),
Rule(pattern: #"(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python3?)\b"#, reason: "скачивает и сразу запускает скрипт из интернета"),
Rule(pattern: #":\(\)\s*\{\s*:\|:&\s*\};:"#, reason: "fork-бомба"),
Rule(pattern: #"\b(kill|pkill|killall)\s+(-9\s+)?(-1\b|1\b|Finder|Dock|WindowServer|loginwindow)"#, reason: "убивает системные процессы"),
Rule(pattern: #"\b(shutdown|reboot|halt)\b"#, reason: "выключает или перезагружает компьютер"),
Rule(pattern: #"\bdocker\s+(system\s+prune|volume\s+(rm|prune)|rm\s+-f)"#, reason: "удаляет контейнеры или тома Docker"),
Rule(pattern: #"\bkubectl\s+delete\b"#, reason: "удаляет ресурсы в Kubernetes"),
Rule(pattern: #"\bterraform\s+(destroy|apply\s+.*-auto-approve)"#, reason: "меняет или уничтожает инфраструктуру"),
Rule(pattern: #"\b(npm|pnpm|yarn)\s+publish\b|\bgh\s+release\s+create\b"#, reason: "публикует наружу"),
Rule(pattern: #"\bgh\s+repo\s+(delete|edit\s+.*--visibility\s+public)"#, reason: "удаляет репозиторий или делает его публичным"),
Rule(pattern: #">\s*(/etc/|/dev/sd|/dev/disk|~/\.ssh/|~/\.zshrc|~/\.bashrc)"#, reason: "перезаписывает системный или личный конфиг"),
Rule(pattern: #"\bfind\b.*\s-delete\b|\bfind\b.*-exec\s+rm\b"#, reason: "удаляет найденные файлы пачкой"),
Rule(pattern: #"\b(security\s+delete-|defaults\s+delete\b|launchctl\s+(unload|remove|bootout))"#, reason: "меняет системные настройки macOS"),
]
/// Paths that are dangerous to write or edit.
static let sensitivePaths: [(pattern: String, reason: String)] = [
(#"(^|/)\.env(\.|$)"#, "правит секреты (.env)"),
(#"/\.ssh/"#, "правит SSH-ключи или конфиг"),
(#"^/(etc|System|Library|usr|bin|sbin)/"#, "пишет в системную папку"),
(#"/\.claude/settings(\.local)?\.json$"#, "меняет настройки Claude Code"),
(#"/\.(zshrc|bashrc|zprofile|bash_profile|gitconfig)$"#, "правит конфиг оболочки или git"),
(#"/\.git/"#, "правит внутренности репозитория (.git)"),
(#"(id_rsa|id_ed25519|\.pem|\.key|credentials|secrets?)(\.|$)"#, "трогает ключи или секреты"),
]
static func reasons(tool: String, input: [String: Any]) -> [String] {
var out: [String] = []
if let command = input["command"] as? String {
for rule in shellRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
out.append(rule.reason)
}
}
let fileTools: Set<String> = ["Write", "Edit", "MultiEdit", "NotebookEdit"]
if fileTools.contains(tool) {
let path = (input["file_path"] as? String ?? input["notebook_path"] as? String ?? "")
let expanded = (path as NSString).expandingTildeInPath
for (pattern, reason) in sensitivePaths where matches(pattern, expanded) && !out.contains(reason) {
out.append(reason)
}
}
return out
}
static func matches(_ pattern: String, _ text: String) -> Bool {
text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
}
/// What the approval card shows for non-shell tools: "Write · ~/proj/.env".
static func describe(tool: String, input: [String: Any]) -> String {
if let command = input["command"] as? String { return command }
if let path = (input["file_path"] ?? input["notebook_path"] ?? input["path"]) as? String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let short = path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path
return "\(tool) · \(short)"
}
if let url = input["url"] as? String { return "\(tool) · \(url)" }
return tool
}
}
+9 -16
View File
@@ -24,6 +24,7 @@ struct DesktopBotView: View {
@ObservedObject var appState: AppState @ObservedObject var appState: AppState
/// Engine owned by DesktopMochiController; controller calls methods on it directly. /// Engine owned by DesktopMochiController; controller calls methods on it directly.
let engine: BotEngine let engine: BotEngine
@State private var fader = MemojiFader()
@ObservedObject var viewState: DesktopBotViewState @ObservedObject var viewState: DesktopBotViewState
var body: some View { var body: some View {
@@ -59,23 +60,15 @@ struct DesktopBotView: View {
var c = ctx var c = ctx
engine.applyDance(&c, size: size) engine.applyDance(&c, size: size)
// Rigid roll when outfit is present (matches BotCanvasView) // BroV: the Memoji is the character. The engine's box (file upload morph)
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 { // fades in only while morphing; hands and outfits are not drawn.
let center = engine.bodyCenter(size: size) if engine.morph > 0.02 {
var rigidCtx = c var boxCtx = c
rigidCtx.translateBy(x: center.x, y: center.y) boxCtx.opacity = Double(min(1, engine.morph * 2))
rigidCtx.rotate(by: .radians(engine.roll)) engine.draw(context: boxCtx, size: size)
rigidCtx.translateBy(x: -center.x, y: -center.y)
engine.drawHandsBehind(context: rigidCtx, size: size)
engine.drawOutfitBehind(context: rigidCtx, size: size)
engine.draw(context: rigidCtx, size: size)
engine.drawOutfitFront(context: rigidCtx, size: size)
} else {
engine.drawHandsBehind(context: c, size: size)
engine.drawOutfitBehind(context: c, size: size)
engine.draw(context: c, size: size)
engine.drawOutfitFront(context: c, size: size)
} }
engine.drawMemoji(context: c, size: size, fader: fader,
opacity: Double(max(0, 1 - engine.morph * 2)))
engine.drawHandsAndExtras(context: c, size: size) engine.drawHandsAndExtras(context: c, size: size)
} }
} }
@@ -47,6 +47,7 @@ enum FileDropHandler {
static func handle(urls: [URL], state: AppState) async { static func handle(urls: [URL], state: AppState) async {
guard let url = urls.first else { return } guard let url = urls.first else { return }
let name = url.lastPathComponent let name = url.lastPathComponent
state.lastDroppedURLs = urls
// Start animation immediately — do NOT block on file copy. // Start animation immediately — do NOT block on file copy.
// Use original URL first; swap to inbox copy once background copy finishes. // Use original URL first; swap to inbox copy once background copy finishes.
+141
View File
@@ -0,0 +1,141 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
// MARK: - File shelf
//
// Drop files on the notch → "На полку": they wait in the top-left slot of the home
// view. Drag an icon out to put the file anywhere, click to open it, right-click to
// reveal or remove. The shelf keeps references (paths), not copies, and survives restarts;
// files that no longer exist are dropped silently.
struct ShelfItem: Identifiable, Codable, Equatable {
var id = UUID()
var path: String
var addedAt = Date()
var url: URL { URL(fileURLWithPath: path) }
var name: String { url.lastPathComponent }
}
@MainActor
final class FileShelf: ObservableObject {
static let shared = FileShelf()
static let maxItems = 12
private let key = "fileShelf"
@Published private(set) var items: [ShelfItem] = []
private init() {
if let data = UserDefaults.standard.data(forKey: key),
let saved = try? JSONDecoder().decode([ShelfItem].self, from: data) {
items = saved.filter { FileManager.default.fileExists(atPath: $0.path) }
}
}
func add(_ urls: [URL]) {
for url in urls where !items.contains(where: { $0.path == url.path }) {
items.insert(ShelfItem(path: url.path), at: 0)
}
if items.count > Self.maxItems { items = Array(items.prefix(Self.maxItems)) }
save()
}
func remove(_ item: ShelfItem) {
items.removeAll { $0.id == item.id }
save()
}
func clear() {
items = []
save()
}
/// Drops entries whose file was moved or deleted.
func prune() {
let alive = items.filter { FileManager.default.fileExists(atPath: $0.path) }
if alive != items { items = alive; save() }
}
private func save() {
if let data = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(data, forKey: key)
}
}
}
// MARK: - Shelf pill (home view, top-left slot)
struct ShelfPill: View {
@ObservedObject private var shelf = FileShelf.shared
var body: some View {
ZStack {
if shelf.items.isEmpty {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
Text("полка · брось файл на чёлку")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#4A4E55"))
} else {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(hex: "#0E0F11"))
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
HStack(spacing: 6) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(shelf.items) { item in
ShelfIcon(item: item)
}
}
.padding(.horizontal, 2)
}
Button {
shelf.clear()
SoundEngine.shared.play("close")
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundColor(Color(hex: "#5F646D"))
.frame(width: 16, height: 16)
.background(Circle().fill(Color.white.opacity(0.06)))
}
.buttonStyle(.plain)
.help("Очистить полку")
}
.padding(.horizontal, 8)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { shelf.prune() }
}
}
struct ShelfIcon: View {
let item: ShelfItem
@State private var isHovered = false
var body: some View {
Image(nsImage: NSWorkspace.shared.icon(forFile: item.path))
.resizable()
.interpolation(.high)
.frame(width: 28, height: 28)
.padding(2)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(isHovered ? 0.10 : 0)))
.scaleEffect(isHovered ? 1.08 : 1)
.onHover { h in withAnimation(.easeOut(duration: 0.12)) { isHovered = h } }
.help(item.name)
.onTapGesture { NSWorkspace.shared.open(item.url) }
.onDrag {
SoundEngine.shared.play("pop")
return NSItemProvider(contentsOf: item.url) ?? NSItemProvider()
}
.contextMenu {
Button("Открыть") { NSWorkspace.shared.open(item.url) }
Button("Показать в Finder") { NSWorkspace.shared.activateFileViewerSelecting([item.url]) }
Divider()
Button("Убрать с полки") { FileShelf.shared.remove(item) }
}
}
}
@@ -444,56 +444,15 @@ private func drawMochi(_ ctx: CGContext, p: GreetPose) {
ctx.rotate(by: CGFloat(p.tilt)) ctx.rotate(by: CGFloat(p.tilt))
ctx.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy)) ctx.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy))
drawHandL(ctx, hw: hw, hh: hh, p: p) // BroV: the Memoji replaces the drawn body, eyes and hands.
drawHandR(ctx, hw: hw, hh: hh, p: p) let face: MemojiExpression = {
if p.eye == .happy || p.eye == .content { return .happy }
let mpath = mochiPath(hw: hw, hh: hh) if p.open < 0.35 { return .blink }
whiteFill(ctx, mpath, x0: hw*0.6, y0: -hh, x1: -hw*0.6, y1: hh) if p.open < 0.8 { return .halfblink }
return .neutral
if p.tint > 0 { }()
let cs = CGColorSpaceCreateDeviceRGB() MemojiCG.draw(face, in: ctx, center: CGPoint(x: CGFloat(p.lookX) * hw * 0.12, y: 0),
let c0 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: CGFloat(p.tint)) side: hh * 2 * 1.45)
let c1 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: 0)
if let g = CGGradient(colorsSpace: cs, colors: [c0,c1] as CFArray, locations: [0,1]) {
ctx.saveGState()
ctx.addPath(mpath); ctx.clip()
ctx.drawLinearGradient(g, start: CGPoint(x: 0, y: hh),
end: CGPoint(x: 0, y: -hh*0.1), options: [])
ctx.restoreGState()
}
}
ctx.saveGState()
ctx.addPath(mpath); ctx.clip()
ctx.setFillColor(gHex("#16171A"))
ctx.setStrokeColor(gHex("#16171A"))
let er = CGFloat(p.hb*0.06)
let sp = CGFloat(p.hb*0.19)
let lx = CGFloat(p.lookX)*hw*0.42
let ly = CGFloat(p.lookY)*hh*0.28 + hh*0.12 + CGFloat(p.eyeRoll)*hh*1.25
for sd: CGFloat in [-1, 1] {
ctx.saveGState()
ctx.translateBy(x: sd*sp+lx, y: ly)
if p.eye == .happy {
ctx.setLineWidth(er*0.95); ctx.setLineCap(.round)
ctx.beginPath()
ctx.addArc(center: CGPoint(x: 0, y: er*0.6), radius: er*1.25,
startAngle: .pi*1.15, endAngle: .pi*1.85, clockwise: false)
ctx.strokePath()
} else if p.eye == .content {
ctx.setLineWidth(er*0.95); ctx.setLineCap(.round)
ctx.beginPath()
ctx.addArc(center: CGPoint(x: 0, y: -er*0.5), radius: er*1.25,
startAngle: .pi*0.15, endAngle: .pi*0.85, clockwise: false)
ctx.strokePath()
} else {
ctx.scaleBy(x: 1, y: max(0.12, CGFloat(p.open)))
ctx.addEllipse(in: CGRect(x: -er, y: -er, width: er*2, height: er*2))
ctx.fillPath()
}
ctx.restoreGState()
}
ctx.restoreGState()
// Activity badge // Activity badge
if p.badge > 0.01 { if p.badge > 0.01 {
+43 -2
View File
@@ -414,6 +414,20 @@ final class HookServer: @unchecked Sendable {
// Approval dismissed — fall through so the resolving event updates state normally. // Approval dismissed — fall through so the resolving event updates state normally.
} }
// BroV: crash watch + login status.
if let pid = (payload["brov_agent_pid"] as? NSNumber)?.intValue {
AgentWatch.shared.register(sessionId: sessionId, pid: pid,
kind: payload["brov_agent_kind"] as? String ?? "",
cwd: cwd, pillId: agentId)
}
if name == "SessionEnd" { AgentWatch.shared.ended(sessionId: sessionId) }
if name == "Notification" || name == "StopFailure" {
let text = (payload["message"] as? String ?? "") + " " + (payload["error"] as? String ?? "")
if AgentAuth.looksLikeAuthError(text) {
AgentAuth.shared.reportAuthError(isCodexEvent ? .codex : .claude)
}
}
switch name { switch name {
case "SessionStart": case "SessionStart":
@@ -672,7 +686,8 @@ final class HookServer: @unchecked Sendable {
return return
} }
let command = toolInput["command"] as? String ?? tool let command = DangerCheck.describe(tool: tool, input: toolInput)
let danger = DangerCheck.reasons(tool: tool, input: toolInput)
if pendingApprovalFD >= 0 { if pendingApprovalFD >= 0 {
// Displace the previous request: write "ask" then cancel its source. // Displace the previous request: write "ask" then cancel its source.
@@ -692,7 +707,9 @@ final class HookServer: @unchecked Sendable {
upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd)
state.updateTask(id: pillId, state: .approval) state.updateTask(id: pillId, state: .approval)
state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool, state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool,
command: command, inputKey: inputKey, pillId: pillId) command: command, inputKey: inputKey, pillId: pillId,
danger: danger)
if !danger.isEmpty { NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised) }
state.isPinned = true state.isPinned = true
SoundEngine.shared.play("approval") SoundEngine.shared.play("approval")
@@ -2048,6 +2065,30 @@ def main():
if agent: if agent:
payload.setdefault('coucou_agent', agent) payload.setdefault('coucou_agent', agent)
# BroV crash watch: on session start / prompt, report the agent process (claude/codex)
# found by walking up the parent chain, so BroV can notice when it dies mid-turn.
try:
ev0 = payload.get('hook_event_name', '') or arg_event
if ev0 in ('SessionStart', 'UserPromptSubmit', 'session_start', 'user_prompt_submit'):
import subprocess
pid = os.getppid()
for _ in range(8):
out = subprocess.run(['ps', '-o', 'ppid=,comm=', '-p', str(pid)],
capture_output=True, text=True, timeout=1).stdout.strip()
if not out:
break
ppid_s, _, comm = out.partition(' ')
name = os.path.basename(comm.strip())
if name in ('claude', 'codex'):
payload['brov_agent_pid'] = pid
payload['brov_agent_kind'] = name
break
pid = int(ppid_s)
if pid <= 1:
break
except Exception:
pass
# Enrich with terminal context # Enrich with terminal context
env = os.environ env = os.environ
payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) payload.setdefault('term_program', env.get('TERM_PROGRAM', ''))
+131 -22
View File
@@ -32,11 +32,7 @@ struct IslandContainer: View {
private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72) private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72)
private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34) private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34)
private var chatPromptHeight: CGFloat { private var chatPromptHeight: CGFloat { state.chatIslandHeight }
let base: CGFloat = 240
let perMsg: CGFloat = 40
return min(300, base + CGFloat(state.chatHistory.count) * perMsg)
}
/// Pixels the content must be pushed down to clear the concave ear transparent area. /// Pixels the content must be pushed down to clear the concave ear transparent area.
/// = 0 in expanded mode (no ears), = earRadius in compact/notch mode. /// = 0 in expanded mode (no ears), = earRadius in compact/notch mode.
@@ -101,11 +97,18 @@ struct IslandContainer: View {
Rectangle().frame(width: islandWidth, Rectangle().frame(width: islandWidth,
height: state.mode == .expanded ? 320 : islandHeight) height: state.mode == .expanded ? 320 : islandHeight)
} }
.opacity(uploadActive || greetingActive ? 0 : 1) .opacity(uploadActive || greetingActive || (state.mode == .expanded && state.view == .terminal) ? 0 : 1)
.animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive) .animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive)
CountdownBar(state: state, islandW: islandWidth) CountdownBar(state: state, islandW: islandWidth)
// Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen).
if state.mode == .expanded && [.prompt, .terminal, .overview, .empty].contains(state.view) {
ChatResizeHandle(state: state)
.position(x: islandWidth / 2, y: islandHeight - 7)
.transition(.opacity)
}
Group { Group {
if state.mode == .compact { if state.mode == .compact {
CompactMiniGrid(state: state) CompactMiniGrid(state: state)
@@ -127,7 +130,7 @@ struct IslandContainer: View {
let tr: CGFloat = 0 let tr: CGFloat = 0
withAnimation(anim) { withAnimation(anim) {
islandWidth = w islandWidth = w
islandHeight = (newMode == .expanded && state.view == .prompt) ? chatPromptHeight : h islandHeight = (newMode == .expanded && state.view.isTall) ? chatPromptHeight : h
cornerRadius = cr cornerRadius = cr
islandTopRadius = tr islandTopRadius = tr
} }
@@ -144,19 +147,31 @@ struct IslandContainer: View {
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
withAnimation(openSpring) { withAnimation(openSpring) {
islandWidth = w islandWidth = w
islandHeight = newView == .prompt ? chatPromptHeight : h islandHeight = newView.isTall ? chatPromptHeight : h
} }
} }
.onChange(of: state.chatHistory.count) { _, _ in .onChange(of: state.chatHistory.count) { _, _ in
guard state.mode == .expanded, state.view == .prompt else { return } guard state.mode == .expanded, state.view == .prompt else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight } withAnimation(openSpring) { islandHeight = chatPromptHeight }
} }
.onChange(of: state.chatDragHeight) { _, h in
guard state.mode == .expanded, state.view.isTall, let h else { return }
islandHeight = h // follow the pointer, no spring
}
.onChange(of: state.chatDetent) { _, _ in
guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight }
}
.onChange(of: state.chatWidth) { _, w in
guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandWidth = w }
}
.onAppear { .onAppear {
let (w, h) = islandSize(mode: state.mode, view: state.view, let (w, h) = islandSize(mode: state.mode, view: state.view,
progress: state.uploadProgress, progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
islandWidth = w islandWidth = w
islandHeight = state.view == .prompt ? chatPromptHeight : h islandHeight = state.view.isTall ? chatPromptHeight : h
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
islandTopRadius = 0 islandTopRadius = 0
} }
@@ -366,6 +381,11 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH:
let cy: CGFloat let cy: CGFloat
if let fixedY = layout.botY { if let fixedY = layout.botY {
cy = fixedY cy = fixedY
} else if view == .prompt {
// Tall chat: keep the character near the top instead of mid-screen.
let headerBottom: CGFloat = 42
let cardH: CGFloat = 84
cy = min(171, headerBottom + (islandH - headerBottom - cardH) / 2 + cardH / 2)
} else { } else {
// Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42) // Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42)
let headerBottom: CGFloat = 42 let headerBottom: CGFloat = 42
@@ -376,6 +396,52 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH:
} }
} }
// MARK: - Chat resize handle
/// Thin pill at the bottom of the island. Drag down: the chat follows the pointer and
/// snaps to the nearest detent (fits messages, ⅓, ½, full screen). From the home view
/// it opens the chat first.
struct ChatResizeHandle: View {
@ObservedObject var state: AppState
@State private var startHeight: CGFloat? = nil
@State private var isHovered = false
var body: some View {
Capsule()
.fill(Color.white.opacity(isHovered || startHeight != nil ? 0.55 : 0.22))
.frame(width: isHovered ? 54 : 40, height: 4)
.frame(width: 160, height: 14)
.contentShape(Rectangle())
.onHover { h in
withAnimation(.easeOut(duration: 0.15)) { isHovered = h }
if h { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
}
.gesture(
DragGesture(minimumDistance: 2, coordinateSpace: .global)
.onChanged { g in
if startHeight == nil {
if state.view != .prompt {
state.chatDetent = 0
state.view = .prompt
}
startHeight = state.chatDetentHeight(state.chatDetent)
}
let h = (startHeight ?? 0) + g.translation.height
state.chatDragHeight = min(state.chatDetentHeight(3), max(state.chatDetentHeight(0), h))
}
.onEnded { g in
let target = (startHeight ?? 0) + g.predictedEndTranslation.height
let best = (0...3).min { abs(state.chatDetentHeight($0) - target) < abs(state.chatDetentHeight($1) - target) } ?? 0
state.chatDetent = best
state.chatDragHeight = nil
startHeight = nil
SoundEngine.shared.play("tick")
}
)
.help("Потяни, чтобы развернуть чат")
}
}
// MARK: - Countdown bar // MARK: - Countdown bar
struct CountdownBar: View { struct CountdownBar: View {
@@ -437,7 +503,7 @@ struct IslandContentView: View {
// Views that fill available height instead of the fixed 98pt content frame: // Views that fill available height instead of the fixed 98pt content frame:
// chat (prompt) is always flexible; mail is flexible only when active so // chat (prompt) is always flexible; mail is flexible only when active so
// it doesn't push the ZStack taller when inactive. // it doesn't push the ZStack taller when inactive.
let isTall = v == .prompt || (v == .mail && active) let isTall = v == .prompt || ((v == .mail || v == .terminal) && active)
let anim: Animation = active let anim: Animation = active
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16) ? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
: .easeIn(duration: 0.16) : .easeIn(duration: 0.16)
@@ -464,20 +530,18 @@ struct IslandContentView: View {
struct IslandHeader: View { struct IslandHeader: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
private var leftTabs: [BroTab] { Array(state.tabs.prefix(3)) }
private var rightTabs: [BroTab] { Array(state.tabs.dropFirst(3)) }
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Left: tab capsules // Left: tab capsules
// Tabs are split around the camera: three to its left, the rest to its right,
// with "+" right after the last tab.
HStack(spacing: 5) { HStack(spacing: 5) {
TabButton(icon: "house.fill", view: .overview, state: state) TabButton(icon: "house.fill", view: .overview, state: state)
TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: { ForEach(leftTabs) { TabChip(tab: $0, state: state) }
#if !APPSTORE if rightTabs.isEmpty { NewTabButton(state: state) }
if state.promptContext == nil {
state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp)
}
#endif
})
TabButton(icon: "plus", view: .upload, state: state)
} }
.padding(.leading, 14) .padding(.leading, 14)
@@ -485,12 +549,30 @@ struct IslandHeader: View {
// Right: plan pill (GitHub build, home view only) + action icons // Right: plan pill (GitHub build, home view only) + action icons
HStack(spacing: 8) { HStack(spacing: 8) {
#if !APPSTORE if !rightTabs.isEmpty {
if state.view == .overview && state.showPlanInNotch && state.planRelayInstalled { HStack(spacing: 5) {
ClaudePlanHeaderPill(state: state) ForEach(rightTabs) { TabChip(tab: $0, state: state) }
NewTabButton(state: state)
} }
#endif }
// Plan limits live inside the Claude / ChatGPT pills (AppGridView):
// here they ran under the camera.
HStack(spacing: 14) { HStack(spacing: 14) {
// Fold everything back into the notch (and the chat to its normal size).
Button(action: {
state.chatDragHeight = nil
state.chatDetent = 0
NotificationCenter.default.post(name: .islandCollapse, object: "user")
}) {
Image(systemName: "chevron.up")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#8E939C"))
.frame(width: 22, height: 22)
.background(Circle().fill(Color.white.opacity(state.chatDetent > 0 && state.view == .prompt ? 0.10 : 0)))
}
.buttonStyle(.plain)
.help("Свернуть в чёлку")
Button(action: { Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
state.view = .settings state.view = .settings
@@ -564,8 +646,11 @@ struct ClaudePlanHeaderPill: View {
private var label: String { private var label: String {
guard let usage = state.claudePlanUsage, guard let usage = state.claudePlanUsage,
let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" } let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" }
guard let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() else {
return "Claude \(Int(pct.rounded()))%" return "Claude \(Int(pct.rounded()))%"
} }
return "Claude \(Int(pct.rounded()))% (до сброса \(PlanTime.untilReset(w.resetsAt)))"
}
private var isActive: Bool { state.showingPlanDetail || isHovered } private var isActive: Bool { state.showingPlanDetail || isHovered }
@@ -608,6 +693,30 @@ struct ClaudePlanHeaderPill: View {
} }
#endif #endif
/// ChatGPT weekly limit, from the Codex logs: "ChatGPT 23% (сброс пт 14:00)".
struct ChatGPTLimitPill: View {
let window: PlanWindow
var body: some View {
let pct = ClaudePlanGauge.effectivePct(window)
let color = ClaudePlanGauge.color(for: pct)
let reset = window.resetsAt > Date() ? " (сброс \(PlanTime.resetMoment(window.resetsAt)))" : ""
HStack(spacing: 4) {
Circle().fill(Color(hex: color)).frame(width: 6, height: 6)
Text("ChatGPT \(Int(pct.rounded()))%\(reset)")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(Color(hex: "#6B7079"))
.lineLimit(1)
.fixedSize()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Color(hex: "#0E0F11")))
.overlay(Capsule().stroke(Color(hex: color).opacity(0.14), lineWidth: 1))
.help("Недельный лимит ChatGPT (по логам Codex)")
}
}
// MARK: - Compact mini mochi grid (2×2 to the right of the notch) // MARK: - Compact mini mochi grid (2×2 to the right of the notch)
struct CompactMiniGrid: View { struct CompactMiniGrid: View {
+21 -4
View File
@@ -27,6 +27,7 @@ struct IslandViewContent: View {
case .settings: SettingsIslandView(state: state) case .settings: SettingsIslandView(state: state)
case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView
case .wardrobe: WardrobeView(state: state) case .wardrobe: WardrobeView(state: state)
case .terminal: TerminalScreen(state: state)
} }
} }
} }
@@ -298,11 +299,25 @@ struct ApprovalView: View {
var approval: ApprovalInfo? { state.pendingApproval } var approval: ApprovalInfo? { state.pendingApproval }
var body: some View { var body: some View {
let danger = approval?.danger ?? []
ZStack { ZStack {
CardBackground(wash: .amber) CardBackground(wash: danger.isEmpty ? .amber : .red)
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
AgentWho(task: state.focusTask, label: "просит разрешение") AgentWho(task: state.focusTask, label: "просит разрешение")
CodeBlock(text: approval?.command ?? approval?.tool ?? "…") CodeBlock(text: approval?.command ?? approval?.tool ?? "…")
if !danger.isEmpty {
HStack(alignment: .top, spacing: 5) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10, weight: .bold))
Text("Опасно: " + danger.joined(separator: "; "))
.font(.system(size: 10.5, weight: .semibold))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundColor(Color(hex: "#FF6B76"))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(RoundedRectangle(cornerRadius: 6).fill(Color(hex: "#F4505E").opacity(0.14)))
}
HStack(spacing: 8) { HStack(spacing: 8) {
SecondaryButton("Запретить") { SecondaryButton("Запретить") {
HookServer.shared.sendApprovalDecision("deny") HookServer.shared.sendApprovalDecision("deny")
@@ -310,8 +325,9 @@ struct ApprovalView: View {
PrimaryButton("Разрешить") { PrimaryButton("Разрешить") {
HookServer.shared.sendApprovalDecision("allow") HookServer.shared.sendApprovalDecision("allow")
} }
// Codex rejects updatedPermissions, so "Always" is not offered // Codex rejects updatedPermissions, so "Always" is not offered.
if approval?.pillId != "agent_codex" { // Never for a dangerous command: one click must not save a destructive rule.
if approval?.pillId != "agent_codex" && danger.isEmpty {
SecondaryButton("Всегда") { SecondaryButton("Всегда") {
HookServer.shared.sendApprovalDecision("always") HookServer.shared.sendApprovalDecision("always")
} }
@@ -922,6 +938,7 @@ struct ChooseView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
PrimaryButton("Задать вопрос") { state.view = .prompt } PrimaryButton("Задать вопрос") { state.view = .prompt }
SecondaryButton("Отправить почтой") { state.view = .mail } SecondaryButton("Отправить почтой") { state.view = .mail }
SecondaryButton("На полку") { state.shelveLastDrop() }
} }
} }
.padding(.leading, 98) .padding(.leading, 98)
@@ -1244,7 +1261,7 @@ struct PromptView: View {
text = "" text = ""
state.chatHistory = [] state.chatHistory = []
ClaudeService.shared.clearConversation() ClaudeService.shared.clearConversation()
ClaudeCodeCLI.shared.newConversation() ChatTabs.shared.cli(for: state.activeTabId).newConversation()
focused = true focused = true
} }
} }
@@ -56,9 +56,11 @@ final class IslandWindowController: NSWindowController {
let nW = geometry.width let nW = geometry.width
let nH = geometry.height let nH = geometry.height
let panelW: CGFloat = 720 // Tall, transparent panel: the chat can grow down to the full screen height
let panelH: CGFloat = 320 // and widen; clicks outside the island still pass through (pollFrame).
let sf = screen.frame let sf = screen.frame
let panelW: CGFloat = 1100
let panelH: CGFloat = sf.height
let panel = IslandPanel( let panel = IslandPanel(
contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH, contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH,
width: panelW, height: panelH), width: panelW, height: panelH),
@@ -204,7 +206,12 @@ final class IslandWindowController: NSWindowController {
self?.fsm.greetComplete() self?.fsm.greetComplete()
} }
fsm.isHeldOpen = { AppState.shared.pendingApproval != nil } // Held open: a pending approval, or a chat/terminal tab on screen (typing with
// the pointer elsewhere must not fold it away; ⌃ in the header closes it).
fsm.isHeldOpen = {
let s = AppState.shared
return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall)
}
} }
// MARK: - 60 Hz polling loop // MARK: - 60 Hz polling loop
@@ -232,10 +239,12 @@ final class IslandWindowController: NSWindowController {
// in the app window immediately below the menu bar. // in the app window immediately below the menu bar.
let hoverRect = !hasNotch && state.mode != .expanded let hoverRect = !hasNotch && state.mode != .expanded
? islandRect : islandRect.insetBy(dx: -6, dy: -6) ? islandRect : islandRect.insetBy(dx: -6, dy: -6)
let inIsland = hoverRect.contains(local) // While the chat handle is dragged, count the pointer as inside (no collapse).
let inIsland = hoverRect.contains(local) || state.chatDragHeight != nil
// Toggle click-through // Toggle click-through
let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil
|| state.chatDragHeight != nil
if panel.ignoresMouseEvents == shouldAcceptMouse { if panel.ignoresMouseEvents == shouldAcceptMouse {
panel.ignoresMouseEvents = !shouldAcceptMouse panel.ignoresMouseEvents = !shouldAcceptMouse
if shouldAcceptMouse, let cv = panel.contentView { if shouldAcceptMouse, let cv = panel.contentView {
@@ -360,8 +369,14 @@ final class IslandWindowController: NSWindowController {
state.lastActivity = .now state.lastActivity = .now
} }
func collapse() { /// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too;
/// only a pending approval still keeps the island open.
func collapse(byUser: Bool = false) {
if byUser {
guard state.pendingApproval == nil else { return }
} else {
guard fsm.isHeldOpen?() != true else { return } guard fsm.isHeldOpen?() != true else { return }
}
state.isPinned = false state.isPinned = false
finishedPinTimer?.cancel() finishedPinTimer?.cancel()
// Keep the FSM in step with what is on screen (home/coucou → petit now). // Keep the FSM in step with what is on screen (home/coucou → petit now).
@@ -382,7 +397,7 @@ final class IslandWindowController: NSWindowController {
switch action { switch action {
case .toggleIsland: case .toggleIsland:
if state.mode == .expanded { if state.mode == .expanded {
collapse() collapse(byUser: true)
} else { } else {
islandPanel.makeKey() islandPanel.makeKey()
expand(to: defaultView()) expand(to: defaultView())
@@ -494,6 +509,8 @@ final class IslandWindowController: NSWindowController {
if cmd, let n = digitCodes[event.keyCode] { if cmd, let n = digitCodes[event.keyCode] {
switchToPill(number: n); return true switchToPill(number: n); return true
} }
// ⎋ Escape — belongs to the shell in a term.macOS tab (vim, claude, less…).
if event.keyCode == 53 && state.view == .terminal { return false }
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse // ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
if event.keyCode == 53 && raw.isEmpty { if event.keyCode == 53 && raw.isEmpty {
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil) let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
@@ -606,8 +623,9 @@ final class IslandWindowController: NSWindowController {
} }
// Collapse requests from views (OK button, etc.) // Collapse requests from views (OK button, etc.)
NotificationCenter.default.addObserver(forName: .islandCollapse, object: nil, queue: .main) { [weak self] _ in NotificationCenter.default.addObserver(forName: .islandCollapse, object: nil, queue: .main) { [weak self] note in
self?.collapse() let byUser = (note.object as? String) == "user"
MainActor.assumeIsolated { self?.collapse(byUser: byUser) }
} }
// Wardrobe open/close from desktop Mochi right-click (does NOT post .hookExpand) // Wardrobe open/close from desktop Mochi right-click (does NOT post .hookExpand)
@@ -1015,12 +1033,10 @@ final class IslandWindowController: NSWindowController {
let panelW = window?.frame.width ?? 720 let panelW = window?.frame.width ?? 720
let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view, let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view,
progress: s.uploadProgress, nw: notchW, nh: notchH) progress: s.uploadProgress, nw: notchW, nh: notchH)
// Chat view resizes dynamically — must match IslandContainer.chatPromptHeight // Chat view resizes dynamically — AppState.chatIslandHeight
let islandH: CGFloat let islandH: CGFloat
if s.mode == .expanded && s.view == .prompt { if s.mode == .expanded && s.view.isTall {
let base: CGFloat = 240 islandH = s.chatIslandHeight
let perMsg: CGFloat = 40
islandH = min(300, base + CGFloat(s.chatHistory.count) * perMsg)
} else { } else {
islandH = fixedH islandH = fixedH
} }
@@ -1082,10 +1098,8 @@ final class IslandPanel: NSPanel {
let (w, fixedH) = islandSize(mode: s.mode, view: s.view, let (w, fixedH) = islandSize(mode: s.mode, view: s.view,
progress: s.uploadProgress, nw: nw, nh: nh) progress: s.uploadProgress, nw: nw, nh: nh)
let h: CGFloat let h: CGFloat
if s.mode == .expanded && s.view == .prompt { if s.mode == .expanded && s.view.isTall {
let base: CGFloat = 240 h = s.chatIslandHeight
let perMsg: CGFloat = 40
h = min(300, base + CGFloat(s.chatHistory.count) * perMsg)
} else { } else {
h = fixedH h = fixedH
} }
@@ -1140,6 +1154,7 @@ extension Notification.Name {
// MARK: - islandSize (takes real notch dimensions) // MARK: - islandSize (takes real notch dimensions)
@MainActor
func islandSize(mode: IslandMode, view: IslandView, func islandSize(mode: IslandMode, view: IslandView,
progress: Double = 0, progress: Double = 0,
nw: CGFloat = IslandConst.notchWidth, nw: CGFloat = IslandConst.notchWidth,
@@ -1149,6 +1164,8 @@ func islandSize(mode: IslandMode, view: IslandView,
case .compact: return (nw + 160, nh) case .compact: return (nw + 160, nh)
case .expanded: case .expanded:
let layout = IslandConst.viewLayouts[view]! let layout = IslandConst.viewLayouts[view]!
// BroV: the chat has its own width (Settings → Чат).
if view.isTall { return (AppState.shared.chatWidth, layout.height) }
return (IslandConst.expandedWidth, layout.height) return (IslandConst.expandedWidth, layout.height)
} }
} }
+159
View File
@@ -0,0 +1,159 @@
import SwiftUI
import AppKit
// MARK: - Memoji character (BroV)
//
// The character is 11 pre-rendered expressions (Resources/memoji/*.png, 256×256 RGBA).
// BotEngine still runs every animation (gaze, blink, squash, bounce, roll, emotes,
// particles); here we only pick the expression for the engine's state and draw the
// picture with the engine's transforms. Expression changes cross-fade in 220 ms;
// blinks swap neutral → halfblink → blink → halfblink → neutral instantly.
enum MemojiExpression: String, CaseIterable {
case neutral, blink, halfblink, happy, thinking, question, concern, wink, speaking, focused, surprised
}
enum MemojiAssets {
nonisolated(unsafe) private static var cache: [MemojiExpression: CGImage] = [:]
private static let lock = NSLock()
static func image(_ e: MemojiExpression) -> CGImage? {
lock.lock(); defer { lock.unlock() }
if let img = cache[e] { return img }
guard let url = Bundle.main.url(forResource: e.rawValue, withExtension: "png", subdirectory: "memoji"),
let src = CGImageSourceCreateWithURL(url as CFURL, nil),
let img = CGImageSourceCreateImageAtIndex(src, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary)
else { return nil }
cache[e] = img
return img
}
/// Expression for an agent state (states.json from the kit).
static func forState(_ s: BotState) -> MemojiExpression {
switch s {
case .idle: return .neutral
case .working: return .focused
case .thinking, .searching: return .thinking
case .approval, .question: return .question
case .error: return .concern
case .finished: return .happy
case .ratelimit, .dizzy: return .halfblink
case .sleeping: return .blink
}
}
/// Expression for an emote / transient eye override from the engine.
static func forEye(_ e: EyeShape) -> MemojiExpression? {
switch e {
case .happy, .heart, .star: return .happy
case .dot, .cup: return .surprised
case .wink: return .wink
case .line, .flat: return .concern
case .closed: return .blink
case .tired, .spiral: return .halfblink
case .pill, .wide: return nil
}
}
/// Faces whose eyes may be blinked by swapping in the neutral blink frames.
static let blinkable: Set<MemojiExpression> = [.neutral, .focused, .thinking, .question, .concern, .speaking]
}
/// Cross-fade bookkeeping, one per drawn character.
@MainActor
final class MemojiFader {
private(set) var current: MemojiExpression = .neutral
private(set) var previous: MemojiExpression?
private var changedAt: Double = 0
static let fade: Double = 0.22
/// Returns (previous, current, progress 0…1).
func step(to e: MemojiExpression, now: Double) -> (MemojiExpression?, MemojiExpression, Double) {
if e != current {
previous = current
current = e
changedAt = now
}
let p = min(1, (now - changedAt) / Self.fade)
if p >= 1 { previous = nil }
return (previous, current, p)
}
}
extension BotEngine {
/// The face to show right now, before blinking.
var memojiBase: MemojiExpression {
if let eye = eyeOverride, let e = MemojiAssets.forEye(eye) { return e }
if isDancing && dancingLevel > 0.15 { return .happy }
return MemojiAssets.forState(state)
}
/// Draws the Memoji with the engine's current pose. Call after `update(dt:)`.
func drawMemoji(context: GraphicsContext, size: CGSize, fader: MemojiFader, opacity: Double = 1) {
guard opacity > 0.01 else { return }
let W = size.width
let R = W * 0.3
let center = bodyCenter(size: size)
// Expression + blink frames.
var face = memojiBase
if MemojiAssets.blinkable.contains(face) {
if open < 0.35 { face = .blink } else if open < 0.8 { face = .halfblink }
}
let isBlinkFrame = face == .blink || face == .halfblink
let now = CACurrentMediaTime()
// Blinks don't cross-fade (they'd look like a dissolve, not a blink).
let (prev, cur, p) = isBlinkFrame && MemojiAssets.blinkable.contains(memojiBase)
? (nil, face, 1.0)
: fader.step(to: face, now: now)
var c = context
c.opacity = opacity
c.translateBy(x: center.x, y: center.y)
// Head turn: slide toward the gaze and narrow a little, like turning a sphere.
c.translateBy(x: yaw * R * 0.18, y: -pitch * R * 0.12)
c.rotate(by: .radians(tilt + roll))
c.scaleBy(x: sx * (1 - abs(yaw) * 0.10), y: sy * (1 - abs(pitch) * 0.06))
// Status tint as a soft glow behind the head.
if tint > 0.02 {
let glow = Color(red: col.0, green: col.1, blue: col.2)
let gr = R * 1.45
c.fill(Path(ellipseIn: CGRect(x: -gr, y: -gr * 0.9, width: gr * 2, height: gr * 2)),
with: .radialGradient(Gradient(colors: [glow.opacity(0.45 * Double(tint)), glow.opacity(0)]),
center: .zero, startRadius: 0, endRadius: gr))
}
let side = R * 2.9 * es.clamped(0.9, 1.3)
let rect = CGRect(x: -side / 2, y: -side * 0.52, width: side, height: side)
if let prev, p < 1, let img = MemojiAssets.image(prev) {
var pc = c
pc.opacity = opacity * (1 - p)
pc.draw(Image(decorative: img, scale: 1), in: rect)
}
if let img = MemojiAssets.image(cur) {
var cc = c
cc.opacity = opacity * (prev == nil ? 1 : p)
cc.draw(Image(decorative: img, scale: 1), in: rect)
}
}
}
private extension CGFloat {
func clamped(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { Swift.min(hi, Swift.max(lo, self)) }
}
// MARK: - Plain CGContext drawing (greeting scene)
enum MemojiCG {
/// Draws an expression centred at `center` with height `side` into a y-down CGContext.
static func draw(_ e: MemojiExpression, in ctx: CGContext, center: CGPoint, side: CGFloat,
tilt: CGFloat = 0, sx: CGFloat = 1, sy: CGFloat = 1, alpha: CGFloat = 1) {
guard let img = MemojiAssets.image(e), alpha > 0.01 else { return }
ctx.saveGState()
ctx.setAlpha(alpha)
ctx.translateBy(x: center.x, y: center.y)
ctx.rotate(by: tilt)
ctx.scaleBy(x: sx, y: -sy) // flip: CGImage draws y-up
ctx.draw(img, in: CGRect(x: -side / 2, y: -side * 0.48, width: side, height: side))
ctx.restoreGState()
}
}
+300
View File
@@ -0,0 +1,300 @@
import Foundation
import AppKit
// MARK: - Keeping BroV and the agents alive
//
// 1. BroVLaunchAgent — a user LaunchAgent restarts BroV if it crashes (KeepAlive on a
// non-zero exit) and starts it at login. Quitting from the menu (exit 0) stays quit.
// 2. AgentWatch — the hook relay reports the claude/codex process of each session. When
// that process disappears mid-turn (working, thinking, waiting for us) without a
// SessionEnd, the session is marked "упала"; one click (or the auto option) reopens
// it in Terminal with `claude --resume <id>` / `codex resume <id>`.
// 3. AgentAuth — `claude auth status` / `codex login status` every 5 minutes, plus auth
// errors seen in the chat or in hook messages, drive the "нужен вход" status.
// MARK: - LaunchAgent for BroV
enum BroVLaunchAgent {
static let label = "local.maksar.brov"
static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
}
static var installedAppPath: String {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications/BroV.app").path
}
static let enabledKey = "brovKeepAlive"
static var isInstalled: Bool { FileManager.default.fileExists(atPath: plistURL.path) }
/// Started by launchd (the job's process)?
static var isManaged: Bool { ProcessInfo.processInfo.environment["XPC_SERVICE_NAME"] == label }
static var plist: String {
let exe = installedAppPath + "/Contents/MacOS/BroV"
return """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>\(label)</string>
<key>ProgramArguments</key><array><string>\(exe)</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
<key>ThrottleInterval</key><integer>10</integer>
<key>ProcessType</key><string>Interactive</string>
</dict>
</plist>
"""
}
/// Called at launch: installs on first run (setting on by default) and hands this
/// instance over to launchd so the running copy is the protected one.
@MainActor
static func ensure() {
let ud = UserDefaults.standard
if ud.object(forKey: enabledKey) == nil { ud.set(true, forKey: enabledKey) }
guard ud.bool(forKey: enabledKey) else { return }
// Only the installed copy is managed (not a build run from Xcode / DerivedData).
guard Bundle.main.bundlePath == installedAppPath else { return }
guard !isManaged else { return }
// Loop guard: if launchd's instance isn't recognised as managed, never hand over
// again within a minute (otherwise every start would quit and restart).
let last = ud.double(forKey: "brovHandoverAt")
let now = Date().timeIntervalSince1970
guard now - last > 60 else { return }
ud.set(now, forKey: "brovHandoverAt")
// Opened by hand (or first run): hand this instance over to launchd. No other
// BroV is running here (AppDelegate exits duplicates before calling us).
install(handOver: true)
}
@MainActor
static func install(handOver: Bool) {
try? FileManager.default.createDirectory(at: plistURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try? plist.write(to: plistURL, atomically: true, encoding: .utf8)
// Bootstrap after we quit, so launchd starts the only instance.
detached("sleep 1; launchctl bootout gui/\(getuid())/\(label) 2>/dev/null; launchctl bootstrap gui/\(getuid()) '\(plistURL.path)'")
if handOver { NSApp.terminate(nil) }
}
@MainActor
static func uninstall() {
// bootout ends this very process; reopen it unmanaged afterwards.
detached("sleep 1; launchctl bootout gui/\(getuid())/\(label) 2>/dev/null; rm -f '\(plistURL.path)'; open '\(installedAppPath)'")
}
static func isLoaded() -> Bool {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
p.arguments = ["print", "gui/\(getuid())/\(label)"]
p.standardOutput = FileHandle.nullDevice
p.standardError = FileHandle.nullDevice
try? p.run()
p.waitUntilExit()
return p.terminationStatus == 0
}
private static func detached(_ script: String) {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/sh")
p.arguments = ["-c", "(\(script)) >/dev/null 2>&1 &"]
try? p.run()
}
}
// MARK: - Watching agent processes
enum AgentKind: String, Codable {
case claude, codex
var title: String { self == .claude ? "Claude Code" : "Codex" }
}
struct CrashedSession: Identifiable, Equatable {
let id: String // session id
let kind: AgentKind
let cwd: String
let at: Date
var project: String { (cwd as NSString).lastPathComponent }
}
@MainActor
final class AgentWatch {
static let shared = AgentWatch()
static let autoKey = "agentAutoRestart"
private struct Watched {
let pid: pid_t
let kind: AgentKind
let cwd: String
let pillId: String
}
private var watched: [String: Watched] = [:] // by session id
private var restarts: [String: [Date]] = [:] // crash-loop guard
private var timer: Timer?
func start() {
guard timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
Task { @MainActor in AgentWatch.shared.check() }
}
}
/// From the hook server, for every event carrying `brov_agent_pid`.
func register(sessionId: String, pid: Int, kind: String, cwd: String, pillId: String) {
guard !sessionId.isEmpty, pid > 1, let k = AgentKind(rawValue: kind) else { return }
watched[sessionId] = Watched(pid: pid_t(pid), kind: k, cwd: cwd, pillId: pillId)
AppState.shared.crashedSessions.removeAll { $0.id == sessionId }
}
func ended(sessionId: String) {
watched.removeValue(forKey: sessionId)
}
private func check() {
let state = AppState.shared
for (sid, w) in watched where kill(w.pid, 0) != 0 && errno == ESRCH {
watched.removeValue(forKey: sid)
// Gone while idle = the user closed it. Gone mid-turn = it fell over.
let busy: Set<BotState> = [.working, .thinking, .searching, .approval, .question]
let taskState = state.tasks.first(where: { $0.id == w.pillId })?.state ?? .idle
guard busy.contains(taskState) else { continue }
let crash = CrashedSession(id: sid, kind: w.kind, cwd: w.cwd, at: Date())
state.crashedSessions.removeAll { $0.id == sid }
state.crashedSessions.append(crash)
state.updateTask(id: w.pillId, state: .error)
SoundEngine.shared.play("error")
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
appendAppLog("watch.log", "\(w.kind.rawValue) \(sid.prefix(8)) died mid-turn in \(w.cwd)")
if UserDefaults.standard.bool(forKey: Self.autoKey) { revive(crash, auto: true) }
}
}
/// Reopens the session in Terminal. Auto mode allows at most 2 restarts per 10 minutes.
func revive(_ crash: CrashedSession, auto: Bool = false) {
let now = Date()
var times = (restarts[crash.id] ?? []).filter { now.timeIntervalSince($0) < 600 }
if auto && times.count >= 2 {
appendAppLog("watch.log", "auto-restart of \(crash.id.prefix(8)) skipped: crash loop")
return
}
times.append(now)
restarts[crash.id] = times
let cmd = crash.kind == .claude
? "claude --resume \(shellQuote(crash.id))"
: "codex resume \(shellQuote(crash.id))"
TerminalLauncher.run("cd \(shellQuote(crash.cwd)) && \(cmd)", name: "brov-resume")
AppState.shared.crashedSessions.removeAll { $0.id == crash.id }
}
func dismiss(_ crash: CrashedSession) {
AppState.shared.crashedSessions.removeAll { $0.id == crash.id }
}
}
// MARK: - Login status
@MainActor
final class AgentAuth {
static let shared = AgentAuth()
private var task: Task<Void, Never>?
func start() {
guard task == nil else { return }
task = Task {
while !Task.isCancelled {
await refresh()
try? await Task.sleep(for: .seconds(300))
}
}
}
func refresh() async {
async let c = Self.claudeLoggedIn()
async let x = Self.codexLoggedIn()
let (claude, codex) = await (c, x)
let s = AppState.shared
// nil = CLI not installed / unknown: keep what we had.
if let claude { s.claudeNeedsLogin = !claude }
if let codex { s.codexNeedsLogin = !codex }
}
/// Marks a CLI as logged out after an auth error seen elsewhere (chat, hook message).
func reportAuthError(_ kind: AgentKind) {
if kind == .claude { AppState.shared.claudeNeedsLogin = true }
else { AppState.shared.codexNeedsLogin = true }
}
static func looksLikeAuthError(_ text: String) -> Bool {
let t = text.lowercased()
return ["authenticate", "not logged in", "/login", "oauth", "401", "unauthorized",
"invalid api key", "please log in", "login required"].contains { t.contains($0) }
}
func login(_ kind: AgentKind) {
TerminalLauncher.run(kind == .claude ? "claude /login" : "codex login", name: "brov-login")
}
private nonisolated static func claudeLoggedIn() async -> Bool? {
guard let bin = ClaudeCodeCLI.resolveBinary(custom: "") else { return nil }
guard let out = await run(bin, ["auth", "status"]) else { return nil }
if let data = out.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ok = json["loggedIn"] as? Bool { return ok }
return nil
}
private nonisolated static func codexLoggedIn() async -> Bool? {
let fm = FileManager.default
let candidates = ["/opt/homebrew/bin/codex", "/usr/local/bin/codex",
fm.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/codex").path,
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"]
guard let bin = candidates.first(where: { fm.isExecutableFile(atPath: $0) }) else { return nil }
guard let out = await run(bin, ["login", "status"]) else { return nil }
let t = out.lowercased()
if t.contains("not logged in") { return false }
if t.contains("logged in") { return true }
return nil
}
/// Runs a CLI briefly (20 s cap), stdin closed, without the nested-session markers.
private nonisolated static func run(_ bin: String, _ args: [String]) async -> String? {
await withCheckedContinuation { cont in
DispatchQueue.global(qos: .utility).async {
let p = Process()
p.executableURL = URL(fileURLWithPath: bin)
p.arguments = args
p.environment = ClaudeCodeCLI.childEnvironment()
p.standardInput = FileHandle.nullDevice
let pipe = Pipe()
p.standardOutput = pipe
p.standardError = pipe
guard (try? p.run()) != nil else { cont.resume(returning: nil); return }
let deadline = DispatchTime.now() + 20
DispatchQueue.global().asyncAfter(deadline: deadline) { if p.isRunning { p.terminate() } }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
cont.resume(returning: String(data: data, encoding: .utf8))
}
}
}
}
// MARK: - Opening a command in Terminal
enum TerminalLauncher {
/// Opens Terminal running `command` in a login shell, via a .command file
/// (no Apple Events permission needed).
static func run(_ command: String, name: String) {
let dir = HookServer.supportDir.appendingPathComponent("commands")
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("\(name)-\(Int(Date().timeIntervalSince1970)).command")
let script = "#!/bin/zsh -l\nunset CLAUDECODE\n\(command)\n"
try? script.write(to: url, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
NSWorkspace.shared.open(url)
}
}
func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
+27 -1
View File
@@ -228,6 +228,26 @@ struct SettingsView: View {
// MARK: - General section // MARK: - General section
@ViewBuilder private var generalSection: some View { @ViewBuilder private var generalSection: some View {
GroupBox("Надёжность") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Поднимать BroV, если он упал, и запускать при входе в систему", isOn: Binding(
get: { UserDefaults.standard.bool(forKey: BroVLaunchAgent.enabledKey) },
set: { on in
UserDefaults.standard.set(on, forKey: BroVLaunchAgent.enabledKey)
if on { BroVLaunchAgent.install(handOver: !BroVLaunchAgent.isManaged) }
else if BroVLaunchAgent.isInstalled { BroVLaunchAgent.uninstall() }
}))
Text("Если выйти через меню, BroV не вернётся. Работает для копии в ~/Applications.")
.font(.system(size: 11)).foregroundColor(.secondary)
Toggle("Сразу поднимать упавшие сессии Claude Code и Codex", isOn: Binding(
get: { UserDefaults.standard.bool(forKey: AgentWatch.autoKey) },
set: { UserDefaults.standard.set($0, forKey: AgentWatch.autoKey) }))
Text("Сессия считается упавшей, если её процесс пропал посреди работы. Без галочки в пилюле появится кнопка «поднять». Не чаще 2 раз за 10 минут.")
.font(.system(size: 11)).foregroundColor(.secondary)
}
.padding(6)
}
GroupBox("Звук") { GroupBox("Звук") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Toggle("Включить звуки", isOn: $state.soundEnabled) Toggle("Включить звуки", isOn: $state.soundEnabled)
@@ -617,10 +637,16 @@ struct SettingsView: View {
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url { if panel.runModal() == .OK, let url = panel.url {
state.chatWorkingDirectory = url.path state.chatWorkingDirectory = url.path
ClaudeCodeCLI.shared.newConversation() ChatTabs.shared.cli(for: state.activeTabId).newConversation()
} }
} }
} }
Picker("Ширина чата", selection: $state.chatWidth) {
ForEach(AppState.chatWidthOptions, id: \.self) { w in
Text(w == 640 ? "640 (как главный экран)" : "\(Int(w))").tag(w)
}
}
.pickerStyle(.segmented)
Picker("Модель", selection: $state.claudeCodeModel) { Picker("Модель", selection: $state.claudeCodeModel) {
ForEach(ClaudeCodeCLI.models, id: \.id) { m in ForEach(ClaudeCodeCLI.models, id: \.id) { m in
Text(m.label).tag(m.id) Text(m.label).tag(m.id)
+323
View File
@@ -0,0 +1,323 @@
import SwiftUI
import AppKit
import SwiftTerm
// MARK: - Tabs: brov-chat dialogs and term.macOS terminals
//
// "+" in the header opens a new tab of either kind (6 at most). Each brov-chat tab has its
// own history and its own `claude` conversation; each term.macOS tab is a real login
// shell in a pseudo-terminal (SwiftTerm) that keeps running while you look elsewhere.
// Tabs are split around the camera: the first three to its left, the rest to its right.
enum TabKind: String {
case chat, terminal
var baseTitle: String { self == .chat ? "brov-chat" : "term.macOS" }
var symbol: String { self == .chat ? "bubble.left.fill" : "terminal.fill" }
var closeQuestion: String { self == .chat ? "Закрыть диалог с BroV?" : "Закрыть терминал?" }
}
struct BroTab: Identifiable, Equatable {
let id = UUID()
let kind: TabKind
let number: Int
var title: String { "\(kind.baseTitle) \(number)" }
}
extension IslandView {
/// Views that use the resizable tall panel (chat and terminal).
var isTall: Bool { self == .prompt || self == .terminal }
}
// MARK: - Per-tab chat state
@MainActor
final class ChatTabs {
static let shared = ChatTabs()
private var clis: [UUID: ClaudeCodeCLI] = [:]
private var histories: [UUID: [ChatMessage]] = [:]
func cli(for id: UUID?) -> ClaudeCodeCLI {
guard let id else { return ClaudeCodeCLI.shared }
if let c = clis[id] { return c }
let c = ClaudeCodeCLI()
clis[id] = c
return c
}
func saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h }
func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
/// Updates a message of a tab, whether it's on screen or not.
func update(tab: UUID, message: UUID, _ change: (inout ChatMessage) -> Void) {
let state = AppState.shared
if state.activeTabId == tab {
if let i = state.chatHistory.firstIndex(where: { $0.id == message }) { change(&state.chatHistory[i]) }
} else if var h = histories[tab], let i = h.firstIndex(where: { $0.id == message }) {
change(&h[i]); histories[tab] = h
}
}
func append(tab: UUID, _ m: ChatMessage) {
let state = AppState.shared
if state.activeTabId == tab { state.chatHistory.append(m) } else { histories[tab, default: []].append(m) }
}
func remove(tab: UUID, message: UUID) {
let state = AppState.shared
if state.activeTabId == tab { state.chatHistory.removeAll { $0.id == message } }
else { histories[tab]?.removeAll { $0.id == message } }
}
func close(_ id: UUID) {
clis[id]?.cancel()
clis.removeValue(forKey: id)
histories.removeValue(forKey: id)
}
}
// MARK: - Terminals
@MainActor
final class TerminalStore: NSObject, LocalProcessTerminalViewDelegate {
static let shared = TerminalStore()
private var views: [UUID: LocalProcessTerminalView] = [:]
func view(for id: UUID) -> LocalProcessTerminalView {
if let v = views[id] { return v }
let v = LocalProcessTerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
v.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
v.nativeBackgroundColor = NSColor(calibratedRed: 0.055, green: 0.059, blue: 0.067, alpha: 1)
v.nativeForegroundColor = NSColor(calibratedWhite: 0.92, alpha: 1)
v.caretColor = NSColor(calibratedRed: 0.85, green: 0.47, blue: 0.34, alpha: 1)
v.processDelegate = self
var env = ProcessInfo.processInfo.environment
env.removeValue(forKey: "CLAUDECODE")
env.removeValue(forKey: "CLAUDE_CODE_ENTRYPOINT")
env["TERM"] = "xterm-256color"
env["COLORTERM"] = "truecolor"
if env["LANG"] == nil { env["LANG"] = "ru_RU.UTF-8" }
env["TERM_PROGRAM"] = "BroV"
let shell = env["SHELL"] ?? "/bin/zsh"
let home = FileManager.default.homeDirectoryForCurrentUser.path
v.startProcess(executable: shell, args: ["-l"],
environment: env.map { "\($0.key)=\($0.value)" },
execName: "-" + (shell as NSString).lastPathComponent,
currentDirectory: home)
views[id] = v
return v
}
func close(_ id: UUID) {
if let v = views.removeValue(forKey: id) {
v.processDelegate = nil
v.terminate()
v.removeFromSuperview()
}
}
private func tabId(of source: TerminalView) -> UUID? {
views.first(where: { $0.value === source })?.key
}
// LocalProcessTerminalViewDelegate
nonisolated func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
nonisolated func setTerminalTitle(source: LocalProcessTerminalView, title: String) {}
nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
nonisolated func processTerminated(source: TerminalView, exitCode: Int32?) {
// The shell exited (e.g. `exit`): close its tab.
DispatchQueue.main.async {
MainActor.assumeIsolated {
if let id = TerminalStore.shared.tabId(of: source) {
AppState.shared.closeTab(id)
}
}
}
}
}
/// Hosts the live terminal of a tab; switching tabs moves the right terminal view in.
struct TerminalTabView: NSViewRepresentable {
let tabId: UUID
func makeNSView(context: Context) -> NSView {
let container = NSView()
container.wantsLayer = true
return container
}
func updateNSView(_ container: NSView, context: Context) {
let term = TerminalStore.shared.view(for: tabId)
if term.superview !== container {
container.subviews.forEach { $0.removeFromSuperview() }
term.frame = container.bounds
term.autoresizingMask = [.width, .height]
container.addSubview(term)
}
DispatchQueue.main.async {
container.window?.makeFirstResponder(term)
}
}
}
struct TerminalScreen: View {
@ObservedObject var state: AppState
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(red: 0.055, green: 0.059, blue: 0.067))
// Only while on screen: a hidden terminal must not grab the keyboard from the chat.
if state.view == .terminal, state.mode == .expanded,
let id = state.activeTabId, state.tabs.first(where: { $0.id == id })?.kind == .terminal {
TerminalTabView(tabId: id)
.padding(.horizontal, 10)
.padding(.vertical, 8)
}
}
.padding(.top, 4)
.padding(.bottom, 12) // room for the resize handle
}
}
// MARK: - Header: tab chips and the "+" menu
/// One tab in the header. Hover 3 s → × appears; × asks before closing.
struct TabChip: View {
let tab: BroTab
@ObservedObject var state: AppState
@State private var hovered = false
@State private var showClose = false
@State private var confirm = false
@State private var hoverTask: Task<Void, Never>?
private var isActive: Bool {
state.activeTabId == tab.id && state.view.isTall && state.mode == .expanded
}
var body: some View {
HStack(spacing: 4) {
Image(systemName: tab.kind.symbol)
.font(.system(size: 10))
Text("\(tab.number)")
.font(.system(size: 10.5, weight: .semibold))
.monospacedDigit()
if showClose {
Button { confirm = true } label: {
Image(systemName: "xmark")
.font(.system(size: 7.5, weight: .bold))
.frame(width: 13, height: 13)
.background(Circle().fill(Color.white.opacity(0.14)))
}
.buttonStyle(.plain)
.transition(.scale.combined(with: .opacity))
}
}
.foregroundColor(isActive ? Color(hex: "#F5F6F8") : Color(hex: hovered ? "#B0B5BE" : "#8E939C"))
.padding(.horizontal, 8)
.frame(height: 24)
.background(Capsule().fill(Color.white.opacity(isActive ? 0.12 : (hovered ? 0.06 : 0))))
.overlay(Capsule().stroke(tab.kind == .terminal ? Color(hex: "#34D399").opacity(isActive ? 0.45 : 0.15)
: Color(hex: "#D97757").opacity(isActive ? 0.45 : 0.15),
lineWidth: 1))
.contentShape(Capsule())
.onTapGesture { state.activateTab(tab.id) }
.help(tab.title)
.onHover { h in
withAnimation(.easeOut(duration: 0.12)) { hovered = h }
hoverTask?.cancel()
if h {
hoverTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
withAnimation(.spring(response: 0.25, dampingFraction: 0.75)) { showClose = true }
}
} else if !confirm {
withAnimation(.easeOut(duration: 0.15)) { showClose = false }
}
}
.popover(isPresented: $confirm, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 10) {
Text(tab.kind.closeQuestion)
.font(.system(size: 13, weight: .semibold))
Text(tab.kind == .terminal
? "«\(tab.title)» и всё, что в нём запущено, будет остановлено."
: "История «\(tab.title)» пропадёт из BroV.")
.font(.system(size: 11.5))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Отмена") { confirm = false; showClose = false }
.keyboardShortcut(.cancelAction)
Button("Закрыть") {
confirm = false
state.closeTab(tab.id)
}
.keyboardShortcut(.defaultAction)
}
}
.padding(14)
.frame(width: 260)
}
}
}
/// "+" → brov-chat or term.macOS.
struct NewTabButton: View {
@ObservedObject var state: AppState
@State private var open = false
@State private var hovered = false
var body: some View {
Button { open.toggle() } label: {
Image(systemName: "plus")
.font(.system(size: 13))
.foregroundColor(Color(hex: hovered || open ? "#F5F6F8" : "#8E939C"))
.frame(width: 26, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
.popover(isPresented: $open, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
newTabRow(.chat, subtitle: "новый диалог с BroV (claude)")
newTabRow(.terminal, subtitle: "новое окно терминала macOS")
if state.tabs.count >= AppState.maxTabs {
Text("Открыто максимум вкладок (\(AppState.maxTabs)). Закрой одну: наведи на неё на 3 секунды.")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
.frame(width: 230, alignment: .leading)
.padding(.top, 4)
}
}
.padding(10)
}
}
private func newTabRow(_ kind: TabKind, subtitle: String) -> some View {
Button {
open = false
state.openTab(kind)
} label: {
HStack(spacing: 10) {
Image(systemName: kind.symbol)
.font(.system(size: 13))
.foregroundColor(kind == .terminal ? Color(hex: "#34D399") : Color(hex: "#D97757"))
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(kind.baseTitle).font(.system(size: 13, weight: .semibold, design: .monospaced))
Text(subtitle).font(.system(size: 11)).foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(width: 240)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(state.tabs.count >= AppState.maxTabs)
}
}
@@ -80,6 +80,21 @@ struct UploadCanvasView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.frame(width: 120, height: 26) .frame(width: 120, height: 26)
.position(x: 290 + 60, y: 113 + 13) // center = (350, 126) .position(x: 290 + 60, y: 113 + 13) // center = (350, 126)
// Tertiary: put on the shelf
Button {
withAnimation(.easeInOut(duration: 0.22)) { state.shelveLastDrop() }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
UploadSequenceEngine.shared.deactivate()
}
} label: {
Color.clear
.frame(width: 96, height: 26)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(width: 96, height: 26)
.position(x: 418 + 48, y: 113 + 13) // center = (466, 126)
} }
.opacity(f.chooseAlpha) .opacity(f.chooseAlpha)
.allowsHitTesting(f.chooseAlpha > 0.5) .allowsHitTesting(f.chooseAlpha > 0.5)
@@ -288,6 +303,14 @@ struct UploadCanvasView: View {
.font(.system(size:12.5, weight:.medium)) .font(.system(size:12.5, weight:.medium))
.foregroundColor(Color(hex:"#F1F2F4")) .foregroundColor(Color(hex:"#F1F2F4"))
cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center) cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center)
// Tertiary button: shelf
cCtx.fill(roundedRect(CGRect(x:418,y:113,width:96,height:26), r:13),
with: .color(Color.white.opacity(0.09)))
let btn3 = Text("На полку")
.font(.system(size:12.5, weight:.medium))
.foregroundColor(Color(hex:"#F1F2F4"))
cCtx.draw(btn3, at: CGPoint(x:466, y:126), anchor: .center)
} }
// MARK: - Mochi (superellipse body + eyes + mouth) // MARK: - Mochi (superellipse body + eyes + mouth)
@@ -302,6 +325,17 @@ struct UploadCanvasView: View {
c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt))) c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt)))
c.concatenate(CGAffineTransform(scaleX: CGFloat(f.sx), y: CGFloat(f.sy))) c.concatenate(CGAffineTransform(scaleX: CGFloat(f.sx), y: CGFloat(f.sy)))
// BroV: the Memoji while it's a character; the mailbox fades in as it morphs.
if mc < 0.5, let img = MemojiAssets.image(f.eye == .content ? .happy : (f.eye == .cup ? .surprised : .neutral)) {
var mc2 = c
mc2.opacity = 1 - mc * 2
let side = CGFloat(R) * 2.9
mc2.draw(Image(decorative: img, scale: 1),
in: CGRect(x: -side / 2, y: -side * 0.52, width: side, height: side))
}
guard mc > 0.02 else { return }
c.opacity = min(1, mc * 2)
let (bp, rx, ry) = usBodyPath(m: m, R: R) let (bp, rx, ry) = usBodyPath(m: m, R: R)
// ── Body gradient ────────────────────────────────────────── // ── Body gradient ──────────────────────────────────────────
@@ -12,6 +12,7 @@ enum IslandView: String, CaseIterable {
case overview, empty, approval, question, error, finished case overview, empty, approval, question, error, finished
case confused, upload, uploading, choose, mail, prompt case confused, upload, uploading, choose, mail, prompt
case searching, result, note, settings, greeting, wardrobe case searching, result, note, settings, greeting, wardrobe
case terminal // BroV term.macOS tab
} }
// MARK: - Bot State // MARK: - Bot State
@@ -38,6 +39,8 @@ struct ApprovalInfo: Sendable {
var inputKey: String var inputKey: String
/// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex". /// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex".
var pillId: String var pillId: String
/// Why this request looks destructive (DangerCheck), empty when nothing matched.
var danger: [String] = []
} }
// MARK: - Pill badge (shown on pill edge when non-focused task has an alert) // MARK: - Pill badge (shown on pill edge when non-focused task has an alert)
@@ -176,9 +179,9 @@ enum IslandConst {
static let viewLayouts: [IslandView: ViewLayout] = [ static let viewLayouts: [IslandView: ViewLayout] = [
// Home is the reference: height 150 // Home is the reference: height 150
.overview: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .pills), .overview: ViewLayout(height: 160, botX: 58, botY: nil, botDiameter: 58, agentMode: .pills),
// All non-chat views match home height (150) — law // All non-chat views match home height (150) — law
.empty: ViewLayout(height: 160, botX: 70, botY: nil, botDiameter: 62, agentMode: .none), .empty: ViewLayout(height: 160, botX: 58, botY: nil, botDiameter: 58, agentMode: .none),
.approval: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column), .approval: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column),
.question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column), .question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column),
.error: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), .error: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column),
@@ -196,6 +199,8 @@ enum IslandConst {
// Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed // Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed
.greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none), .greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none),
.wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .none), .wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .none),
// Terminal tab: full-width shell, the character is hidden.
.terminal: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .none),
] ]
// Project colors — keyed by lowercase display name or slug // Project colors — keyed by lowercase display name or slug
+11
View File
@@ -23,6 +23,12 @@ settings:
CODE_SIGNING_ALLOWED: YES CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: ""
# The only third-party dependency: a real terminal emulator for the term.macOS tabs.
packages:
SwiftTerm:
url: https://github.com/migueldeicaza/SwiftTerm
from: 1.20.0
schemes: schemes:
BroV: BroV:
build: build:
@@ -45,6 +51,11 @@ targets:
- path: Resources/sounds - path: Resources/sounds
buildPhase: resources buildPhase: resources
type: folder type: folder
- path: Resources/memoji
buildPhase: resources
type: folder
dependencies:
- package: SwiftTerm
info: info:
path: Resources/Info.plist path: Resources/Info.plist
properties: properties:
+14 -3
View File
@@ -5,12 +5,23 @@ set -e
cd "$(dirname "$0")/../NotchBuddy" cd "$(dirname "$0")/../NotchBuddy"
D="$HOME/Library/Developer/Xcode/DerivedData/BroV" D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
xcodegen -q xcodegen -q
xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" build | grep -E 'error:|BUILD (SUCCEEDED|FAILED)' || true LOG=$(mktemp)
xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" -skipPackagePluginValidation -skipMacroValidation build > "$LOG" 2>&1 || true
grep -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u
grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; }
APP="$D/Build/Products/${1:-Debug}/BroV.app" APP="$D/Build/Products/${1:-Debug}/BroV.app"
[ -d "$APP" ] || { echo "build failed"; exit 1; } # Stop the running copy. If launchd keeps it alive, unload the job first or it would
# restart the old binary mid-copy.
PLIST="$HOME/Library/LaunchAgents/local.maksar.brov.plist"
launchctl bootout "gui/$(id -u)/local.maksar.brov" 2>/dev/null || true
pkill -x BroV 2>/dev/null || true pkill -x BroV 2>/dev/null || true
sleep 0.5
mkdir -p "$HOME/Applications" mkdir -p "$HOME/Applications"
rm -rf "$HOME/Applications/BroV.app" rm -rf "$HOME/Applications/BroV.app"
cp -R "$APP" "$HOME/Applications/BroV.app" cp -R "$APP" "$HOME/Applications/BroV.app"
open "$HOME/Applications/BroV.app" if [ -f "$PLIST" ]; then
launchctl bootstrap "gui/$(id -u)" "$PLIST"
else
open "$HOME/Applications/BroV.app"
fi
echo "BroV installed to ~/Applications/BroV.app and launched" echo "BroV installed to ~/Applications/BroV.app and launched"