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? 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) } }