import AppKit import ByreAPI import SwiftTerm import SwiftUI let exitedCode = 4000 private let retryAfter = Duration.seconds(2) typealias TaskTerminal = Components.Schemas.TaskTerminal typealias SavedCommand = Components.Schemas.SavedCommand @MainActor func terminalFocused() -> Bool { NSApp.keyWindow?.firstResponder is TerminalView } struct TerminalTab: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var list: [TaskTerminal]? @ViewState private var commands: [SavedCommand] = [] @ViewState private var selected: String? @ViewState private var status = "" @ViewState private var failure: String? @ViewState private var started = false @ViewState private var loads = 0 @ViewState private var off: (() -> Void)? var body: some View { let taskId = store.selectedTaskId let task = taskId.flatMap { store.findTask($0) } let current = list?.first { $0.id == selected }?.id ?? list?.first?.id VStack(spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 12) { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .firstTextBaseline, spacing: 4) { ForEach(list ?? [], id: \.id) { term in HStack(alignment: .firstTextBaseline, spacing: 0) { RangeTabButton(label: term.name, active: term.id == current, help: "Show this terminal") { selected = term.id } Button("×") { close(term) } .buttonStyle(.plain) .foregroundStyle(Palette.muted) .help("Close this terminal") } } if task?.status == .active { Button("+ Shell") { start(nil) } .buttonStyle(SmallLinkButtonStyle()) .help("Open another shell in the task's worktree") .padding(.leading, 8) ForEach(commands, id: \.id) { c in Button("▸ \(c.name)") { start(c.id) } .buttonStyle(SmallLinkButtonStyle()) .help("Open a shell and run \(c.command)") } } } } Text(status) .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .lineLimit(1) } .modifier(HeaderBar()) Group { if let taskId, task?.status == .active { if let current { TerminalHost(api: store.api, taskId: taskId, terminalId: current, active: { [weak store] in store?.findTask(taskId)?.status == .active }, show: { text in status = text }, lost: { load() }) .id("\(taskId) \(current)") } else if let failure { EmptyText(text: failure, error: true) } else if list != nil { EmptyText(text: "No terminals are open in this task.") } } else if taskId == nil { EmptyText(text: "No task selected.") } else { EmptyText(text: "The task is closed; its worktree is gone.") } } .padding(.horizontal, 8) .padding(.vertical, 6) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Palette.bg) } .onChange(of: "\(taskId ?? "") \(task?.status.rawValue ?? "")", initial: true) { list = nil selected = nil status = "" failure = nil started = false load() } .onChange(of: store.connected) { if store.connected { load() } } .onAppear { off = store.events.add { ev in if ev.kind == "terminals.changed" && ev.taskId == store.selectedTaskId { load() } if ev.kind == "command.saved" || ev.kind == "command.deleted" { loadCommands() } } loadCommands() } .onDisappear { off?() off = nil loads += 1 } } private func load() { guard let taskId = store.selectedTaskId, store.findTask(taskId)?.status == .active else { return } loads += 1 let n = loads Task { do { let loaded = try await store.api.client.listTaskTerminals(path: .init(taskId: taskId)).ok.body.json if n != loads { return } list = loaded failure = nil if loaded.isEmpty && !started { start(nil) } } catch { failure = errorText(error) } } } private func loadCommands() { Task { do { commands = try await store.api.client.listSavedCommands().ok.body.json } catch { overlays.showAlert(errorText(error)) } } } private func start(_ commandId: String?) { guard let taskId = store.selectedTaskId else { return } started = true Task { do { let term = try await store.api.client.startTaskTerminal(path: .init(taskId: taskId), body: .json(.init(commandId: commandId))).created.body.json if store.selectedTaskId == taskId { selected = term.id load() } } catch { failure = errorText(error) } } } private func close(_ term: TaskTerminal) { guard let taskId = store.selectedTaskId else { return } Task { do { _ = try await store.api.client.closeTaskTerminal(path: .init(taskId: taskId, terminalId: term.id)).noContent } catch { overlays.showAlert(errorText(error)) } } } } struct TerminalHost: NSViewRepresentable { let api: API let taskId: String let terminalId: String let active: @MainActor () -> Bool let show: @MainActor (String) -> Void let lost: @MainActor () -> Void @Environment(\.colorScheme) private var scheme func makeCoordinator() -> TerminalSession { TerminalSession(api: api, taskId: taskId, terminalId: terminalId, active: active, show: show, lost: lost) } func makeNSView(context: Context) -> TerminalView { let t = TerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) t.font = Fonts.ns(13, mono: true) t.changeScrollback(5000) t.setCursorStyle(.steadyBlock) t.terminalDelegate = context.coordinator context.coordinator.view = t DispatchQueue.main.async { unsafe t.window?.makeFirstResponder(t) context.coordinator.connect() } return t } func updateNSView(_ nsView: TerminalView, context: Context) { let font = Fonts.ns(13, mono: true) if nsView.font !== font { nsView.font = font } if context.coordinator.scheme == scheme { return } context.coordinator.scheme = scheme DispatchQueue.main.async { color(nsView) } } private func color(_ t: TerminalView) { func resolved(_ c: SwiftUI.Color) -> NSColor { var out = NSColor(c) t.effectiveAppearance.performAsCurrentDrawingAppearance { out = NSColor(c).usingColorSpace(.sRGB) ?? NSColor(c) } return out } t.nativeBackgroundColor = resolved(Palette.bg) t.nativeForegroundColor = resolved(Palette.text) t.caretColor = resolved(Palette.accent) t.caretTextColor = resolved(Palette.bg) t.selectedTextBackgroundColor = resolved(Palette.accentTint) } static func dismantleNSView(_ nsView: TerminalView, coordinator: TerminalSession) { coordinator.close() } } @MainActor final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate { let api: API let taskId: String let terminalId: String let active: @MainActor () -> Bool let show: @MainActor (String) -> Void let lost: @MainActor () -> Void weak var view: TerminalView? var scheme: ColorScheme? private var ws: URLSessionWebSocketTask? private var retry: Task? private var done = false init(api: API, taskId: String, terminalId: String, active: @escaping @MainActor () -> Bool, show: @escaping @MainActor (String) -> Void, lost: @escaping @MainActor () -> Void) { self.api = api self.taskId = taskId self.terminalId = terminalId self.active = active self.show = show self.lost = lost } func connect() { guard let view, !done else { return } retry?.cancel() show("connecting…") let t = view.terminalDimensions let url = api.url("/api/tasks/\(taskId)/terminals/\(terminalId)", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))], scheme: "wss") let task = api.session.webSocketTask(with: url) task.delegate = self ws = task task.resume() receive(task) } func close() { done = true retry?.cancel() ws?.cancel(with: .normalClosure, reason: nil) ws = nil } nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { let id = ObjectIdentifier(webSocketTask) Task { @MainActor in guard let ws = self.ws, ObjectIdentifier(ws) == id, !self.done else { return } self.view?.resetToInitialState() self.show("") } } private func receive(_ task: URLSessionWebSocketTask) { task.receive { result in Task { @MainActor in guard self.ws === task, !self.done else { return } switch result { case .success(.data(let d)): self.view?.feed(byteArray: [UInt8](d)[...]) self.receive(task) case .success(.string(let s)): self.view?.feed(text: s) self.receive(task) case .success: self.receive(task) case .failure: self.closed(task) } } } } private func closed(_ task: URLSessionWebSocketTask) { ws = nil let code = task.closeCode.rawValue let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? "" if code == exitedCode { show("shell ended: \(reason)") return } if !active() { show("task closed") return } show("disconnected, reconnecting…") lost() retry = Task { try? await Task.sleep(for: retryAfter) if !Task.isCancelled { connect() } } } func send(source: TerminalView, data: ArraySlice) { ws?.send(.data(Data(data))) { _ in } } func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { ws?.send(.string("{\"cols\":\(newCols),\"rows\":\(newRows)}")) { _ in } } func setTerminalTitle(source: TerminalView, title: String) {} func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} func scrolled(source: TerminalView, position: Double) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} }