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