Files
brov-macbook/NotchBuddy/Sources/App/Tabs.swift
T

324 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}