Compare commits
10 Commits
00dd73e33c
...
d8a64ab5b5
| Author | SHA1 | Date | |
|---|---|---|---|
| d8a64ab5b5 | |||
| a92bf02477 | |||
| 56367ef9cd | |||
| 0d40b78b26 | |||
| 37c01f991a | |||
| 3857776111 | |||
| ae394370bf | |||
| a4d5032959 | |||
| fbf90fd4fb | |||
| 8a8dd98e06 |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -16,7 +16,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Warm up Keychain cache on main thread BEFORE any poller or view touches it
|
||||
_ = KeychainStore.shared
|
||||
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()
|
||||
CodexUsageMonitor.shared.start()
|
||||
AgentWatch.shared.start()
|
||||
AgentAuth.shared.start()
|
||||
setupIsland()
|
||||
#if PHONE_LINK
|
||||
CloudProbe.shared.startIfEnabled()
|
||||
|
||||
@@ -3,8 +3,8 @@ import AppKit
|
||||
|
||||
// MARK: - BroV home: two floors of flat app pills
|
||||
//
|
||||
// Top floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch).
|
||||
// Bottom floor: two empty slots, to be filled later. The bot keeps its spot on the left.
|
||||
// Top floor: two empty slots, to be filled later.
|
||||
// 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 {
|
||||
let id: String
|
||||
@@ -28,20 +28,72 @@ enum DesktopApps {
|
||||
struct AppGridView: View {
|
||||
@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 {
|
||||
ZStack {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
AppPill(app: DesktopApps.claude)
|
||||
AppPill(app: DesktopApps.chatGPT)
|
||||
ShelfPill()
|
||||
EmptyPill()
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
EmptyPill()
|
||||
EmptyPill()
|
||||
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
|
||||
AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit, alert: alert(for: .codex))
|
||||
}
|
||||
}
|
||||
.padding(.leading, 108)
|
||||
.padding(.trailing, 14)
|
||||
.padding(.leading, 104)
|
||||
.padding(.trailing, 20)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
#if !APPSTORE
|
||||
@@ -62,8 +114,22 @@ struct AppGridView: View {
|
||||
|
||||
// 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 {
|
||||
let app: DesktopAppSlot
|
||||
var limit: PillLimit? = nil
|
||||
var alert: PillAlert? = nil
|
||||
@State private var appURL: URL?
|
||||
@State private var isRunning = false
|
||||
@State private var isHovered = false
|
||||
@@ -74,28 +140,44 @@ struct AppPill: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: open) {
|
||||
HStack(spacing: 10) {
|
||||
icon
|
||||
.frame(width: 26, height: 26)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(app.name)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.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)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.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)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
@@ -107,8 +189,7 @@ struct AppPill: View {
|
||||
.stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onTapGesture(perform: open)
|
||||
.scaleEffect(isHovered ? 1.02 : 1)
|
||||
.onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } }
|
||||
.onAppear(perform: refresh)
|
||||
@@ -116,6 +197,27 @@ struct AppPill: View {
|
||||
.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 {
|
||||
if let appURL {
|
||||
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
|
||||
|
||||
@@ -9,7 +9,26 @@ final class AppState: ObservableObject {
|
||||
|
||||
// Island state
|
||||
@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
|
||||
@Published var tasks: [AgentTask] = []
|
||||
@@ -243,6 +262,16 @@ final class AppState: ObservableObject {
|
||||
|
||||
// Dropped file (set during upload flow)
|
||||
@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)
|
||||
@Published var noteMessage: String? = nil
|
||||
@@ -395,6 +424,82 @@ final class AppState: ObservableObject {
|
||||
#endif
|
||||
|
||||
// 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 {
|
||||
didSet {
|
||||
if let u = claudePlanUsage,
|
||||
@@ -422,6 +527,7 @@ final class AppState: ObservableObject {
|
||||
// MARK: - Init (loads persisted settings)
|
||||
|
||||
private init() {
|
||||
activeTabId = tabs.first?.id
|
||||
let ud = UserDefaults.standard
|
||||
|
||||
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 }
|
||||
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.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: "claudeBinaryPath") { claudeBinaryPath = v }
|
||||
if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v }
|
||||
|
||||
@@ -11,6 +11,7 @@ struct BotCanvasView: View {
|
||||
|
||||
// One engine per view instance (main bot)
|
||||
@StateObject private var engine = BotEngine()
|
||||
@State private var fader = MemojiFader()
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation(paused: state.mode == .hidden)) { timeline in
|
||||
@@ -69,26 +70,15 @@ struct BotCanvasView: View {
|
||||
engine.update(dt: dt)
|
||||
var ctx = context
|
||||
engine.applyDance(&ctx, size: size)
|
||||
// Rigid-roll: when Mochi wears an outfit (presence > 0.05) and is rolling,
|
||||
// rotate the entire body+accessories context around the body center so the
|
||||
// whole character genuinely turns. Particles/badge (drawHandsAndExtras) are
|
||||
// drawn outside the rotated context and do not spin.
|
||||
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 {
|
||||
let center = engine.bodyCenter(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)
|
||||
// BroV: the Memoji is the character. The engine's box (file upload morph)
|
||||
// fades in only while morphing; hands and outfits are not drawn.
|
||||
if engine.morph > 0.02 {
|
||||
var boxCtx = ctx
|
||||
boxCtx.opacity = Double(min(1, engine.morph * 2))
|
||||
engine.draw(context: boxCtx, size: size)
|
||||
}
|
||||
engine.drawMemoji(context: ctx, size: size, fader: fader,
|
||||
opacity: Double(max(0, 1 - engine.morph * 2)))
|
||||
engine.drawHandsAndExtras(context: ctx, size: size)
|
||||
}
|
||||
}
|
||||
@@ -172,8 +162,8 @@ struct BotCanvasView: View {
|
||||
let (islandW, islandH) = islandSize(mode: state.mode, view: state.view,
|
||||
progress: state.uploadProgress,
|
||||
nw: state.notchWidth, nh: state.notchHeight)
|
||||
let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt)
|
||||
? min(300, 240 + CGFloat(state.chatHistory.count) * 40)
|
||||
let actualH: CGFloat = (state.mode == .expanded && state.view.isTall)
|
||||
? state.chatIslandHeight
|
||||
: islandH
|
||||
let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view,
|
||||
islandW: islandW, islandH: actualH,
|
||||
|
||||
@@ -149,6 +149,7 @@ final class ClaudeCodeCLI {
|
||||
|
||||
if let error = parser.errorMessage {
|
||||
if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") {
|
||||
AgentAuth.shared.reportAuthError(.claude)
|
||||
throw ClaudeCodeCLIError.failed("Claude Code не авторизован. Выполните `claude /login` в терминале.")
|
||||
}
|
||||
// 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).
|
||||
static func color(for pct: Double?) -> String {
|
||||
guard let p = pct else { return "#6B7079" }
|
||||
|
||||
@@ -271,7 +271,10 @@ final class ClaudeService {
|
||||
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
|
||||
|
||||
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
|
||||
// Context goes with the first message of a conversation, like the API chat.
|
||||
if cli.sessionID == nil, let context {
|
||||
@@ -292,7 +295,7 @@ final class ClaudeService {
|
||||
|
||||
let placeholder = ChatMessage(role: .assistant, content: "")
|
||||
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
|
||||
|
||||
do {
|
||||
@@ -306,25 +309,32 @@ final class ClaudeService {
|
||||
if !visible.isEmpty, state.stateOverride == .thinking {
|
||||
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
|
||||
}
|
||||
}
|
||||
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) }
|
||||
else { state.chatHistory[idx].content = trimmed }
|
||||
}
|
||||
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)
|
||||
} catch is CancellationError {
|
||||
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
|
||||
state.stateOverride = nil
|
||||
} catch {
|
||||
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
|
||||
if let tab { ChatTabs.shared.remove(tab: tab, message: msgId) }
|
||||
state.stateOverride = nil
|
||||
await showError(error.localizedDescription, state: state)
|
||||
if isOnScreen() { await showError(error.localizedDescription, state: state) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ struct DesktopBotView: View {
|
||||
@ObservedObject var appState: AppState
|
||||
/// Engine owned by DesktopMochiController; controller calls methods on it directly.
|
||||
let engine: BotEngine
|
||||
@State private var fader = MemojiFader()
|
||||
@ObservedObject var viewState: DesktopBotViewState
|
||||
|
||||
var body: some View {
|
||||
@@ -59,23 +60,15 @@ struct DesktopBotView: View {
|
||||
var c = ctx
|
||||
engine.applyDance(&c, size: size)
|
||||
|
||||
// Rigid roll when outfit is present (matches BotCanvasView)
|
||||
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 {
|
||||
let center = engine.bodyCenter(size: size)
|
||||
var rigidCtx = c
|
||||
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: c, size: size)
|
||||
engine.drawOutfitBehind(context: c, size: size)
|
||||
engine.draw(context: c, size: size)
|
||||
engine.drawOutfitFront(context: c, size: size)
|
||||
// BroV: the Memoji is the character. The engine's box (file upload morph)
|
||||
// fades in only while morphing; hands and outfits are not drawn.
|
||||
if engine.morph > 0.02 {
|
||||
var boxCtx = c
|
||||
boxCtx.opacity = Double(min(1, engine.morph * 2))
|
||||
engine.draw(context: boxCtx, size: size)
|
||||
}
|
||||
engine.drawMemoji(context: c, size: size, fader: fader,
|
||||
opacity: Double(max(0, 1 - engine.morph * 2)))
|
||||
engine.drawHandsAndExtras(context: c, size: size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ enum FileDropHandler {
|
||||
static func handle(urls: [URL], state: AppState) async {
|
||||
guard let url = urls.first else { return }
|
||||
let name = url.lastPathComponent
|
||||
state.lastDroppedURLs = urls
|
||||
|
||||
// Start animation immediately — do NOT block on file copy.
|
||||
// Use original URL first; swap to inbox copy once background copy finishes.
|
||||
|
||||
@@ -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.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy))
|
||||
|
||||
drawHandL(ctx, hw: hw, hh: hh, p: p)
|
||||
drawHandR(ctx, hw: hw, hh: hh, p: p)
|
||||
|
||||
let mpath = mochiPath(hw: hw, hh: hh)
|
||||
whiteFill(ctx, mpath, x0: hw*0.6, y0: -hh, x1: -hw*0.6, y1: hh)
|
||||
|
||||
if p.tint > 0 {
|
||||
let cs = CGColorSpaceCreateDeviceRGB()
|
||||
let c0 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: CGFloat(p.tint))
|
||||
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()
|
||||
// BroV: the Memoji replaces the drawn body, eyes and hands.
|
||||
let face: MemojiExpression = {
|
||||
if p.eye == .happy || p.eye == .content { return .happy }
|
||||
if p.open < 0.35 { return .blink }
|
||||
if p.open < 0.8 { return .halfblink }
|
||||
return .neutral
|
||||
}()
|
||||
MemojiCG.draw(face, in: ctx, center: CGPoint(x: CGFloat(p.lookX) * hw * 0.12, y: 0),
|
||||
side: hh * 2 * 1.45)
|
||||
|
||||
// Activity badge
|
||||
if p.badge > 0.01 {
|
||||
|
||||
@@ -414,6 +414,20 @@ final class HookServer: @unchecked Sendable {
|
||||
// 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 {
|
||||
|
||||
case "SessionStart":
|
||||
@@ -672,7 +686,8 @@ final class HookServer: @unchecked Sendable {
|
||||
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 {
|
||||
// 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)
|
||||
state.updateTask(id: pillId, state: .approval)
|
||||
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
|
||||
SoundEngine.shared.play("approval")
|
||||
|
||||
@@ -2048,6 +2065,30 @@ def main():
|
||||
if 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
|
||||
env = os.environ
|
||||
payload.setdefault('term_program', env.get('TERM_PROGRAM', ''))
|
||||
|
||||
@@ -32,11 +32,7 @@ struct IslandContainer: View {
|
||||
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 var chatPromptHeight: CGFloat {
|
||||
let base: CGFloat = 240
|
||||
let perMsg: CGFloat = 40
|
||||
return min(300, base + CGFloat(state.chatHistory.count) * perMsg)
|
||||
}
|
||||
private var chatPromptHeight: CGFloat { state.chatIslandHeight }
|
||||
|
||||
/// 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.
|
||||
@@ -101,11 +97,18 @@ struct IslandContainer: View {
|
||||
Rectangle().frame(width: islandWidth,
|
||||
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)
|
||||
|
||||
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 {
|
||||
if state.mode == .compact {
|
||||
CompactMiniGrid(state: state)
|
||||
@@ -127,7 +130,7 @@ struct IslandContainer: View {
|
||||
let tr: CGFloat = 0
|
||||
withAnimation(anim) {
|
||||
islandWidth = w
|
||||
islandHeight = (newMode == .expanded && state.view == .prompt) ? chatPromptHeight : h
|
||||
islandHeight = (newMode == .expanded && state.view.isTall) ? chatPromptHeight : h
|
||||
cornerRadius = cr
|
||||
islandTopRadius = tr
|
||||
}
|
||||
@@ -144,19 +147,31 @@ struct IslandContainer: View {
|
||||
nw: state.notchWidth, nh: state.notchHeight)
|
||||
withAnimation(openSpring) {
|
||||
islandWidth = w
|
||||
islandHeight = newView == .prompt ? chatPromptHeight : h
|
||||
islandHeight = newView.isTall ? chatPromptHeight : h
|
||||
}
|
||||
}
|
||||
.onChange(of: state.chatHistory.count) { _, _ in
|
||||
guard state.mode == .expanded, state.view == .prompt else { return }
|
||||
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 {
|
||||
let (w, h) = islandSize(mode: state.mode, view: state.view,
|
||||
progress: state.uploadProgress,
|
||||
nw: state.notchWidth, nh: state.notchHeight)
|
||||
islandWidth = w
|
||||
islandHeight = state.view == .prompt ? chatPromptHeight : h
|
||||
islandHeight = state.view.isTall ? chatPromptHeight : h
|
||||
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
|
||||
islandTopRadius = 0
|
||||
}
|
||||
@@ -366,6 +381,11 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH:
|
||||
let cy: CGFloat
|
||||
if let fixedY = layout.botY {
|
||||
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 {
|
||||
// Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=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
|
||||
|
||||
struct CountdownBar: View {
|
||||
@@ -437,7 +503,7 @@ struct IslandContentView: View {
|
||||
// Views that fill available height instead of the fixed 98pt content frame:
|
||||
// chat (prompt) is always flexible; mail is flexible only when active so
|
||||
// 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
|
||||
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
|
||||
: .easeIn(duration: 0.16)
|
||||
@@ -464,20 +530,18 @@ struct IslandContentView: View {
|
||||
|
||||
struct IslandHeader: View {
|
||||
@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 {
|
||||
HStack(spacing: 0) {
|
||||
// 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) {
|
||||
TabButton(icon: "house.fill", view: .overview, state: state)
|
||||
TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: {
|
||||
#if !APPSTORE
|
||||
if state.promptContext == nil {
|
||||
state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp)
|
||||
}
|
||||
#endif
|
||||
})
|
||||
TabButton(icon: "plus", view: .upload, state: state)
|
||||
ForEach(leftTabs) { TabChip(tab: $0, state: state) }
|
||||
if rightTabs.isEmpty { NewTabButton(state: state) }
|
||||
}
|
||||
.padding(.leading, 14)
|
||||
|
||||
@@ -485,12 +549,30 @@ struct IslandHeader: View {
|
||||
|
||||
// Right: plan pill (GitHub build, home view only) + action icons
|
||||
HStack(spacing: 8) {
|
||||
#if !APPSTORE
|
||||
if state.view == .overview && state.showPlanInNotch && state.planRelayInstalled {
|
||||
ClaudePlanHeaderPill(state: state)
|
||||
if !rightTabs.isEmpty {
|
||||
HStack(spacing: 5) {
|
||||
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) {
|
||||
// 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: {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
state.view = .settings
|
||||
@@ -564,8 +646,11 @@ struct ClaudePlanHeaderPill: View {
|
||||
private var label: String {
|
||||
guard let usage = state.claudePlanUsage,
|
||||
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()))% (до сброса \(PlanTime.untilReset(w.resetsAt)))"
|
||||
}
|
||||
|
||||
private var isActive: Bool { state.showingPlanDetail || isHovered }
|
||||
|
||||
@@ -608,6 +693,30 @@ struct ClaudePlanHeaderPill: View {
|
||||
}
|
||||
#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)
|
||||
|
||||
struct CompactMiniGrid: View {
|
||||
|
||||
@@ -27,6 +27,7 @@ struct IslandViewContent: View {
|
||||
case .settings: SettingsIslandView(state: state)
|
||||
case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView
|
||||
case .wardrobe: WardrobeView(state: state)
|
||||
case .terminal: TerminalScreen(state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,11 +299,25 @@ struct ApprovalView: View {
|
||||
var approval: ApprovalInfo? { state.pendingApproval }
|
||||
|
||||
var body: some View {
|
||||
let danger = approval?.danger ?? []
|
||||
ZStack {
|
||||
CardBackground(wash: .amber)
|
||||
CardBackground(wash: danger.isEmpty ? .amber : .red)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
AgentWho(task: state.focusTask, label: "просит разрешение")
|
||||
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) {
|
||||
SecondaryButton("Запретить") {
|
||||
HookServer.shared.sendApprovalDecision("deny")
|
||||
@@ -310,8 +325,9 @@ struct ApprovalView: View {
|
||||
PrimaryButton("Разрешить") {
|
||||
HookServer.shared.sendApprovalDecision("allow")
|
||||
}
|
||||
// Codex rejects updatedPermissions, so "Always" is not offered
|
||||
if approval?.pillId != "agent_codex" {
|
||||
// Codex rejects updatedPermissions, so "Always" is not offered.
|
||||
// Never for a dangerous command: one click must not save a destructive rule.
|
||||
if approval?.pillId != "agent_codex" && danger.isEmpty {
|
||||
SecondaryButton("Всегда") {
|
||||
HookServer.shared.sendApprovalDecision("always")
|
||||
}
|
||||
@@ -922,6 +938,7 @@ struct ChooseView: View {
|
||||
HStack(spacing: 8) {
|
||||
PrimaryButton("Задать вопрос") { state.view = .prompt }
|
||||
SecondaryButton("Отправить почтой") { state.view = .mail }
|
||||
SecondaryButton("На полку") { state.shelveLastDrop() }
|
||||
}
|
||||
}
|
||||
.padding(.leading, 98)
|
||||
@@ -1244,7 +1261,7 @@ struct PromptView: View {
|
||||
text = ""
|
||||
state.chatHistory = []
|
||||
ClaudeService.shared.clearConversation()
|
||||
ClaudeCodeCLI.shared.newConversation()
|
||||
ChatTabs.shared.cli(for: state.activeTabId).newConversation()
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,11 @@ final class IslandWindowController: NSWindowController {
|
||||
let nW = geometry.width
|
||||
let nH = geometry.height
|
||||
|
||||
let panelW: CGFloat = 720
|
||||
let panelH: CGFloat = 320
|
||||
// Tall, transparent panel: the chat can grow down to the full screen height
|
||||
// and widen; clicks outside the island still pass through (pollFrame).
|
||||
let sf = screen.frame
|
||||
let panelW: CGFloat = 1100
|
||||
let panelH: CGFloat = sf.height
|
||||
let panel = IslandPanel(
|
||||
contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH,
|
||||
width: panelW, height: panelH),
|
||||
@@ -204,7 +206,12 @@ final class IslandWindowController: NSWindowController {
|
||||
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
|
||||
@@ -232,10 +239,12 @@ final class IslandWindowController: NSWindowController {
|
||||
// in the app window immediately below the menu bar.
|
||||
let hoverRect = !hasNotch && state.mode != .expanded
|
||||
? 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
|
||||
let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil
|
||||
|| state.chatDragHeight != nil
|
||||
if panel.ignoresMouseEvents == shouldAcceptMouse {
|
||||
panel.ignoresMouseEvents = !shouldAcceptMouse
|
||||
if shouldAcceptMouse, let cv = panel.contentView {
|
||||
@@ -360,8 +369,14 @@ final class IslandWindowController: NSWindowController {
|
||||
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 }
|
||||
}
|
||||
state.isPinned = false
|
||||
finishedPinTimer?.cancel()
|
||||
// Keep the FSM in step with what is on screen (home/coucou → petit now).
|
||||
@@ -382,7 +397,7 @@ final class IslandWindowController: NSWindowController {
|
||||
switch action {
|
||||
case .toggleIsland:
|
||||
if state.mode == .expanded {
|
||||
collapse()
|
||||
collapse(byUser: true)
|
||||
} else {
|
||||
islandPanel.makeKey()
|
||||
expand(to: defaultView())
|
||||
@@ -494,6 +509,8 @@ final class IslandWindowController: NSWindowController {
|
||||
if cmd, let n = digitCodes[event.keyCode] {
|
||||
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
|
||||
if event.keyCode == 53 && raw.isEmpty {
|
||||
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.)
|
||||
NotificationCenter.default.addObserver(forName: .islandCollapse, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.collapse()
|
||||
NotificationCenter.default.addObserver(forName: .islandCollapse, object: nil, queue: .main) { [weak self] note in
|
||||
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)
|
||||
@@ -1015,12 +1033,10 @@ final class IslandWindowController: NSWindowController {
|
||||
let panelW = window?.frame.width ?? 720
|
||||
let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view,
|
||||
progress: s.uploadProgress, nw: notchW, nh: notchH)
|
||||
// Chat view resizes dynamically — must match IslandContainer.chatPromptHeight
|
||||
// Chat view resizes dynamically — AppState.chatIslandHeight
|
||||
let islandH: CGFloat
|
||||
if s.mode == .expanded && s.view == .prompt {
|
||||
let base: CGFloat = 240
|
||||
let perMsg: CGFloat = 40
|
||||
islandH = min(300, base + CGFloat(s.chatHistory.count) * perMsg)
|
||||
if s.mode == .expanded && s.view.isTall {
|
||||
islandH = s.chatIslandHeight
|
||||
} else {
|
||||
islandH = fixedH
|
||||
}
|
||||
@@ -1082,10 +1098,8 @@ final class IslandPanel: NSPanel {
|
||||
let (w, fixedH) = islandSize(mode: s.mode, view: s.view,
|
||||
progress: s.uploadProgress, nw: nw, nh: nh)
|
||||
let h: CGFloat
|
||||
if s.mode == .expanded && s.view == .prompt {
|
||||
let base: CGFloat = 240
|
||||
let perMsg: CGFloat = 40
|
||||
h = min(300, base + CGFloat(s.chatHistory.count) * perMsg)
|
||||
if s.mode == .expanded && s.view.isTall {
|
||||
h = s.chatIslandHeight
|
||||
} else {
|
||||
h = fixedH
|
||||
}
|
||||
@@ -1140,6 +1154,7 @@ extension Notification.Name {
|
||||
|
||||
// MARK: - islandSize (takes real notch dimensions)
|
||||
|
||||
@MainActor
|
||||
func islandSize(mode: IslandMode, view: IslandView,
|
||||
progress: Double = 0,
|
||||
nw: CGFloat = IslandConst.notchWidth,
|
||||
@@ -1149,6 +1164,8 @@ func islandSize(mode: IslandMode, view: IslandView,
|
||||
case .compact: return (nw + 160, nh)
|
||||
case .expanded:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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: "'\\''") + "'"
|
||||
}
|
||||
@@ -228,6 +228,26 @@ struct SettingsView: View {
|
||||
// MARK: - General section
|
||||
|
||||
@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("Звук") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Toggle("Включить звуки", isOn: $state.soundEnabled)
|
||||
@@ -617,10 +637,16 @@ struct SettingsView: View {
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
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) {
|
||||
ForEach(ClaudeCodeCLI.models, id: \.id) { m in
|
||||
Text(m.label).tag(m.id)
|
||||
|
||||
@@ -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)
|
||||
.frame(width: 120, height: 26)
|
||||
.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)
|
||||
.allowsHitTesting(f.chooseAlpha > 0.5)
|
||||
@@ -288,6 +303,14 @@ struct UploadCanvasView: View {
|
||||
.font(.system(size:12.5, weight:.medium))
|
||||
.foregroundColor(Color(hex:"#F1F2F4"))
|
||||
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)
|
||||
@@ -302,6 +325,17 @@ struct UploadCanvasView: View {
|
||||
c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt)))
|
||||
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)
|
||||
|
||||
// ── Body gradient ──────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@ enum IslandView: String, CaseIterable {
|
||||
case overview, empty, approval, question, error, finished
|
||||
case confused, upload, uploading, choose, mail, prompt
|
||||
case searching, result, note, settings, greeting, wardrobe
|
||||
case terminal // BroV term.macOS tab
|
||||
}
|
||||
|
||||
// MARK: - Bot State
|
||||
@@ -38,6 +39,8 @@ struct ApprovalInfo: Sendable {
|
||||
var inputKey: String
|
||||
/// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex".
|
||||
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)
|
||||
@@ -176,9 +179,9 @@ enum IslandConst {
|
||||
|
||||
static let viewLayouts: [IslandView: ViewLayout] = [
|
||||
// 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
|
||||
.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),
|
||||
.question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, 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: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, 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
|
||||
|
||||
@@ -23,6 +23,12 @@ settings:
|
||||
CODE_SIGNING_ALLOWED: YES
|
||||
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:
|
||||
BroV:
|
||||
build:
|
||||
@@ -45,6 +51,11 @@ targets:
|
||||
- path: Resources/sounds
|
||||
buildPhase: resources
|
||||
type: folder
|
||||
- path: Resources/memoji
|
||||
buildPhase: resources
|
||||
type: folder
|
||||
dependencies:
|
||||
- package: SwiftTerm
|
||||
info:
|
||||
path: Resources/Info.plist
|
||||
properties:
|
||||
|
||||
@@ -5,12 +5,23 @@ set -e
|
||||
cd "$(dirname "$0")/../NotchBuddy"
|
||||
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
|
||||
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"
|
||||
[ -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
|
||||
sleep 0.5
|
||||
mkdir -p "$HOME/Applications"
|
||||
rm -rf "$HOME/Applications/BroV.app"
|
||||
cp -R "$APP" "$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"
|
||||
|
||||