Tabs: '+' opens brov-chat or term.macOS (max 6, split around the camera); real zsh terminals via SwiftTerm; per-tab chat history and claude session; hover 3 s to close with confirmation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-07 15:27:02 +03:00
parent 0d40b78b26
commit 56367ef9cd
11 changed files with 452 additions and 35 deletions
+64 -2
View File
@@ -9,7 +9,26 @@ final class AppState: ObservableObject {
// Island state // Island state
@Published var mode: IslandMode = .hidden @Published var mode: IslandMode = .hidden
@Published var view: IslandView = .overview @Published var view: IslandView = .overview {
didSet {
// Any way into the chat (drop → "Задать вопрос", shortcuts…) lands in a brov-chat tab.
if view == .prompt, tabs.first(where: { $0.id == activeTabId })?.kind != .chat {
if let chat = tabs.first(where: { $0.kind == .chat }) {
if let cur = activeTabId, tabs.first(where: { $0.id == cur })?.kind == .chat {
ChatTabs.shared.saveHistory(chatHistory, for: cur)
}
activeTabId = chat.id
chatHistory = ChatTabs.shared.history(for: chat.id)
} else if tabs.count < Self.maxTabs {
let n = (1...Self.maxTabs).first { k in !tabs.contains { $0.kind == .chat && $0.number == k } } ?? 1
let t = BroTab(kind: .chat, number: n)
tabs.append(t)
activeTabId = t.id
chatHistory = []
}
}
}
}
// Tasks // Tasks
@Published var tasks: [AgentTask] = [] @Published var tasks: [AgentTask] = []
@@ -420,7 +439,7 @@ final class AppState: ObservableObject {
} }
func chatDetentHeight(_ d: Int) -> CGFloat { func chatDetentHeight(_ d: Int) -> CGFloat {
let auto = min(300, 240 + CGFloat(chatHistory.count) * 40) let auto = view == .terminal ? 380 : min(300, 240 + CGFloat(chatHistory.count) * 40)
switch d { switch d {
case 1: return max(320, screenHeight / 3) case 1: return max(320, screenHeight / 3)
case 2: return max(340, screenHeight / 2) case 2: return max(340, screenHeight / 2)
@@ -432,6 +451,48 @@ final class AppState: ObservableObject {
/// Current chat island height (drag, else detent). /// Current chat island height (drag, else detent).
var chatIslandHeight: CGFloat { chatDragHeight ?? chatDetentHeight(chatDetent) } 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) // MARK: Agent health (BroV)
@Published var crashedSessions: [CrashedSession] = [] @Published var crashedSessions: [CrashedSession] = []
@Published var claudeNeedsLogin = false @Published var claudeNeedsLogin = false
@@ -466,6 +527,7 @@ final class AppState: ObservableObject {
// MARK: - Init (loads persisted settings) // MARK: - Init (loads persisted settings)
private init() { private init() {
activeTabId = tabs.first?.id
let ud = UserDefaults.standard let ud = UserDefaults.standard
if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v } if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v }
+1 -1
View File
@@ -162,7 +162,7 @@ struct BotCanvasView: View {
let (islandW, islandH) = islandSize(mode: state.mode, view: state.view, let (islandW, islandH) = islandSize(mode: state.mode, view: state.view,
progress: state.uploadProgress, progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt) let actualH: CGFloat = (state.mode == .expanded && state.view.isTall)
? state.chatIslandHeight ? state.chatIslandHeight
: islandH : islandH
let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view, let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view,
+15 -6
View File
@@ -271,7 +271,10 @@ final class ClaudeService {
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key) // MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async { func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
let cli = ClaudeCodeCLI.shared // Each brov-chat tab has its own claude conversation and history.
let tab = state.activeTabId
let cli = ChatTabs.shared.cli(for: tab)
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
var prompt = query var prompt = query
// Context goes with the first message of a conversation, like the API chat. // Context goes with the first message of a conversation, like the API chat.
if cli.sessionID == nil, let context { if cli.sessionID == nil, let context {
@@ -292,7 +295,7 @@ final class ClaudeService {
let placeholder = ChatMessage(role: .assistant, content: "") let placeholder = ChatMessage(role: .assistant, content: "")
let msgId = placeholder.id let msgId = placeholder.id
state.chatHistory.append(placeholder) if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
state.stateOverride = .thinking state.stateOverride = .thinking
do { do {
@@ -306,17 +309,22 @@ final class ClaudeService {
if !visible.isEmpty, state.stateOverride == .thinking { if !visible.isEmpty, state.stateOverride == .thinking {
state.stateOverride = nil // hide typing dots on first visible text state.stateOverride = nil // hide typing dots on first visible text
} }
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { if let tab {
ChatTabs.shared.update(tab: tab, message: msgId) { $0.content = visible }
} else if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
state.chatHistory[idx].content = visible state.chatHistory[idx].content = visible
} }
} }
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { if let tab {
if trimmed.isEmpty { ChatTabs.shared.remove(tab: tab, message: msgId) }
else { ChatTabs.shared.update(tab: tab, message: msgId) { $0.content = trimmed } }
} else if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
if trimmed.isEmpty { state.chatHistory.remove(at: idx) } if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
else { state.chatHistory[idx].content = trimmed } else { state.chatHistory[idx].content = trimmed }
} }
state.stateOverride = nil state.stateOverride = nil
state.view = .prompt if isOnScreen() && state.view != .terminal { state.view = .prompt }
state.claudeNeedsLogin = false state.claudeNeedsLogin = false
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
} catch is CancellationError { } catch is CancellationError {
@@ -324,8 +332,9 @@ final class ClaudeService {
state.stateOverride = nil state.stateOverride = nil
} catch { } catch {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty } state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
if let tab { ChatTabs.shared.remove(tab: tab, message: msgId) }
state.stateOverride = nil state.stateOverride = nil
await showError(error.localizedDescription, state: state) if isOnScreen() { await showError(error.localizedDescription, state: state) }
} }
} }
+23 -19
View File
@@ -97,13 +97,13 @@ struct IslandContainer: View {
Rectangle().frame(width: islandWidth, Rectangle().frame(width: islandWidth,
height: state.mode == .expanded ? 320 : islandHeight) height: state.mode == .expanded ? 320 : islandHeight)
} }
.opacity(uploadActive || greetingActive ? 0 : 1) .opacity(uploadActive || greetingActive || (state.mode == .expanded && state.view == .terminal) ? 0 : 1)
.animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive) .animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive)
CountdownBar(state: state, islandW: islandWidth) CountdownBar(state: state, islandW: islandWidth)
// Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen). // Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen).
if state.mode == .expanded && [.prompt, .overview, .empty].contains(state.view) { if state.mode == .expanded && [.prompt, .terminal, .overview, .empty].contains(state.view) {
ChatResizeHandle(state: state) ChatResizeHandle(state: state)
.position(x: islandWidth / 2, y: islandHeight - 7) .position(x: islandWidth / 2, y: islandHeight - 7)
.transition(.opacity) .transition(.opacity)
@@ -130,7 +130,7 @@ struct IslandContainer: View {
let tr: CGFloat = 0 let tr: CGFloat = 0
withAnimation(anim) { withAnimation(anim) {
islandWidth = w islandWidth = w
islandHeight = (newMode == .expanded && state.view == .prompt) ? chatPromptHeight : h islandHeight = (newMode == .expanded && state.view.isTall) ? chatPromptHeight : h
cornerRadius = cr cornerRadius = cr
islandTopRadius = tr islandTopRadius = tr
} }
@@ -147,7 +147,7 @@ struct IslandContainer: View {
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
withAnimation(openSpring) { withAnimation(openSpring) {
islandWidth = w islandWidth = w
islandHeight = newView == .prompt ? chatPromptHeight : h islandHeight = newView.isTall ? chatPromptHeight : h
} }
} }
.onChange(of: state.chatHistory.count) { _, _ in .onChange(of: state.chatHistory.count) { _, _ in
@@ -155,15 +155,15 @@ struct IslandContainer: View {
withAnimation(openSpring) { islandHeight = chatPromptHeight } withAnimation(openSpring) { islandHeight = chatPromptHeight }
} }
.onChange(of: state.chatDragHeight) { _, h in .onChange(of: state.chatDragHeight) { _, h in
guard state.mode == .expanded, state.view == .prompt, let h else { return } guard state.mode == .expanded, state.view.isTall, let h else { return }
islandHeight = h // follow the pointer, no spring islandHeight = h // follow the pointer, no spring
} }
.onChange(of: state.chatDetent) { _, _ in .onChange(of: state.chatDetent) { _, _ in
guard state.mode == .expanded, state.view == .prompt else { return } guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight } withAnimation(openSpring) { islandHeight = chatPromptHeight }
} }
.onChange(of: state.chatWidth) { _, w in .onChange(of: state.chatWidth) { _, w in
guard state.mode == .expanded, state.view == .prompt else { return } guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandWidth = w } withAnimation(openSpring) { islandWidth = w }
} }
.onAppear { .onAppear {
@@ -171,7 +171,7 @@ struct IslandContainer: View {
progress: state.uploadProgress, progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight) nw: state.notchWidth, nh: state.notchHeight)
islandWidth = w islandWidth = w
islandHeight = state.view == .prompt ? chatPromptHeight : h islandHeight = state.view.isTall ? chatPromptHeight : h
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
islandTopRadius = 0 islandTopRadius = 0
} }
@@ -503,7 +503,7 @@ struct IslandContentView: View {
// Views that fill available height instead of the fixed 98pt content frame: // Views that fill available height instead of the fixed 98pt content frame:
// chat (prompt) is always flexible; mail is flexible only when active so // chat (prompt) is always flexible; mail is flexible only when active so
// it doesn't push the ZStack taller when inactive. // it doesn't push the ZStack taller when inactive.
let isTall = v == .prompt || (v == .mail && active) let isTall = v == .prompt || ((v == .mail || v == .terminal) && active)
let anim: Animation = active let anim: Animation = active
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16) ? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
: .easeIn(duration: 0.16) : .easeIn(duration: 0.16)
@@ -530,20 +530,18 @@ struct IslandContentView: View {
struct IslandHeader: View { struct IslandHeader: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
private var leftTabs: [BroTab] { Array(state.tabs.prefix(3)) }
private var rightTabs: [BroTab] { Array(state.tabs.dropFirst(3)) }
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Left: tab capsules // Left: tab capsules
// Tabs are split around the camera: three to its left, the rest to its right,
// with "+" right after the last tab.
HStack(spacing: 5) { HStack(spacing: 5) {
TabButton(icon: "house.fill", view: .overview, state: state) TabButton(icon: "house.fill", view: .overview, state: state)
TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: { ForEach(leftTabs) { TabChip(tab: $0, state: state) }
#if !APPSTORE if rightTabs.isEmpty { NewTabButton(state: state) }
if state.promptContext == nil {
state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp)
}
#endif
})
TabButton(icon: "plus", view: .upload, state: state)
} }
.padding(.leading, 14) .padding(.leading, 14)
@@ -551,12 +549,17 @@ struct IslandHeader: View {
// Right: plan pill (GitHub build, home view only) + action icons // Right: plan pill (GitHub build, home view only) + action icons
HStack(spacing: 8) { HStack(spacing: 8) {
#if !APPSTORE if !rightTabs.isEmpty {
HStack(spacing: 5) {
ForEach(rightTabs) { TabChip(tab: $0, state: state) }
NewTabButton(state: state)
}
}
// Plan limits live inside the Claude / ChatGPT pills (AppGridView): // Plan limits live inside the Claude / ChatGPT pills (AppGridView):
// here they ran under the camera. // here they ran under the camera.
#endif
HStack(spacing: 14) { HStack(spacing: 14) {
// Fold everything back into the notch (and the chat to its normal size). // Fold everything back into the notch (and the chat to its normal size).
if state.view.isTall {
Button(action: { Button(action: {
state.chatDragHeight = nil state.chatDragHeight = nil
state.chatDetent = 0 state.chatDetent = 0
@@ -570,6 +573,7 @@ struct IslandHeader: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Свернуть в чёлку") .help("Свернуть в чёлку")
}
Button(action: { Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
@@ -27,6 +27,7 @@ struct IslandViewContent: View {
case .settings: SettingsIslandView(state: state) case .settings: SettingsIslandView(state: state)
case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView
case .wardrobe: WardrobeView(state: state) case .wardrobe: WardrobeView(state: state)
case .terminal: TerminalScreen(state: state)
} }
} }
} }
@@ -1260,7 +1261,7 @@ struct PromptView: View {
text = "" text = ""
state.chatHistory = [] state.chatHistory = []
ClaudeService.shared.clearConversation() ClaudeService.shared.clearConversation()
ClaudeCodeCLI.shared.newConversation() ChatTabs.shared.cli(for: state.activeTabId).newConversation()
focused = true focused = true
} }
} }
@@ -206,7 +206,12 @@ final class IslandWindowController: NSWindowController {
self?.fsm.greetComplete() self?.fsm.greetComplete()
} }
fsm.isHeldOpen = { AppState.shared.pendingApproval != nil } // Held open: a pending approval, or a chat/terminal tab on screen (typing with
// the pointer elsewhere must not fold it away; ⌃ in the header closes it).
fsm.isHeldOpen = {
let s = AppState.shared
return s.pendingApproval != nil || (s.mode == .expanded && s.view.isTall)
}
} }
// MARK: - 60 Hz polling loop // MARK: - 60 Hz polling loop
@@ -498,6 +503,8 @@ final class IslandWindowController: NSWindowController {
if cmd, let n = digitCodes[event.keyCode] { if cmd, let n = digitCodes[event.keyCode] {
switchToPill(number: n); return true switchToPill(number: n); return true
} }
// ⎋ Escape — belongs to the shell in a term.macOS tab (vim, claude, less…).
if event.keyCode == 53 && state.view == .terminal { return false }
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse // ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
if event.keyCode == 53 && raw.isEmpty { if event.keyCode == 53 && raw.isEmpty {
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil) let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
@@ -1021,7 +1028,7 @@ final class IslandWindowController: NSWindowController {
progress: s.uploadProgress, nw: notchW, nh: notchH) progress: s.uploadProgress, nw: notchW, nh: notchH)
// Chat view resizes dynamically — AppState.chatIslandHeight // Chat view resizes dynamically — AppState.chatIslandHeight
let islandH: CGFloat let islandH: CGFloat
if s.mode == .expanded && s.view == .prompt { if s.mode == .expanded && s.view.isTall {
islandH = s.chatIslandHeight islandH = s.chatIslandHeight
} else { } else {
islandH = fixedH islandH = fixedH
@@ -1084,7 +1091,7 @@ final class IslandPanel: NSPanel {
let (w, fixedH) = islandSize(mode: s.mode, view: s.view, let (w, fixedH) = islandSize(mode: s.mode, view: s.view,
progress: s.uploadProgress, nw: nw, nh: nh) progress: s.uploadProgress, nw: nw, nh: nh)
let h: CGFloat let h: CGFloat
if s.mode == .expanded && s.view == .prompt { if s.mode == .expanded && s.view.isTall {
h = s.chatIslandHeight h = s.chatIslandHeight
} else { } else {
h = fixedH h = fixedH
@@ -1151,7 +1158,7 @@ func islandSize(mode: IslandMode, view: IslandView,
case .expanded: case .expanded:
let layout = IslandConst.viewLayouts[view]! let layout = IslandConst.viewLayouts[view]!
// BroV: the chat has its own width (Settings → Чат). // BroV: the chat has its own width (Settings → Чат).
if view == .prompt { return (AppState.shared.chatWidth, layout.height) } if view.isTall { return (AppState.shared.chatWidth, layout.height) }
return (IslandConst.expandedWidth, layout.height) return (IslandConst.expandedWidth, layout.height)
} }
} }
+1 -1
View File
@@ -637,7 +637,7 @@ struct SettingsView: View {
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url { if panel.runModal() == .OK, let url = panel.url {
state.chatWorkingDirectory = url.path state.chatWorkingDirectory = url.path
ClaudeCodeCLI.shared.newConversation() ChatTabs.shared.cli(for: state.activeTabId).newConversation()
} }
} }
} }
+323
View File
@@ -0,0 +1,323 @@
import SwiftUI
import AppKit
import SwiftTerm
// MARK: - Tabs: brov-chat dialogs and term.macOS terminals
//
// "+" in the header opens a new tab of either kind (6 at most). Each brov-chat tab has its
// own history and its own `claude` conversation; each term.macOS tab is a real login
// shell in a pseudo-terminal (SwiftTerm) that keeps running while you look elsewhere.
// Tabs are split around the camera: the first three to its left, the rest to its right.
enum TabKind: String {
case chat, terminal
var baseTitle: String { self == .chat ? "brov-chat" : "term.macOS" }
var symbol: String { self == .chat ? "bubble.left.fill" : "terminal.fill" }
var closeQuestion: String { self == .chat ? "Закрыть диалог с BroV?" : "Закрыть терминал?" }
}
struct BroTab: Identifiable, Equatable {
let id = UUID()
let kind: TabKind
let number: Int
var title: String { "\(kind.baseTitle) \(number)" }
}
extension IslandView {
/// Views that use the resizable tall panel (chat and terminal).
var isTall: Bool { self == .prompt || self == .terminal }
}
// MARK: - Per-tab chat state
@MainActor
final class ChatTabs {
static let shared = ChatTabs()
private var clis: [UUID: ClaudeCodeCLI] = [:]
private var histories: [UUID: [ChatMessage]] = [:]
func cli(for id: UUID?) -> ClaudeCodeCLI {
guard let id else { return ClaudeCodeCLI.shared }
if let c = clis[id] { return c }
let c = ClaudeCodeCLI()
clis[id] = c
return c
}
func saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h }
func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
/// Updates a message of a tab, whether it's on screen or not.
func update(tab: UUID, message: UUID, _ change: (inout ChatMessage) -> Void) {
let state = AppState.shared
if state.activeTabId == tab {
if let i = state.chatHistory.firstIndex(where: { $0.id == message }) { change(&state.chatHistory[i]) }
} else if var h = histories[tab], let i = h.firstIndex(where: { $0.id == message }) {
change(&h[i]); histories[tab] = h
}
}
func append(tab: UUID, _ m: ChatMessage) {
let state = AppState.shared
if state.activeTabId == tab { state.chatHistory.append(m) } else { histories[tab, default: []].append(m) }
}
func remove(tab: UUID, message: UUID) {
let state = AppState.shared
if state.activeTabId == tab { state.chatHistory.removeAll { $0.id == message } }
else { histories[tab]?.removeAll { $0.id == message } }
}
func close(_ id: UUID) {
clis[id]?.cancel()
clis.removeValue(forKey: id)
histories.removeValue(forKey: id)
}
}
// MARK: - Terminals
@MainActor
final class TerminalStore: NSObject, LocalProcessTerminalViewDelegate {
static let shared = TerminalStore()
private var views: [UUID: LocalProcessTerminalView] = [:]
func view(for id: UUID) -> LocalProcessTerminalView {
if let v = views[id] { return v }
let v = LocalProcessTerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
v.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
v.nativeBackgroundColor = NSColor(calibratedRed: 0.055, green: 0.059, blue: 0.067, alpha: 1)
v.nativeForegroundColor = NSColor(calibratedWhite: 0.92, alpha: 1)
v.caretColor = NSColor(calibratedRed: 0.85, green: 0.47, blue: 0.34, alpha: 1)
v.processDelegate = self
var env = ProcessInfo.processInfo.environment
env.removeValue(forKey: "CLAUDECODE")
env.removeValue(forKey: "CLAUDE_CODE_ENTRYPOINT")
env["TERM"] = "xterm-256color"
env["COLORTERM"] = "truecolor"
if env["LANG"] == nil { env["LANG"] = "ru_RU.UTF-8" }
env["TERM_PROGRAM"] = "BroV"
let shell = env["SHELL"] ?? "/bin/zsh"
let home = FileManager.default.homeDirectoryForCurrentUser.path
v.startProcess(executable: shell, args: ["-l"],
environment: env.map { "\($0.key)=\($0.value)" },
execName: "-" + (shell as NSString).lastPathComponent,
currentDirectory: home)
views[id] = v
return v
}
func close(_ id: UUID) {
if let v = views.removeValue(forKey: id) {
v.processDelegate = nil
v.terminate()
v.removeFromSuperview()
}
}
private func tabId(of source: TerminalView) -> UUID? {
views.first(where: { $0.value === source })?.key
}
// LocalProcessTerminalViewDelegate
nonisolated func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
nonisolated func setTerminalTitle(source: LocalProcessTerminalView, title: String) {}
nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
nonisolated func processTerminated(source: TerminalView, exitCode: Int32?) {
// The shell exited (e.g. `exit`): close its tab.
DispatchQueue.main.async {
MainActor.assumeIsolated {
if let id = TerminalStore.shared.tabId(of: source) {
AppState.shared.closeTab(id)
}
}
}
}
}
/// Hosts the live terminal of a tab; switching tabs moves the right terminal view in.
struct TerminalTabView: NSViewRepresentable {
let tabId: UUID
func makeNSView(context: Context) -> NSView {
let container = NSView()
container.wantsLayer = true
return container
}
func updateNSView(_ container: NSView, context: Context) {
let term = TerminalStore.shared.view(for: tabId)
if term.superview !== container {
container.subviews.forEach { $0.removeFromSuperview() }
term.frame = container.bounds
term.autoresizingMask = [.width, .height]
container.addSubview(term)
}
DispatchQueue.main.async {
container.window?.makeFirstResponder(term)
}
}
}
struct TerminalScreen: View {
@ObservedObject var state: AppState
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(red: 0.055, green: 0.059, blue: 0.067))
// Only while on screen: a hidden terminal must not grab the keyboard from the chat.
if state.view == .terminal, state.mode == .expanded,
let id = state.activeTabId, state.tabs.first(where: { $0.id == id })?.kind == .terminal {
TerminalTabView(tabId: id)
.padding(.horizontal, 10)
.padding(.vertical, 8)
}
}
.padding(.top, 4)
.padding(.bottom, 12) // room for the resize handle
}
}
// MARK: - Header: tab chips and the "+" menu
/// One tab in the header. Hover 3 s → × appears; × asks before closing.
struct TabChip: View {
let tab: BroTab
@ObservedObject var state: AppState
@State private var hovered = false
@State private var showClose = false
@State private var confirm = false
@State private var hoverTask: Task<Void, Never>?
private var isActive: Bool {
state.activeTabId == tab.id && state.view.isTall && state.mode == .expanded
}
var body: some View {
HStack(spacing: 4) {
Image(systemName: tab.kind.symbol)
.font(.system(size: 10))
Text("\(tab.number)")
.font(.system(size: 10.5, weight: .semibold))
.monospacedDigit()
if showClose {
Button { confirm = true } label: {
Image(systemName: "xmark")
.font(.system(size: 7.5, weight: .bold))
.frame(width: 13, height: 13)
.background(Circle().fill(Color.white.opacity(0.14)))
}
.buttonStyle(.plain)
.transition(.scale.combined(with: .opacity))
}
}
.foregroundColor(isActive ? Color(hex: "#F5F6F8") : Color(hex: hovered ? "#B0B5BE" : "#8E939C"))
.padding(.horizontal, 8)
.frame(height: 24)
.background(Capsule().fill(Color.white.opacity(isActive ? 0.12 : (hovered ? 0.06 : 0))))
.overlay(Capsule().stroke(tab.kind == .terminal ? Color(hex: "#34D399").opacity(isActive ? 0.45 : 0.15)
: Color(hex: "#D97757").opacity(isActive ? 0.45 : 0.15),
lineWidth: 1))
.contentShape(Capsule())
.onTapGesture { state.activateTab(tab.id) }
.help(tab.title)
.onHover { h in
withAnimation(.easeOut(duration: 0.12)) { hovered = h }
hoverTask?.cancel()
if h {
hoverTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
withAnimation(.spring(response: 0.25, dampingFraction: 0.75)) { showClose = true }
}
} else if !confirm {
withAnimation(.easeOut(duration: 0.15)) { showClose = false }
}
}
.popover(isPresented: $confirm, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 10) {
Text(tab.kind.closeQuestion)
.font(.system(size: 13, weight: .semibold))
Text(tab.kind == .terminal
? "«\(tab.title)» и всё, что в нём запущено, будет остановлено."
: "История «\(tab.title)» пропадёт из BroV.")
.font(.system(size: 11.5))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Отмена") { confirm = false; showClose = false }
.keyboardShortcut(.cancelAction)
Button("Закрыть") {
confirm = false
state.closeTab(tab.id)
}
.keyboardShortcut(.defaultAction)
}
}
.padding(14)
.frame(width: 260)
}
}
}
/// "+" → brov-chat or term.macOS.
struct NewTabButton: View {
@ObservedObject var state: AppState
@State private var open = false
@State private var hovered = false
var body: some View {
Button { open.toggle() } label: {
Image(systemName: "plus")
.font(.system(size: 13))
.foregroundColor(Color(hex: hovered || open ? "#F5F6F8" : "#8E939C"))
.frame(width: 26, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovered = $0 }
.popover(isPresented: $open, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
newTabRow(.chat, subtitle: "новый диалог с BroV (claude)")
newTabRow(.terminal, subtitle: "новое окно терминала macOS")
if state.tabs.count >= AppState.maxTabs {
Text("Открыто максимум вкладок (\(AppState.maxTabs)). Закрой одну: наведи на неё на 3 секунды.")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
.frame(width: 230, alignment: .leading)
.padding(.top, 4)
}
}
.padding(10)
}
}
private func newTabRow(_ kind: TabKind, subtitle: String) -> some View {
Button {
open = false
state.openTab(kind)
} label: {
HStack(spacing: 10) {
Image(systemName: kind.symbol)
.font(.system(size: 13))
.foregroundColor(kind == .terminal ? Color(hex: "#34D399") : Color(hex: "#D97757"))
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(kind.baseTitle).font(.system(size: 13, weight: .semibold, design: .monospaced))
Text(subtitle).font(.system(size: 11)).foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(width: 240)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(state.tabs.count >= AppState.maxTabs)
}
}
@@ -12,6 +12,7 @@ enum IslandView: String, CaseIterable {
case overview, empty, approval, question, error, finished case overview, empty, approval, question, error, finished
case confused, upload, uploading, choose, mail, prompt case confused, upload, uploading, choose, mail, prompt
case searching, result, note, settings, greeting, wardrobe case searching, result, note, settings, greeting, wardrobe
case terminal // BroV term.macOS tab
} }
// MARK: - Bot State // MARK: - Bot State
@@ -198,6 +199,8 @@ enum IslandConst {
// Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed // Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed
.greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none), .greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none),
.wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .none), .wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .none),
// Terminal tab: full-width shell, the character is hidden.
.terminal: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .none),
] ]
// Project colors — keyed by lowercase display name or slug // Project colors — keyed by lowercase display name or slug
+8
View File
@@ -23,6 +23,12 @@ settings:
CODE_SIGNING_ALLOWED: YES CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: ""
# The only third-party dependency: a real terminal emulator for the term.macOS tabs.
packages:
SwiftTerm:
url: https://github.com/migueldeicaza/SwiftTerm
from: 1.20.0
schemes: schemes:
BroV: BroV:
build: build:
@@ -48,6 +54,8 @@ targets:
- path: Resources/memoji - path: Resources/memoji
buildPhase: resources buildPhase: resources
type: folder type: folder
dependencies:
- package: SwiftTerm
info: info:
path: Resources/Info.plist path: Resources/Info.plist
properties: properties:
+1 -1
View File
@@ -6,7 +6,7 @@ cd "$(dirname "$0")/../NotchBuddy"
D="$HOME/Library/Developer/Xcode/DerivedData/BroV" D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
xcodegen -q xcodegen -q
LOG=$(mktemp) LOG=$(mktemp)
xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" build > "$LOG" 2>&1 || true 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 -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u
grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; } grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; }
APP="$D/Build/Products/${1:-Debug}/BroV.app" APP="$D/Build/Products/${1:-Debug}/BroV.app"