Add multiple terminals per task and saved commands that start one

This commit is contained in:
Ian Gulliver
2026-10-21 08:59:03 -07:00
parent fe2a2022c3
commit d062d7ccae
15 changed files with 1418 additions and 450 deletions
+139
View File
@@ -42,6 +42,11 @@ struct SettingsView: View {
ModelMenu(current: store.defaultModel, help: "Model for new tasks") { m in
try await store.api.client.updateDefaultModel(body: .json(.init(model: m))).ok.body.json
}
heading("Saved commands")
Text("Each is a button in the Terminal tab of every task on this server. It opens a new shell in the task's worktree and runs the command there.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
SavedCommands()
heading("iCloud sync")
SyncState()
heading("This Mac's permissions")
@@ -217,6 +222,140 @@ private struct PrefsBox: View {
}
}
private struct SavedCommands: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var list: [SavedCommand] = []
@ViewState private var editing: String?
@ViewState private var name = ""
@ViewState private var command = ""
@ViewState private var off: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(list, id: \.id) { c in
if editing == c.id {
form(c.id)
} else {
HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(c.name)
Text(c.command)
.font(Fonts.font(12))
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
Button("Edit") {
editing = c.id
name = c.name
command = c.command
}
.buttonStyle(SmallLinkButtonStyle())
Button("Delete") {
delete(c)
}
.buttonStyle(SmallLinkButtonStyle(color: Palette.danger))
}
.padding(.vertical, 6)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
if editing == nil {
form(nil)
}
}
.padding(.top, 8)
.onAppear {
off = store.events.add { ev in
if ev.kind == "command.saved" || ev.kind == "command.deleted" {
load()
}
}
load()
}
.onDisappear {
off?()
off = nil
}
}
private func form(_ id: String?) -> some View {
let valid = !name.trimmingCharacters(in: .whitespaces).isEmpty && !command.trimmingCharacters(in: .whitespaces).isEmpty
return HStack(spacing: 6) {
TextField("Name", text: $name)
.modifier(FieldStyle())
.frame(width: 180)
.onSubmit {
save(id, valid)
}
TextField("Command", text: $command)
.modifier(FieldStyle())
.onSubmit {
save(id, valid)
}
Button(id == nil ? "Add" : "Save") {
save(id, valid)
}
.buttonStyle(WebButtonStyle())
.disabled(!valid)
if id != nil {
Button("Cancel") {
editing = nil
name = ""
command = ""
}
.buttonStyle(WebButtonStyle())
}
}
.padding(.vertical, 6)
}
private func load() {
Task {
do {
list = try await store.api.client.listSavedCommands().ok.body.json
} catch {
overlays.showAlert(errorText(error))
}
}
}
private func save(_ id: String?, _ valid: Bool) {
if !valid {
return
}
let body = Components.Schemas.SavedCommandSave(name: name, command: command)
Task {
do {
if let id {
_ = try await store.api.client.updateSavedCommand(path: .init(commandId: id), body: .json(body)).ok.body.json
} else {
_ = try await store.api.client.createSavedCommand(body: .json(body)).created.body.json
}
editing = nil
name = ""
command = ""
} catch {
overlays.showAlert(errorText(error))
}
}
}
private func delete(_ c: SavedCommand) {
Task {
do {
_ = try await store.api.client.deleteSavedCommand(path: .init(commandId: c.id)).noContent
} catch {
overlays.showAlert(errorText(error))
}
}
}
}
private struct ModelMenu: View {
let current: String
let help: String
+166 -34
View File
@@ -1,11 +1,14 @@
import AppKit
import ByreAPI
import SwiftTerm
import SwiftUI
let exitedCode = 4000
let internalErrorCode = 1011
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
@@ -13,39 +16,76 @@ func terminalFocused() -> Bool {
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 exited = false
@ViewState private var shell = 0
@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) {
Text("Shell in the task's worktree")
.foregroundStyle(Palette.muted)
.frame(maxWidth: .infinity, alignment: .leading)
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)
if exited {
Button("New shell") {
shell += 1
}
.buttonStyle(WebButtonStyle())
}
}
.modifier(HeaderBar())
Group {
if let taskId, task?.status == .active {
TerminalHost(api: store.api, taskId: taskId, active: { [weak store] in
store?.findTask(taskId)?.status == .active
}, show: { text, ended in
status = text
exited = ended
})
.id("\(taskId) \(shell)")
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 {
@@ -58,8 +98,97 @@ struct TerminalTab: View {
.background(Palette.bg)
}
.onChange(of: "\(taskId ?? "") \(task?.status.rawValue ?? "")", initial: true) {
list = nil
selected = nil
status = ""
exited = false
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))
}
}
}
}
@@ -67,12 +196,14 @@ struct TerminalTab: View {
struct TerminalHost: NSViewRepresentable {
let api: API
let taskId: String
let terminalId: String
let active: @MainActor () -> Bool
let show: @MainActor (String, Bool) -> Void
let show: @MainActor (String) -> Void
let lost: @MainActor () -> Void
@Environment(\.colorScheme) private var scheme
func makeCoordinator() -> TerminalSession {
TerminalSession(api: api, taskId: taskId, active: active, show: show)
TerminalSession(api: api, taskId: taskId, terminalId: terminalId, active: active, show: show, lost: lost)
}
func makeNSView(context: Context) -> TerminalView {
@@ -127,19 +258,23 @@ struct TerminalHost: NSViewRepresentable {
final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocketDelegate {
let api: API
let taskId: String
let terminalId: String
let active: @MainActor () -> Bool
let show: @MainActor (String, Bool) -> Void
let show: @MainActor (String) -> Void
let lost: @MainActor () -> Void
weak var view: TerminalView?
var scheme: ColorScheme?
private var ws: URLSessionWebSocketTask?
private var retry: Task<Void, Never>?
private var done = false
init(api: API, taskId: String, active: @escaping @MainActor () -> Bool, show: @escaping @MainActor (String, Bool) -> Void) {
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() {
@@ -147,9 +282,9 @@ final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocket
return
}
retry?.cancel()
show("connecting…", false)
show("connecting…")
let t = view.terminalDimensions
let url = api.url("/api/tasks/\(taskId)/terminal", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))], scheme: "wss")
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
@@ -171,7 +306,7 @@ final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocket
return
}
self.view?.resetToInitialState()
self.show("", false)
self.show("")
}
}
@@ -202,18 +337,15 @@ final class TerminalSession: NSObject, TerminalViewDelegate, URLSessionWebSocket
let code = task.closeCode.rawValue
let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? ""
if code == exitedCode {
show("shell ended: \(reason)", true)
return
}
if code == internalErrorCode {
show(reason, true)
show("shell ended: \(reason)")
return
}
if !active() {
show("task closed", false)
show("task closed")
return
}
show("disconnected, reconnecting…", false)
show("disconnected, reconnecting…")
lost()
retry = Task {
try? await Task.sleep(for: retryAfter)
if !Task.isCancelled {