2026-10-16 22:23:31 -07:00
|
|
|
|
import AppKit
|
2026-10-21 08:59:03 -07:00
|
|
|
|
import ByreAPI
|
2026-10-16 22:23:31 -07:00
|
|
|
|
import SwiftTerm
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
|
|
let exitedCode = 4000
|
2026-10-17 07:26:18 -07:00
|
|
|
|
private let retryAfter = Duration.seconds(2)
|
|
|
|
|
|
|
2026-10-21 08:59:03 -07:00
|
|
|
|
typealias TaskTerminal = Components.Schemas.TaskTerminal
|
2026-10-21 15:05:09 -07:00
|
|
|
|
typealias TerminalList = Components.Schemas.TerminalList
|
2026-10-21 08:59:03 -07:00
|
|
|
|
typealias SavedCommand = Components.Schemas.SavedCommand
|
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
|
@MainActor
|
|
|
|
|
|
func terminalFocused() -> Bool {
|
|
|
|
|
|
NSApp.keyWindow?.firstResponder is TerminalView
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
|
struct TerminalTab: View {
|
|
|
|
|
|
@Environment(Store.self) private var store
|
2026-10-21 08:59:03 -07:00
|
|
|
|
@Environment(Overlays.self) private var overlays
|
|
|
|
|
|
@ViewState private var commands: [SavedCommand] = []
|
|
|
|
|
|
@ViewState private var selected: String?
|
2026-10-17 07:26:18 -07:00
|
|
|
|
@ViewState private var status = ""
|
2026-10-21 08:59:03 -07:00
|
|
|
|
@ViewState private var failure: String?
|
2026-10-21 15:05:09 -07:00
|
|
|
|
@ViewState private var starting = false
|
2026-10-21 08:59:03 -07:00
|
|
|
|
@ViewState private var off: (() -> Void)?
|
2026-10-17 07:26:18 -07:00
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
let taskId = store.selectedTaskId
|
|
|
|
|
|
let task = taskId.flatMap { store.findTask($0) }
|
2026-10-21 15:05:09 -07:00
|
|
|
|
let list = taskId.flatMap { store.terminals[$0] } ?? []
|
|
|
|
|
|
let current = list.first { $0.id == selected }?.id ?? list.first?.id
|
2026-10-17 07:26:18 -07:00
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
2026-10-21 08:59:03 -07:00
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
2026-10-21 15:05:09 -07:00
|
|
|
|
ForEach(list, id: \.id) { term in
|
2026-10-21 08:59:03 -07:00
|
|
|
|
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)")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
Text(status)
|
|
|
|
|
|
.font(Fonts.font(11))
|
|
|
|
|
|
.foregroundStyle(Palette.muted)
|
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
.modifier(HeaderBar())
|
|
|
|
|
|
Group {
|
|
|
|
|
|
if let taskId, task?.status == .active {
|
2026-10-21 08:59:03 -07:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
.id("\(taskId) \(current)")
|
|
|
|
|
|
} else if let failure {
|
|
|
|
|
|
EmptyText(text: failure, error: true)
|
2026-10-21 15:05:09 -07:00
|
|
|
|
} else if !starting {
|
2026-10-21 08:59:03 -07:00
|
|
|
|
EmptyText(text: "No terminals are open in this task.")
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
} 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) {
|
2026-10-21 08:59:03 -07:00
|
|
|
|
selected = nil
|
2026-10-17 07:26:18 -07:00
|
|
|
|
status = ""
|
2026-10-21 08:59:03 -07:00
|
|
|
|
failure = nil
|
2026-10-21 15:05:09 -07:00
|
|
|
|
starting = false
|
|
|
|
|
|
if task?.status == .active && list.isEmpty {
|
|
|
|
|
|
start(nil)
|
2026-10-21 08:59:03 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.onAppear {
|
|
|
|
|
|
off = store.events.add { ev in
|
|
|
|
|
|
if ev.kind == "command.saved" || ev.kind == "command.deleted" {
|
|
|
|
|
|
loadCommands()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
loadCommands()
|
|
|
|
|
|
}
|
|
|
|
|
|
.onDisappear {
|
|
|
|
|
|
off?()
|
|
|
|
|
|
off = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-10-21 15:05:09 -07:00
|
|
|
|
starting = true
|
2026-10-21 08:59:03 -07:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
failure = errorText(error)
|
|
|
|
|
|
}
|
2026-10-21 15:05:09 -07:00
|
|
|
|
starting = false
|
2026-10-21 08:59:03 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-16 22:23:31 -07:00
|
|
|
|
|
|
|
|
|
|
struct TerminalHost: NSViewRepresentable {
|
2026-10-16 22:46:54 -07:00
|
|
|
|
let api: API
|
2026-10-16 22:23:31 -07:00
|
|
|
|
let taskId: String
|
2026-10-21 08:59:03 -07:00
|
|
|
|
let terminalId: String
|
2026-10-17 07:26:18 -07:00
|
|
|
|
let active: @MainActor () -> Bool
|
2026-10-21 08:59:03 -07:00
|
|
|
|
let show: @MainActor (String) -> Void
|
2026-10-17 07:26:18 -07:00
|
|
|
|
@Environment(\.colorScheme) private var scheme
|
2026-10-16 22:23:31 -07:00
|
|
|
|
|
|
|
|
|
|
func makeCoordinator() -> TerminalSession {
|
2026-10-21 15:05:09 -07:00
|
|
|
|
TerminalSession(api: api, taskId: taskId, terminalId: terminalId, active: active, show: show)
|
2026-10-16 22:23:31 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func makeNSView(context: Context) -> TerminalView {
|
|
|
|
|
|
let t = TerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400))
|
2026-10-21 08:48:30 -07:00
|
|
|
|
t.font = Fonts.ns(13, mono: true)
|
2026-10-17 10:54:06 -07:00
|
|
|
|
t.changeScrollback(5000)
|
2026-10-18 09:24:25 -07:00
|
|
|
|
t.setCursorStyle(.steadyBlock)
|
2026-10-16 22:23:31 -07:00
|
|
|
|
t.terminalDelegate = context.coordinator
|
|
|
|
|
|
context.coordinator.view = t
|
|
|
|
|
|
DispatchQueue.main.async {
|
2026-10-17 09:57:28 -07:00
|
|
|
|
unsafe t.window?.makeFirstResponder(t)
|
2026-10-16 22:23:31 -07:00
|
|
|
|
context.coordinator.connect()
|
|
|
|
|
|
}
|
|
|
|
|
|
return t
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
|
func updateNSView(_ nsView: TerminalView, context: Context) {
|
2026-10-21 08:48:30 -07:00
|
|
|
|
let font = Fonts.ns(13, mono: true)
|
2026-10-20 19:32:27 -07:00
|
|
|
|
if nsView.font !== font {
|
|
|
|
|
|
nsView.font = font
|
|
|
|
|
|
}
|
2026-10-17 23:21:40 -07:00
|
|
|
|
if context.coordinator.scheme == scheme {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
context.coordinator.scheme = scheme
|
2026-10-17 07:26:18 -07:00
|
|
|
|
DispatchQueue.main.async {
|
2026-10-20 22:28:36 -07:00
|
|
|
|
color(nsView)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 22:28:36 -07:00
|
|
|
|
private func color(_ t: TerminalView) {
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-10-16 22:23:31 -07:00
|
|
|
|
|
|
|
|
|
|
static func dismantleNSView(_ nsView: TerminalView, coordinator: TerminalSession) {
|
|
|
|
|
|
coordinator.close()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@MainActor
|
2026-10-17 07:26:18 -07:00
|
|
|
|
final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate {
|
2026-10-16 22:46:54 -07:00
|
|
|
|
let api: API
|
2026-10-16 22:23:31 -07:00
|
|
|
|
let taskId: String
|
2026-10-21 08:59:03 -07:00
|
|
|
|
let terminalId: String
|
2026-10-17 07:26:18 -07:00
|
|
|
|
let active: @MainActor () -> Bool
|
2026-10-21 08:59:03 -07:00
|
|
|
|
let show: @MainActor (String) -> Void
|
2026-10-16 22:23:31 -07:00
|
|
|
|
weak var view: TerminalView?
|
2026-10-17 23:21:40 -07:00
|
|
|
|
var scheme: ColorScheme?
|
2026-10-16 22:23:31 -07:00
|
|
|
|
private var ws: URLSessionWebSocketTask?
|
2026-10-17 07:26:18 -07:00
|
|
|
|
private var retry: Task<Void, Never>?
|
|
|
|
|
|
private var done = false
|
2026-10-16 22:23:31 -07:00
|
|
|
|
|
2026-10-21 15:05:09 -07:00
|
|
|
|
init(api: API, taskId: String, terminalId: String, active: @escaping @MainActor () -> Bool, show: @escaping @MainActor (String) -> Void) {
|
2026-10-16 22:46:54 -07:00
|
|
|
|
self.api = api
|
2026-10-16 22:23:31 -07:00
|
|
|
|
self.taskId = taskId
|
2026-10-21 08:59:03 -07:00
|
|
|
|
self.terminalId = terminalId
|
2026-10-17 07:26:18 -07:00
|
|
|
|
self.active = active
|
2026-10-16 22:23:31 -07:00
|
|
|
|
self.show = show
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func connect() {
|
2026-10-17 07:26:18 -07:00
|
|
|
|
guard let view, !done else {
|
2026-10-16 22:23:31 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-10-17 07:26:18 -07:00
|
|
|
|
retry?.cancel()
|
2026-10-21 08:59:03 -07:00
|
|
|
|
show("connecting…")
|
2026-10-16 22:23:31 -07:00
|
|
|
|
let t = view.terminalDimensions
|
2026-10-21 08:59:03 -07:00
|
|
|
|
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")
|
2026-10-17 07:26:18 -07:00
|
|
|
|
let task = api.session.webSocketTask(with: url)
|
|
|
|
|
|
task.delegate = self
|
2026-10-16 22:23:31 -07:00
|
|
|
|
ws = task
|
|
|
|
|
|
task.resume()
|
|
|
|
|
|
receive(task)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func close() {
|
2026-10-17 07:26:18 -07:00
|
|
|
|
done = true
|
|
|
|
|
|
retry?.cancel()
|
2026-10-16 22:23:31 -07:00
|
|
|
|
ws?.cancel(with: .normalClosure, reason: nil)
|
|
|
|
|
|
ws = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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()
|
2026-10-21 08:59:03 -07:00
|
|
|
|
self.show("")
|
2026-10-17 07:26:18 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-16 22:23:31 -07:00
|
|
|
|
private func receive(_ task: URLSessionWebSocketTask) {
|
|
|
|
|
|
task.receive { result in
|
|
|
|
|
|
Task { @MainActor in
|
2026-10-17 07:26:18 -07:00
|
|
|
|
guard self.ws === task, !self.done else {
|
2026-10-16 22:23:31 -07:00
|
|
|
|
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)
|
2026-10-17 07:26:18 -07:00
|
|
|
|
case .failure:
|
|
|
|
|
|
self.closed(task)
|
2026-10-16 22:23:31 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 07:26:18 -07:00
|
|
|
|
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 {
|
2026-10-21 08:59:03 -07:00
|
|
|
|
show("shell ended: \(reason)")
|
2026-10-17 07:26:18 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if !active() {
|
2026-10-21 08:59:03 -07:00
|
|
|
|
show("task closed")
|
2026-10-17 07:26:18 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-10-21 08:59:03 -07:00
|
|
|
|
show("disconnected, reconnecting…")
|
2026-10-17 07:26:18 -07:00
|
|
|
|
retry = Task {
|
|
|
|
|
|
try? await Task.sleep(for: retryAfter)
|
|
|
|
|
|
if !Task.isCancelled {
|
|
|
|
|
|
connect()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-16 22:23:31 -07:00
|
|
|
|
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
|
|
|
|
|
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) {}
|
|
|
|
|
|
}
|