From 56367ef9cd8e6d6aa45a9ce7858f685046136b92 Mon Sep 17 00:00:00 2001 From: maksarsanjeev Date: Wed, 7 Oct 2026 15:27:02 +0300 Subject: [PATCH] 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 --- NotchBuddy/Sources/App/AppState.swift | 66 +++- NotchBuddy/Sources/App/BotCanvasView.swift | 2 +- NotchBuddy/Sources/App/ClaudeService.swift | 21 +- NotchBuddy/Sources/App/IslandRootView.swift | 42 +-- .../Sources/App/IslandViewContent.swift | 3 +- .../Sources/App/IslandWindowController.swift | 15 +- NotchBuddy/Sources/App/SettingsView.swift | 2 +- NotchBuddy/Sources/App/Tabs.swift | 323 ++++++++++++++++++ .../Sources/CoucouKit/IslandTypes.swift | 3 + NotchBuddy/project.yml | 8 + scripts/build.sh | 2 +- 11 files changed, 452 insertions(+), 35 deletions(-) create mode 100644 NotchBuddy/Sources/App/Tabs.swift diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 714b899..c657fab 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -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] = [] @@ -420,7 +439,7 @@ final class AppState: ObservableObject { } 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 { case 1: return max(320, screenHeight / 3) case 2: return max(340, screenHeight / 2) @@ -432,6 +451,48 @@ final class AppState: ObservableObject { /// 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 @@ -466,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 } diff --git a/NotchBuddy/Sources/App/BotCanvasView.swift b/NotchBuddy/Sources/App/BotCanvasView.swift index 7f16936..9421fad 100644 --- a/NotchBuddy/Sources/App/BotCanvasView.swift +++ b/NotchBuddy/Sources/App/BotCanvasView.swift @@ -162,7 +162,7 @@ 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) + let actualH: CGFloat = (state.mode == .expanded && state.view.isTall) ? state.chatIslandHeight : islandH let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view, diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 7833d5d..578577d 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -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,17 +309,22 @@ 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 { @@ -324,8 +332,9 @@ final class ClaudeService { 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) } } } diff --git a/NotchBuddy/Sources/App/IslandRootView.swift b/NotchBuddy/Sources/App/IslandRootView.swift index 2d3cb32..9601c73 100644 --- a/NotchBuddy/Sources/App/IslandRootView.swift +++ b/NotchBuddy/Sources/App/IslandRootView.swift @@ -97,13 +97,13 @@ 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, .overview, .empty].contains(state.view) { + if state.mode == .expanded && [.prompt, .terminal, .overview, .empty].contains(state.view) { ChatResizeHandle(state: state) .position(x: islandWidth / 2, y: islandHeight - 7) .transition(.opacity) @@ -130,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 } @@ -147,7 +147,7 @@ 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 @@ -155,15 +155,15 @@ struct IslandContainer: View { withAnimation(openSpring) { islandHeight = chatPromptHeight } } .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 } .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 } } .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 } } .onAppear { @@ -171,7 +171,7 @@ struct IslandContainer: 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 } @@ -503,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) @@ -530,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) @@ -551,12 +549,17 @@ struct IslandHeader: View { // Right: plan pill (GitHub build, home view only) + action icons 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): // here they ran under the camera. - #endif HStack(spacing: 14) { // Fold everything back into the notch (and the chat to its normal size). + if state.view.isTall { Button(action: { state.chatDragHeight = nil state.chatDetent = 0 @@ -570,6 +573,7 @@ struct IslandHeader: View { } .buttonStyle(.plain) .help("Свернуть в чёлку") + } Button(action: { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 5d9bde1..eb0869b 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -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) } } } @@ -1260,7 +1261,7 @@ struct PromptView: View { text = "" state.chatHistory = [] ClaudeService.shared.clearConversation() - ClaudeCodeCLI.shared.newConversation() + ChatTabs.shared.cli(for: state.activeTabId).newConversation() focused = true } } diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index ac62f6c..077f992 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -206,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 @@ -498,6 +503,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) @@ -1021,7 +1028,7 @@ final class IslandWindowController: NSWindowController { progress: s.uploadProgress, nw: notchW, nh: notchH) // Chat view resizes dynamically — AppState.chatIslandHeight let islandH: CGFloat - if s.mode == .expanded && s.view == .prompt { + if s.mode == .expanded && s.view.isTall { islandH = s.chatIslandHeight } else { islandH = fixedH @@ -1084,7 +1091,7 @@ 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 { + if s.mode == .expanded && s.view.isTall { h = s.chatIslandHeight } else { h = fixedH @@ -1151,7 +1158,7 @@ func islandSize(mode: IslandMode, view: IslandView, case .expanded: let layout = IslandConst.viewLayouts[view]! // 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) } } diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index c5b0364..a8063e9 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -637,7 +637,7 @@ 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() } } } diff --git a/NotchBuddy/Sources/App/Tabs.swift b/NotchBuddy/Sources/App/Tabs.swift new file mode 100644 index 0000000..c78dee5 --- /dev/null +++ b/NotchBuddy/Sources/App/Tabs.swift @@ -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? + + 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) + } +} diff --git a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift index 50dc400..7c0dc20 100644 --- a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift +++ b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift @@ -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 @@ -198,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 diff --git a/NotchBuddy/project.yml b/NotchBuddy/project.yml index e7c4744..41bb6a1 100644 --- a/NotchBuddy/project.yml +++ b/NotchBuddy/project.yml @@ -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: @@ -48,6 +54,8 @@ targets: - path: Resources/memoji buildPhase: resources type: folder + dependencies: + - package: SwiftTerm info: path: Resources/Info.plist properties: diff --git a/scripts/build.sh b/scripts/build.sh index ce72d26..7be0e3d 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -6,7 +6,7 @@ cd "$(dirname "$0")/../NotchBuddy" D="$HOME/Library/Developer/Xcode/DerivedData/BroV" xcodegen -q 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 -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; } APP="$D/Build/Products/${1:-Debug}/BroV.app"