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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user