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:
@@ -9,7 +9,26 @@ final class AppState: ObservableObject {
|
|||||||
|
|
||||||
// Island state
|
// Island state
|
||||||
@Published var mode: IslandMode = .hidden
|
@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
|
// Tasks
|
||||||
@Published var tasks: [AgentTask] = []
|
@Published var tasks: [AgentTask] = []
|
||||||
@@ -420,7 +439,7 @@ final class AppState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func chatDetentHeight(_ d: Int) -> CGFloat {
|
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 {
|
switch d {
|
||||||
case 1: return max(320, screenHeight / 3)
|
case 1: return max(320, screenHeight / 3)
|
||||||
case 2: return max(340, screenHeight / 2)
|
case 2: return max(340, screenHeight / 2)
|
||||||
@@ -432,6 +451,48 @@ final class AppState: ObservableObject {
|
|||||||
/// Current chat island height (drag, else detent).
|
/// Current chat island height (drag, else detent).
|
||||||
var chatIslandHeight: CGFloat { chatDragHeight ?? chatDetentHeight(chatDetent) }
|
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)
|
// MARK: Agent health (BroV)
|
||||||
@Published var crashedSessions: [CrashedSession] = []
|
@Published var crashedSessions: [CrashedSession] = []
|
||||||
@Published var claudeNeedsLogin = false
|
@Published var claudeNeedsLogin = false
|
||||||
@@ -466,6 +527,7 @@ final class AppState: ObservableObject {
|
|||||||
// MARK: - Init (loads persisted settings)
|
// MARK: - Init (loads persisted settings)
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
activeTabId = tabs.first?.id
|
||||||
let ud = UserDefaults.standard
|
let ud = UserDefaults.standard
|
||||||
|
|
||||||
if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v }
|
if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v }
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ struct BotCanvasView: View {
|
|||||||
let (islandW, islandH) = islandSize(mode: state.mode, view: state.view,
|
let (islandW, islandH) = islandSize(mode: state.mode, view: state.view,
|
||||||
progress: state.uploadProgress,
|
progress: state.uploadProgress,
|
||||||
nw: state.notchWidth, nh: state.notchHeight)
|
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
|
? state.chatIslandHeight
|
||||||
: islandH
|
: islandH
|
||||||
let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view,
|
let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view,
|
||||||
|
|||||||
@@ -271,7 +271,10 @@ final class ClaudeService {
|
|||||||
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
|
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
|
||||||
|
|
||||||
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
|
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
|
var prompt = query
|
||||||
// Context goes with the first message of a conversation, like the API chat.
|
// Context goes with the first message of a conversation, like the API chat.
|
||||||
if cli.sessionID == nil, let context {
|
if cli.sessionID == nil, let context {
|
||||||
@@ -292,7 +295,7 @@ final class ClaudeService {
|
|||||||
|
|
||||||
let placeholder = ChatMessage(role: .assistant, content: "")
|
let placeholder = ChatMessage(role: .assistant, content: "")
|
||||||
let msgId = placeholder.id
|
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
|
state.stateOverride = .thinking
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -306,17 +309,22 @@ final class ClaudeService {
|
|||||||
if !visible.isEmpty, state.stateOverride == .thinking {
|
if !visible.isEmpty, state.stateOverride == .thinking {
|
||||||
state.stateOverride = nil // hide typing dots on first visible text
|
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
|
state.chatHistory[idx].content = visible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
|
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) }
|
if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
|
||||||
else { state.chatHistory[idx].content = trimmed }
|
else { state.chatHistory[idx].content = trimmed }
|
||||||
}
|
}
|
||||||
state.stateOverride = nil
|
state.stateOverride = nil
|
||||||
state.view = .prompt
|
if isOnScreen() && state.view != .terminal { state.view = .prompt }
|
||||||
state.claudeNeedsLogin = false
|
state.claudeNeedsLogin = false
|
||||||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
@@ -324,8 +332,9 @@ final class ClaudeService {
|
|||||||
state.stateOverride = nil
|
state.stateOverride = nil
|
||||||
} catch {
|
} catch {
|
||||||
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
|
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
|
||||||
|
if let tab { ChatTabs.shared.remove(tab: tab, message: msgId) }
|
||||||
state.stateOverride = nil
|
state.stateOverride = nil
|
||||||
await showError(error.localizedDescription, state: state)
|
if isOnScreen() { await showError(error.localizedDescription, state: state) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ struct IslandContainer: View {
|
|||||||
Rectangle().frame(width: islandWidth,
|
Rectangle().frame(width: islandWidth,
|
||||||
height: state.mode == .expanded ? 320 : islandHeight)
|
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)
|
.animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive)
|
||||||
|
|
||||||
CountdownBar(state: state, islandW: islandWidth)
|
CountdownBar(state: state, islandW: islandWidth)
|
||||||
|
|
||||||
// Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen).
|
// 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)
|
ChatResizeHandle(state: state)
|
||||||
.position(x: islandWidth / 2, y: islandHeight - 7)
|
.position(x: islandWidth / 2, y: islandHeight - 7)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
@@ -130,7 +130,7 @@ struct IslandContainer: View {
|
|||||||
let tr: CGFloat = 0
|
let tr: CGFloat = 0
|
||||||
withAnimation(anim) {
|
withAnimation(anim) {
|
||||||
islandWidth = w
|
islandWidth = w
|
||||||
islandHeight = (newMode == .expanded && state.view == .prompt) ? chatPromptHeight : h
|
islandHeight = (newMode == .expanded && state.view.isTall) ? chatPromptHeight : h
|
||||||
cornerRadius = cr
|
cornerRadius = cr
|
||||||
islandTopRadius = tr
|
islandTopRadius = tr
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ struct IslandContainer: View {
|
|||||||
nw: state.notchWidth, nh: state.notchHeight)
|
nw: state.notchWidth, nh: state.notchHeight)
|
||||||
withAnimation(openSpring) {
|
withAnimation(openSpring) {
|
||||||
islandWidth = w
|
islandWidth = w
|
||||||
islandHeight = newView == .prompt ? chatPromptHeight : h
|
islandHeight = newView.isTall ? chatPromptHeight : h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: state.chatHistory.count) { _, _ in
|
.onChange(of: state.chatHistory.count) { _, _ in
|
||||||
@@ -155,15 +155,15 @@ struct IslandContainer: View {
|
|||||||
withAnimation(openSpring) { islandHeight = chatPromptHeight }
|
withAnimation(openSpring) { islandHeight = chatPromptHeight }
|
||||||
}
|
}
|
||||||
.onChange(of: state.chatDragHeight) { _, h in
|
.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
|
islandHeight = h // follow the pointer, no spring
|
||||||
}
|
}
|
||||||
.onChange(of: state.chatDetent) { _, _ in
|
.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 }
|
withAnimation(openSpring) { islandHeight = chatPromptHeight }
|
||||||
}
|
}
|
||||||
.onChange(of: state.chatWidth) { _, w in
|
.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 }
|
withAnimation(openSpring) { islandWidth = w }
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -171,7 +171,7 @@ struct IslandContainer: View {
|
|||||||
progress: state.uploadProgress,
|
progress: state.uploadProgress,
|
||||||
nw: state.notchWidth, nh: state.notchHeight)
|
nw: state.notchWidth, nh: state.notchHeight)
|
||||||
islandWidth = w
|
islandWidth = w
|
||||||
islandHeight = state.view == .prompt ? chatPromptHeight : h
|
islandHeight = state.view.isTall ? chatPromptHeight : h
|
||||||
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
|
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
|
||||||
islandTopRadius = 0
|
islandTopRadius = 0
|
||||||
}
|
}
|
||||||
@@ -503,7 +503,7 @@ struct IslandContentView: View {
|
|||||||
// Views that fill available height instead of the fixed 98pt content frame:
|
// Views that fill available height instead of the fixed 98pt content frame:
|
||||||
// chat (prompt) is always flexible; mail is flexible only when active so
|
// chat (prompt) is always flexible; mail is flexible only when active so
|
||||||
// it doesn't push the ZStack taller when inactive.
|
// 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
|
let anim: Animation = active
|
||||||
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
|
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
|
||||||
: .easeIn(duration: 0.16)
|
: .easeIn(duration: 0.16)
|
||||||
@@ -530,20 +530,18 @@ struct IslandContentView: View {
|
|||||||
|
|
||||||
struct IslandHeader: View {
|
struct IslandHeader: View {
|
||||||
@ObservedObject var state: AppState
|
@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 {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Left: tab capsules
|
// 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) {
|
HStack(spacing: 5) {
|
||||||
TabButton(icon: "house.fill", view: .overview, state: state)
|
TabButton(icon: "house.fill", view: .overview, state: state)
|
||||||
TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: {
|
ForEach(leftTabs) { TabChip(tab: $0, state: state) }
|
||||||
#if !APPSTORE
|
if rightTabs.isEmpty { NewTabButton(state: state) }
|
||||||
if state.promptContext == nil {
|
|
||||||
state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
})
|
|
||||||
TabButton(icon: "plus", view: .upload, state: state)
|
|
||||||
}
|
}
|
||||||
.padding(.leading, 14)
|
.padding(.leading, 14)
|
||||||
|
|
||||||
@@ -551,12 +549,17 @@ struct IslandHeader: View {
|
|||||||
|
|
||||||
// Right: plan pill (GitHub build, home view only) + action icons
|
// Right: plan pill (GitHub build, home view only) + action icons
|
||||||
HStack(spacing: 8) {
|
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):
|
// Plan limits live inside the Claude / ChatGPT pills (AppGridView):
|
||||||
// here they ran under the camera.
|
// here they ran under the camera.
|
||||||
#endif
|
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
// Fold everything back into the notch (and the chat to its normal size).
|
// Fold everything back into the notch (and the chat to its normal size).
|
||||||
|
if state.view.isTall {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
state.chatDragHeight = nil
|
state.chatDragHeight = nil
|
||||||
state.chatDetent = 0
|
state.chatDetent = 0
|
||||||
@@ -570,6 +573,7 @@ struct IslandHeader: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Свернуть в чёлку")
|
.help("Свернуть в чёлку")
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct IslandViewContent: View {
|
|||||||
case .settings: SettingsIslandView(state: state)
|
case .settings: SettingsIslandView(state: state)
|
||||||
case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView
|
case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView
|
||||||
case .wardrobe: WardrobeView(state: state)
|
case .wardrobe: WardrobeView(state: state)
|
||||||
|
case .terminal: TerminalScreen(state: state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1260,7 +1261,7 @@ struct PromptView: View {
|
|||||||
text = ""
|
text = ""
|
||||||
state.chatHistory = []
|
state.chatHistory = []
|
||||||
ClaudeService.shared.clearConversation()
|
ClaudeService.shared.clearConversation()
|
||||||
ClaudeCodeCLI.shared.newConversation()
|
ChatTabs.shared.cli(for: state.activeTabId).newConversation()
|
||||||
focused = true
|
focused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,12 @@ final class IslandWindowController: NSWindowController {
|
|||||||
self?.fsm.greetComplete()
|
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
|
// MARK: - 60 Hz polling loop
|
||||||
@@ -498,6 +503,8 @@ final class IslandWindowController: NSWindowController {
|
|||||||
if cmd, let n = digitCodes[event.keyCode] {
|
if cmd, let n = digitCodes[event.keyCode] {
|
||||||
switchToPill(number: n); return true
|
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
|
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
|
||||||
if event.keyCode == 53 && raw.isEmpty {
|
if event.keyCode == 53 && raw.isEmpty {
|
||||||
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
|
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)
|
progress: s.uploadProgress, nw: notchW, nh: notchH)
|
||||||
// Chat view resizes dynamically — AppState.chatIslandHeight
|
// Chat view resizes dynamically — AppState.chatIslandHeight
|
||||||
let islandH: CGFloat
|
let islandH: CGFloat
|
||||||
if s.mode == .expanded && s.view == .prompt {
|
if s.mode == .expanded && s.view.isTall {
|
||||||
islandH = s.chatIslandHeight
|
islandH = s.chatIslandHeight
|
||||||
} else {
|
} else {
|
||||||
islandH = fixedH
|
islandH = fixedH
|
||||||
@@ -1084,7 +1091,7 @@ final class IslandPanel: NSPanel {
|
|||||||
let (w, fixedH) = islandSize(mode: s.mode, view: s.view,
|
let (w, fixedH) = islandSize(mode: s.mode, view: s.view,
|
||||||
progress: s.uploadProgress, nw: nw, nh: nh)
|
progress: s.uploadProgress, nw: nw, nh: nh)
|
||||||
let h: CGFloat
|
let h: CGFloat
|
||||||
if s.mode == .expanded && s.view == .prompt {
|
if s.mode == .expanded && s.view.isTall {
|
||||||
h = s.chatIslandHeight
|
h = s.chatIslandHeight
|
||||||
} else {
|
} else {
|
||||||
h = fixedH
|
h = fixedH
|
||||||
@@ -1151,7 +1158,7 @@ func islandSize(mode: IslandMode, view: IslandView,
|
|||||||
case .expanded:
|
case .expanded:
|
||||||
let layout = IslandConst.viewLayouts[view]!
|
let layout = IslandConst.viewLayouts[view]!
|
||||||
// BroV: the chat has its own width (Settings → Чат).
|
// 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)
|
return (IslandConst.expandedWidth, layout.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ struct SettingsView: View {
|
|||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = false
|
||||||
if panel.runModal() == .OK, let url = panel.url {
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
state.chatWorkingDirectory = url.path
|
state.chatWorkingDirectory = url.path
|
||||||
ClaudeCodeCLI.shared.newConversation()
|
ChatTabs.shared.cli(for: state.activeTabId).newConversation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ enum IslandView: String, CaseIterable {
|
|||||||
case overview, empty, approval, question, error, finished
|
case overview, empty, approval, question, error, finished
|
||||||
case confused, upload, uploading, choose, mail, prompt
|
case confused, upload, uploading, choose, mail, prompt
|
||||||
case searching, result, note, settings, greeting, wardrobe
|
case searching, result, note, settings, greeting, wardrobe
|
||||||
|
case terminal // BroV term.macOS tab
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bot State
|
// MARK: - Bot State
|
||||||
@@ -198,6 +199,8 @@ enum IslandConst {
|
|||||||
// Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed
|
// Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed
|
||||||
.greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none),
|
.greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none),
|
||||||
.wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, 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
|
// Project colors — keyed by lowercase display name or slug
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ settings:
|
|||||||
CODE_SIGNING_ALLOWED: YES
|
CODE_SIGNING_ALLOWED: YES
|
||||||
DEVELOPMENT_TEAM: ""
|
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:
|
schemes:
|
||||||
BroV:
|
BroV:
|
||||||
build:
|
build:
|
||||||
@@ -48,6 +54,8 @@ targets:
|
|||||||
- path: Resources/memoji
|
- path: Resources/memoji
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
type: folder
|
type: folder
|
||||||
|
dependencies:
|
||||||
|
- package: SwiftTerm
|
||||||
info:
|
info:
|
||||||
path: Resources/Info.plist
|
path: Resources/Info.plist
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ cd "$(dirname "$0")/../NotchBuddy"
|
|||||||
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
|
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
|
||||||
xcodegen -q
|
xcodegen -q
|
||||||
LOG=$(mktemp)
|
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 -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u
|
||||||
grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; }
|
grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; }
|
||||||
APP="$D/Build/Products/${1:-Debug}/BroV.app"
|
APP="$D/Build/Products/${1:-Debug}/BroV.app"
|
||||||
|
|||||||
Reference in New Issue
Block a user