9570dc190f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
13 KiB
Swift
334 lines
13 KiB
Swift
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
|
||
}
|
||
|
||
private var seeds: [UUID: String] = [:]
|
||
|
||
/// Context prepended to the first message sent from a tab (e.g. the picked colour).
|
||
func seed(_ id: UUID, _ text: String) { seeds[id] = text }
|
||
func takeSeed(_ id: UUID?) -> String? {
|
||
guard let id else { return nil }
|
||
return seeds.removeValue(forKey: id)
|
||
}
|
||
|
||
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)
|
||
seeds.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)
|
||
}
|
||
}
|