Add the macOS client spike and record its SwiftUI gap results

This commit is contained in:
Ian Gulliver
2026-10-16 22:23:31 -07:00
parent 7e0e6d9585
commit c2d0ff77d7
16 changed files with 1281 additions and 20 deletions
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>run.fc.byre.mac</string>
<key>CFBundleName</key>
<string>byre</string>
<key>CFBundleExecutable</key>
<string>Byre</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>byre</string>
<key>CFBundleShortVersionString</key>
<string>0</string>
<key>CFBundleVersion</key>
<string>0</string>
<key>LSMinimumSystemVersion</key>
<string>27.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
+42
View File
@@ -0,0 +1,42 @@
{
"originHash" : "e5f19b7bfab6a34c2470eda6b14c1682772530dafe85011c2def5e6456f2bb55",
"pins" : [
{
"identity" : "h",
"kind" : "remoteSourceControl",
"location" : "https://github.com/rarestype/h",
"state" : {
"revision" : "aa3626829160917d4378330617971977cbd78f5b",
"version" : "1.0.1"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
"version" : "1.8.2"
}
},
{
"identity" : "swift-png",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tayloraswift/swift-png",
"state" : {
"revision" : "8a0bcd4df5e4b307c804937776a56dd6ecdf6396",
"version" : "4.5.1"
}
},
{
"identity" : "swiftterm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/migueldeicaza/SwiftTerm",
"state" : {
"revision" : "2dad6a1e516f755bed77cf9bbd09455062f54758",
"version" : "1.99.0"
}
}
],
"version" : 3
}
+16
View File
@@ -0,0 +1,16 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "Byre",
platforms: [.macOS("27.0")],
dependencies: [
.package(url: "https://github.com/migueldeicaza/SwiftTerm", from: "1.2.0"),
],
targets: [
.executableTarget(
name: "Byre",
dependencies: [.product(name: "SwiftTerm", package: "SwiftTerm")]
),
]
)
+40
View File
@@ -0,0 +1,40 @@
import AppKit
import SwiftTerm
import SwiftUI
typealias ViewState = State
@main
struct ByreApp: App {
@ViewState private var server = Server()
var body: some Scene {
Window("byre", id: "main") {
SpikeView()
.environment(server)
.font(Fonts.font(13))
.onAppear {
installCtrlMonitor(server)
}
}
.commands {
PasteboardCommands()
}
}
}
@MainActor
private func installCtrlMonitor(_ server: Server) {
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
guard flags.contains(.control), !flags.contains(.command), let key = event.charactersIgnoringModifiers, ["k", "p", "l", "s", "/"].contains(key) else {
return event
}
if event.window?.firstResponder is TerminalView {
server.log("ctrl-\(key) passed to the terminal")
return event
}
server.log("ctrl-\(key) taken as a Go shortcut")
return nil
}
}
+381
View File
@@ -0,0 +1,381 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
let maxImageBytes = 5 << 20
struct PendingImage: Identifiable {
let id = UUID()
let data: Data
let image: NSImage
}
func imageType(_ data: Data) -> String? {
let b = [UInt8](data.prefix(12))
if b.starts(with: [0x89, 0x50, 0x4E, 0x47]) {
return "png"
}
if b.starts(with: [0xFF, 0xD8, 0xFF]) {
return "jpeg"
}
if b.starts(with: Array("GIF8".utf8)) {
return "gif"
}
if b.count == 12, b.starts(with: Array("RIFF".utf8)), Array(b[8..<12]) == Array("WEBP".utf8) {
return "webp"
}
return nil
}
func pngFromTIFF(_ data: Data) -> Data? {
NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:])
}
@MainActor
@Observable
final class Attachments {
var images: [PendingImage] = []
let server: Server
let name: String
init(server: Server, name: String) {
self.server = server
self.name = name
}
func add(_ data: Data, from source: String) {
guard let kind = imageType(data), let image = NSImage(data: data) else {
server.log("\(name): \(source) gave \(data.count) bytes that aren't PNG, JPEG, GIF or WebP")
return
}
if data.count > maxImageBytes {
server.log("\(name): \(source) image is over \(maxImageBytes >> 20) MiB")
return
}
server.log("\(name): \(source) attached a \(kind), \(data.count) bytes")
images.append(PendingImage(data: data, image: image))
}
func add(pasteboard pb: NSPasteboard, from source: String) -> Bool {
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty {
var any = false
for url in urls {
if let data = try? Data(contentsOf: url), imageType(data) != nil || pngFromTIFF(data) != nil && NSImage(data: data) != nil {
add(imageType(data) != nil ? data : pngFromTIFF(data)!, from: "\(source) file \(url.lastPathComponent)")
any = true
}
}
return any
}
for t in [NSPasteboard.PasteboardType.png, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier)] {
if let data = pb.data(forType: t) {
add(data, from: "\(source) \(t.rawValue)")
return true
}
}
if let tiff = pb.data(forType: .tiff), let png = pngFromTIFF(tiff) {
add(png, from: "\(source) public.tiff converted to PNG")
return true
}
return false
}
func add(providers: [NSItemProvider], from source: String) {
server.log("\(name): \(source) with types \(providers.map { $0.registeredTypeIdentifiers })")
for p in providers {
let types = [UTType.png, .jpeg, .gif, .webP, .tiff, .fileURL].filter { p.hasItemConformingToTypeIdentifier($0.identifier) }
guard let t = types.first else {
continue
}
_ = p.loadDataRepresentation(for: t) { data, error in
Task { @MainActor in
guard let data else {
self.server.log("\(self.name): \(source) load failed: \(String(describing: error))")
return
}
if t == .fileURL, let url = URL(dataRepresentation: data, relativeTo: nil), let file = try? Data(contentsOf: url) {
self.add(imageType(file) != nil ? file : (pngFromTIFF(file) ?? file), from: "\(source) file \(url.lastPathComponent)")
} else if t == .tiff, let png = pngFromTIFF(data) {
self.add(png, from: "\(source) tiff converted to PNG")
} else {
self.add(data, from: "\(source) \(t.identifier)")
}
}
}
}
}
func send(_ text: String) {
let images = self.images
self.images = []
server.log("\(name): send \(text.debugDescription) with \(images.count) images")
Task {
for img in images {
do {
server.log("\(name): uploaded blob \(try await server.upload(img.data))")
} catch {
server.log("\(name): upload failed: \(error)")
}
}
}
}
}
struct Thumbnails: View {
@Bindable var attachments: Attachments
var body: some View {
HStack {
ForEach(attachments.images) { img in
ZStack(alignment: .topTrailing) {
Image(nsImage: img.image)
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
Button("×") {
attachments.images.removeAll { $0.id == img.id }
}
}
}
}
.frame(height: attachments.images.isEmpty ? 0 : 64)
}
}
struct ComposerSpike: View {
@Environment(Server.self) private var server
var body: some View {
HStack(alignment: .top, spacing: 16) {
SwiftUIComposer(attachments: Attachments(server: server, name: "TextEditor"))
AppKitComposerPanel(attachments: Attachments(server: server, name: "NSTextView"))
RoutedComposer(attachments: Attachments(server: server, name: "Routed"))
}
.padding(8)
}
}
struct ComposerFocusKey: FocusedValueKey {
typealias Value = Attachments
}
extension FocusedValues {
var composer: Attachments? {
get {
self[ComposerFocusKey.self]
}
set {
self[ComposerFocusKey.self] = newValue
}
}
}
struct PasteboardCommands: Commands {
@FocusedValue(\.composer) private var composer
var body: some Commands {
CommandGroup(replacing: .pasteboard) {
Button("Cut") {
NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: nil)
}
.keyboardShortcut("x")
Button("Copy") {
NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil)
}
.keyboardShortcut("c")
Button("Paste") {
if let composer, composer.add(pasteboard: .general, from: "Edit ▸ Paste") {
return
}
NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
}
.keyboardShortcut("v")
Button("Select All") {
NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
}
.keyboardShortcut("a")
}
}
}
struct RoutedComposer: View {
@ViewState var attachments: Attachments
@ViewState private var text = ""
@ViewState private var overlayHits = false
@ViewState private var targeted = false
var body: some View {
VStack(alignment: .leading) {
Text("SwiftUI TextEditor, paste through Edit ▸ Paste, drop overlay")
Toggle("Drop overlay takes hits (off: clicks pass through to the editor)", isOn: $overlayHits)
Thumbnails(attachments: attachments)
TextEditor(text: $text)
.frame(minHeight: 160)
.border(targeted ? Color.accentColor : Color.secondary.opacity(0.4))
.focusedValue(\.composer, attachments)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
attachments.server.log("Routed: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
if press.modifiers.contains(.shift) || marked {
return .ignored
}
attachments.send(text)
text = ""
return .handled
}
.overlay {
Color.clear
.contentShape(Rectangle())
.allowsHitTesting(overlayHits)
.onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in
attachments.add(providers: providers, from: "overlay onDrop")
return true
}
}
.onChange(of: targeted) {
attachments.server.log("Routed: overlay drop targeted \(targeted)")
}
}
}
}
struct SwiftUIComposer: View {
@ViewState var attachments: Attachments
@ViewState private var text = ""
@ViewState private var checkMarked = false
var body: some View {
VStack(alignment: .leading) {
Text("SwiftUI TextEditor + onKeyPress, onPasteCommand, onDrop")
Toggle("In onKeyPress, also ignore Return while the first responder hasMarkedText()", isOn: $checkMarked)
Thumbnails(attachments: attachments)
TextEditor(text: $text)
.frame(minHeight: 160)
.border(Color.secondary.opacity(0.4))
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
attachments.server.log("TextEditor: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)")
if press.modifiers.contains(.shift) {
return .ignored
}
if checkMarked && marked {
return .ignored
}
attachments.send(text)
text = ""
return .handled
}
.onPasteCommand(of: [.png, .jpeg, .gif, .webP, .tiff, .fileURL]) { providers in
attachments.add(providers: providers, from: "onPasteCommand")
}
.onDrop(of: [.image, .fileURL], isTargeted: nil) { providers in
attachments.add(providers: providers, from: "onDrop")
return true
}
}
}
}
struct AppKitComposerPanel: View {
@ViewState var attachments: Attachments
var body: some View {
VStack(alignment: .leading) {
Text("NSTextView in NSViewRepresentable")
Text("doCommand(insertNewline:), paste(_:), performDragOperation")
Thumbnails(attachments: attachments)
AppKitComposer(attachments: attachments)
.frame(minHeight: 160)
.border(Color.secondary.opacity(0.4))
}
}
}
final class ComposerTextView: NSTextView {
var attachments: Attachments!
override func keyDown(with event: NSEvent) {
if event.keyCode == 36 || event.keyCode == 76 {
attachments.server.log("NSTextView: keyDown return, modifiers \(event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue), hasMarkedText \(hasMarkedText())")
}
super.keyDown(with: event)
}
override func doCommand(by selector: Selector) {
let shift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
if selector == #selector(insertNewline(_:)) && !shift {
attachments.send(string)
string = ""
return
}
if selector == #selector(insertNewline(_:)) {
insertNewlineIgnoringFieldEditor(nil)
return
}
super.doCommand(by: selector)
}
override func paste(_ sender: Any?) {
if attachments.add(pasteboard: .general, from: "paste(_:)") {
return
}
super.paste(sender)
}
override var readablePasteboardTypes: [NSPasteboard.PasteboardType] {
super.readablePasteboardTypes + [.png, .tiff, .fileURL]
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
if hasImage(sender.draggingPasteboard) {
return .copy
}
return super.draggingEntered(sender)
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
if hasImage(sender.draggingPasteboard) {
return .copy
}
return super.draggingUpdated(sender)
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
if hasImage(sender.draggingPasteboard) {
return attachments.add(pasteboard: sender.draggingPasteboard, from: "drop")
}
return super.performDragOperation(sender)
}
private func hasImage(_ pb: NSPasteboard) -> Bool {
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty {
return urls.contains { (try? Data(contentsOf: $0)).map { imageType($0) != nil || NSImage(data: $0) != nil } ?? false }
}
return pb.availableType(from: [.png, .tiff, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier)]) != nil
}
}
struct AppKitComposer: NSViewRepresentable {
let attachments: Attachments
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
let tv = ComposerTextView(frame: .zero)
tv.attachments = attachments
tv.isRichText = false
tv.allowsUndo = true
tv.font = Fonts.ct(13) as NSFont
tv.minSize = .zero
tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
tv.isVerticallyResizable = true
tv.isHorizontallyResizable = false
tv.autoresizingMask = [.width]
tv.textContainer?.widthTracksTextView = true
tv.registerForDraggedTypes([.png, .tiff, .fileURL])
scroll.documentView = tv
return scroll
}
func updateNSView(_ nsView: NSScrollView, context: Context) {}
}
+109
View File
@@ -0,0 +1,109 @@
import AppKit
import CoreText
import SwiftUI
func runFonts(_ font: CTFont, _ text: String) -> String {
let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: [.font: font as NSFont]))
return (CTLineGetGlyphRuns(line) as! [CTRun]).map { run in
let f = (CTRunGetAttributes(run) as NSDictionary)[kCTFontAttributeName] as! CTFont
let file = (CTFontCopyAttribute(f, kCTFontURLAttribute) as? URL)?.lastPathComponent ?? "system"
let r = CTRunGetStringRange(run)
let part = (text as NSString).substring(with: NSRange(location: r.location, length: r.length))
return "\(part) → \(CTFontCopyPostScriptName(f)) (\(file))"
}.joined(separator: "\n")
}
struct FontSpike: View {
private let sample = "The quick brown fox jumps — 0O 1lI {}[] őŁżę → │"
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array([(400.0, false), (700.0, false), (400.0, true), (700.0, true)].enumerated()), id: \.offset) { _, style in
let (w, italic) = style
VStack(alignment: .leading) {
Text("weight \(Int(w))\(italic ? " italic" : "")")
.foregroundStyle(.secondary)
Text(sample)
.font(Fonts.font(16, weight: w, italic: italic))
Text(runFonts(Fonts.ct(16, weight: w, italic: italic), sample))
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading) {
Text("Fonts.font(16) with SwiftUI .bold() and .italic()")
.foregroundStyle(.secondary)
Text(sample)
.font(Fonts.font(16))
.bold()
.italic()
}
VStack(alignment: .leading) {
Text("System font, for comparison")
.foregroundStyle(.secondary)
Text(sample)
.font(.system(size: 16))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
}
}
struct SVGSpike: View {
@Environment(Server.self) private var server
@ViewState private var path = "web/favicon.svg"
@ViewState private var image: NSImage?
@ViewState private var failure = ""
var body: some View {
VStack(alignment: .leading) {
HStack {
TextField("Worktree path", text: $path)
.frame(width: 320)
Button("Load") {
Task {
await load()
}
}
}
if let image {
Text(verbatim: "NSImage size \(Int(image.size.width))×\(Int(image.size.height)), reps \(image.representations.map { String(describing: type(of: $0)) })")
.foregroundStyle(.secondary)
HStack(alignment: .top, spacing: 16) {
Image(nsImage: image)
Image(nsImage: image)
.resizable()
.scaledToFit()
.frame(width: 256, height: 256)
}
}
if !failure.isEmpty {
Text(failure)
}
Spacer()
}
.task {
await load()
}
}
private func load() async {
image = nil
failure = ""
guard !server.taskId.isEmpty else {
return
}
do {
let data = try await server.data(URLRequest(url: try server.url("/api/tasks/\(server.taskId)/image", [URLQueryItem(name: "path", value: path)])))
guard let img = NSImage(data: data) else {
failure = "image not available: \(path)"
return
}
image = img
} catch {
failure = "image not available: \(path) (\(error))"
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import CoreText
import SwiftUI
enum Fonts {
nonisolated(unsafe) private static let descriptors: [String: CTFontDescriptor] = {
let dir = Bundle.main.resourceURL!.appendingPathComponent("fonts")
var out: [String: CTFontDescriptor] = [:]
for name in try! FileManager.default.contentsOfDirectory(atPath: dir.path) where name.hasSuffix(".woff2") {
let url = dir.appendingPathComponent(name)
let found = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as! [CTFontDescriptor]
out[name] = found[0]
}
return out
}()
private static func file(_ subset: String, _ italic: Bool, _ weight: CGFloat) -> CTFontDescriptor {
let d = descriptors["atkinson-hyperlegible-mono-\(subset)-wght-\(italic ? "italic" : "normal").woff2"]!
return CTFontDescriptorCreateCopyWithVariation(d, 0x7767_6874 as CFNumber, weight)
}
static func ct(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> CTFont {
let ext = file("latin-ext", italic, weight)
let d = CTFontDescriptorCreateCopyWithAttributes(file("latin", italic, weight), [kCTFontCascadeListAttribute: [ext]] as CFDictionary)
return CTFontCreateWithFontDescriptor(d, size, nil)
}
static func font(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> Font {
Font(ct(size, weight: weight, italic: italic))
}
}
+94
View File
@@ -0,0 +1,94 @@
import Foundation
import Observation
struct ProjectRow: Decodable {
let id: String
let name: String
}
struct TaskRow: Decodable, Hashable {
let id: String
let title: String
let status: String
}
struct BlobRow: Decodable {
let id: String
}
struct SpikeError: Error, CustomStringConvertible {
let description: String
}
@MainActor
@Observable
final class Server {
var origin = UserDefaults.standard.string(forKey: "byre.server") ?? ""
var tasks: [TaskRow] = []
var taskId = ""
var status = ""
var lines: [String] = []
func log(_ line: String) {
lines.append(line)
}
private var base: URLComponents?
func url(_ path: String, _ query: [URLQueryItem] = []) throws -> URL {
guard var c = base else {
throw SpikeError(description: "not connected")
}
c.path = path
if !query.isEmpty {
c.queryItems = query
}
guard let u = c.url else {
throw SpikeError(description: "bad URL for \(path)")
}
return u
}
func data(_ request: URLRequest) async throws -> Data {
let (data, response) = try await URLSession.shared.data(for: request)
let code = (response as! HTTPURLResponse).statusCode
if !(200..<300).contains(code) {
throw SpikeError(description: "\(code) \(String(decoding: data, as: UTF8.self))")
}
return data
}
func get<T: Decodable>(_ type: T.Type, _ path: String, _ query: [URLQueryItem] = []) async throws -> T {
try JSONDecoder().decode(T.self, from: try await data(URLRequest(url: try url(path, query))))
}
func connect() async {
guard let c = URLComponents(string: origin), c.scheme == "https", let host = c.host, !host.isEmpty, c.path.isEmpty || c.path == "/", c.url != nil else {
base = nil
status = "Server URL must be an https origin with no path, such as https://host.ts.net:8270"
return
}
base = c
UserDefaults.standard.set(origin, forKey: "byre.server")
do {
var all: [TaskRow] = []
for p in try await get([ProjectRow].self, "/api/projects") {
all += try await get([TaskRow].self, "/api/projects/\(p.id)/tasks")
}
tasks = all.filter { $0.status == "active" } + all.filter { $0.status != "active" }
if !tasks.contains(where: { $0.id == taskId }) {
taskId = tasks.first?.id ?? ""
}
status = "\(tasks.count) tasks"
} catch {
status = "\(error)"
}
}
func upload(_ image: Data) async throws -> String {
var req = URLRequest(url: try url("/api/blobs"))
req.httpMethod = "POST"
req.httpBody = image
return try JSONDecoder().decode(BlobRow.self, from: try await data(req)).id
}
}
+69
View File
@@ -0,0 +1,69 @@
import SwiftUI
struct SpikeView: View {
@Environment(Server.self) private var server
var body: some View {
@Bindable var server = server
VStack(spacing: 0) {
HStack {
TextField("Server URL", text: $server.origin)
.frame(width: 320)
Button("Connect") {
Task {
await server.connect()
}
}
Picker("Task", selection: $server.taskId) {
ForEach(server.tasks, id: \.id) { t in
Text(verbatim: "\(t.title) · \(t.status) · \(t.id)").tag(t.id)
}
}
.frame(width: 280)
Text(server.status)
Spacer()
}
.padding(8)
Divider()
TabView {
Tab("Composer", systemImage: "keyboard") {
ComposerSpike()
}
Tab("Transcript", systemImage: "text.alignleft") {
TranscriptSpike()
.id(server.taskId)
}
Tab("Terminal", systemImage: "terminal") {
TerminalSpike()
.id(server.taskId)
}
Tab("Font", systemImage: "textformat") {
FontSpike()
}
Tab("SVG", systemImage: "photo") {
SVGSpike()
.id(server.taskId)
}
}
.padding(8)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(server.lines.enumerated()), id: \.offset) { _, line in
Text(line)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.defaultScrollAnchor(.bottom)
.frame(height: 140)
}
.frame(minWidth: 1000, minHeight: 700)
.task {
if !server.origin.isEmpty {
await server.connect()
}
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import AppKit
import SwiftTerm
import SwiftUI
let exitedCode = 4000
let internalErrorCode = 1011
struct TerminalSpike: View {
@Environment(Server.self) private var server
@ViewState private var status = ""
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Shell in the task's worktree")
Text(status)
.foregroundStyle(.secondary)
}
if server.taskId.isEmpty {
Text("No task selected.")
} else if server.tasks.first(where: { $0.id == server.taskId })?.status != "active" {
Text("The task is closed; its worktree is gone.")
} else {
TerminalHost(server: server, taskId: server.taskId, status: $status)
}
}
}
}
struct TerminalHost: NSViewRepresentable {
let server: Server
let taskId: String
@Binding var status: String
func makeCoordinator() -> TerminalSession {
TerminalSession(server: server, taskId: taskId) { s in
status = s
}
}
func makeNSView(context: Context) -> TerminalView {
let t = TerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400))
t.font = Fonts.ct(13) as NSFont
t.terminalDelegate = context.coordinator
context.coordinator.view = t
DispatchQueue.main.async {
t.window?.makeFirstResponder(t)
context.coordinator.connect()
}
return t
}
func updateNSView(_ nsView: TerminalView, context: Context) {}
static func dismantleNSView(_ nsView: TerminalView, coordinator: TerminalSession) {
coordinator.close()
}
}
@MainActor
final class TerminalSession: NSObject, TerminalViewDelegate {
let server: Server
let taskId: String
let show: (String) -> Void
weak var view: TerminalView?
private var ws: URLSessionWebSocketTask?
init(server: Server, taskId: String, show: @escaping (String) -> Void) {
self.server = server
self.taskId = taskId
self.show = show
}
func connect() {
guard let view else {
return
}
let t = view.terminalDimensions
guard let http = try? server.url("/api/tasks/\(taskId)/terminal", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))]), var c = URLComponents(url: http, resolvingAgainstBaseURL: false) else {
show("not connected")
return
}
c.scheme = "wss"
guard let wss = c.url else {
show("bad terminal URL")
return
}
let task = URLSession.shared.webSocketTask(with: wss)
ws = task
show("connecting…")
task.resume()
receive(task)
}
func close() {
ws?.cancel(with: .normalClosure, reason: nil)
ws = nil
}
private func receive(_ task: URLSessionWebSocketTask) {
task.receive { result in
Task { @MainActor in
guard self.ws === task else {
return
}
switch result {
case .success(.data(let d)):
self.show("")
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(let error):
let code = task.closeCode.rawValue
let reason = task.closeReason.map { String(decoding: $0, as: UTF8.self) } ?? ""
if code == exitedCode {
self.show("shell ended: \(reason)")
} else if code == internalErrorCode {
self.show(reason)
} else {
self.show("disconnected (\(code) \(error.localizedDescription))")
}
self.ws = nil
}
}
}
}
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) {}
}
+282
View File
@@ -0,0 +1,282 @@
import SwiftUI
enum JSON: Decodable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSON])
case array([JSON])
case null
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() {
self = .null
} else if let v = try? c.decode(Bool.self) {
self = .bool(v)
} else if let v = try? c.decode(Double.self) {
self = .number(v)
} else if let v = try? c.decode(String.self) {
self = .string(v)
} else if let v = try? c.decode([JSON].self) {
self = .array(v)
} else {
self = .object(try c.decode([String: JSON].self))
}
}
subscript(key: String) -> JSON? {
if case .object(let o) = self {
return o[key]
}
return nil
}
var string: String? {
if case .string(let s) = self {
return s
}
return nil
}
}
struct Event: Decodable {
let seq: Int64
let kind: String
let payload: JSON
}
struct Row: Identifiable {
let id: Int64
let kind: String
var text: String
var tools: [String] = []
}
func buildRows(_ events: [Event]) -> [Row] {
var rows: [Row] = []
for e in events {
switch e.kind {
case "user.message", "agent.message", "agent.thinking":
rows.append(Row(id: e.seq, kind: e.kind, text: e.payload["text"]?.string ?? ""))
case "agent.tool_call":
let name = e.payload["name"]?.string ?? "?"
let input = e.payload["input"]
let detail = input?["file_path"]?.string ?? input?["command"]?.string ?? input?["pattern"]?.string ?? ""
let line = "\(name) \(detail)"
if rows.last?.kind == "tools" {
rows[rows.count - 1].tools.append(line)
} else {
rows.append(Row(id: e.seq, kind: "tools", text: "", tools: [line]))
}
default:
continue
}
}
return rows
}
func toolSummary(_ tools: [String]) -> String {
var order: [String] = []
var counts: [String: Int] = [:]
for t in tools {
let name = String(t.split(separator: " ").first ?? "")
if counts[name] == nil {
order.append(name)
}
counts[name, default: 0] += 1
}
return order.map { counts[$0]! > 1 ? "\($0) ×\(counts[$0]!)" : $0 }.joined(separator: ", ")
}
struct RowView: View {
let row: Row
@ViewState private var open = false
var body: some View {
switch row.kind {
case "tools":
VStack(alignment: .leading, spacing: 2) {
Button((open ? "▾ " : "▸ ") + toolSummary(row.tools)) {
open.toggle()
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
if open {
ForEach(Array(row.tools.enumerated()), id: \.offset) { _, t in
Text(t)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
case "user.message":
Text(markdown(row.text))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.12))
case "agent.thinking":
Text(row.text)
.italic()
.foregroundStyle(.secondary)
.textSelection(.enabled)
default:
Text(markdown(row.text))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func markdown(_ s: String) -> AttributedString {
(try? AttributedString(markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(s)
}
}
enum Container: String, CaseIterable {
case lazyVStack = "ScrollView + LazyVStack"
case list = "List"
}
struct TranscriptSpike: View {
@Environment(Server.self) private var server
@ViewState private var all: [Row] = []
@ViewState private var shown = 0
@ViewState private var container = Container.lazyVStack
@ViewState private var playing = false
@ViewState private var streaming = false
@ViewState private var pinned = true
@ViewState private var distance: CGFloat = 0
@ViewState private var tick = 0
@ViewState private var position = ScrollPosition(edge: .bottom)
@ViewState private var events = 0
@ViewState private var loadTime = ""
private var rows: ArraySlice<Row> {
all.prefix(shown)
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Picker("", selection: $container) {
ForEach(Container.allCases, id: \.self) { c in
Text(c.rawValue).tag(c)
}
}
.pickerStyle(.segmented)
.frame(width: 320)
Toggle("Play held-back rows", isOn: $playing)
Toggle("Stream a message", isOn: $streaming)
Text("\(events) events, \(shown)/\(all.count) rows \(loadTime) · \(pinned ? "stuck to bottom" : "scrolled up") · \(Int(distance)) pt from bottom")
.foregroundStyle(.secondary)
}
switch container {
case .lazyVStack:
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(rows) { r in
RowView(row: r)
}
}
.scrollTargetLayout()
.padding(.horizontal, 8)
}
.scrollPosition($position)
.defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: CGFloat.self) { g in
g.contentSize.height - g.visibleRect.maxY
} action: { _, d in
distance = d
pinned = d <= 80
}
.onChange(of: tick) {
if pinned {
position.scrollTo(edge: .bottom)
}
}
case .list:
ScrollViewReader { proxy in
List(rows) { r in
RowView(row: r)
}
.onScrollGeometryChange(for: CGFloat.self) { g in
g.contentSize.height - g.visibleRect.maxY
} action: { _, d in
distance = d
pinned = d <= 80
}
.onChange(of: tick) {
if pinned, let last = rows.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
.onAppear {
if let last = rows.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
}
.task {
await load()
}
.task(id: playing) {
while playing && shown < all.count {
shown += 1
tick += 1
try? await Task.sleep(for: .milliseconds(150))
}
}
.task(id: streaming) {
await stream()
}
}
private func load() async {
guard !server.taskId.isEmpty else {
return
}
let start = Date()
var got: [Event] = []
var after: Int64 = 0
do {
while true {
let page = try await server.get([Event].self, "/api/tasks/\(server.taskId)/events", [URLQueryItem(name: "after", value: String(after)), URLQueryItem(name: "limit", value: "1000")])
got += page
if page.count < 1000 {
break
}
after = page.last!.seq
}
} catch {
server.log("transcript: \(error)")
return
}
events = got.count
all = buildRows(got)
shown = all.count > 200 ? all.count - 100 : all.count
loadTime = String(format: "in %.2fs", Date().timeIntervalSince(start))
tick += 1
}
private func stream() async {
guard streaming, let source = all.filter({ $0.kind == "agent.message" }).max(by: { $0.text.count < $1.text.count }) else {
return
}
let id = (all.last?.id ?? 0) + 1_000_000
all.insert(Row(id: id, kind: "agent.message", text: ""), at: shown)
shown += 1
let index = shown - 1
for word in source.text.split(separator: " ", omittingEmptySubsequences: false) {
if !streaming {
break
}
all[index].text += (all[index].text.isEmpty ? "" : " ") + word
tick += 1
try? await Task.sleep(for: .milliseconds(30))
}
streaming = false
}
}
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
mac=$(cd "$(dirname "$0")" && pwd)
root=$(dirname "$mac")
build=$mac/.build
app=$build/byre.app
swift build --package-path "$mac" --configuration release
bin=$(swift build --package-path "$mac" --configuration release --show-bin-path)
rm -rf "$app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources/fonts"
cp "$bin/Byre" "$app/Contents/MacOS/Byre"
cp "$mac/Info.plist" "$app/Contents/Info.plist"
cp "$root"/web/fonts/*.woff2 "$app/Contents/Resources/fonts/"
iconset=$build/byre.iconset
rm -rf "$iconset"
mkdir -p "$iconset"
for size in 16 32 128 256 512; do
rsvg-convert --width $size --height $size --output "$iconset/icon_${size}x${size}.png" "$root/web/favicon-tile.svg"
rsvg-convert --width $((size * 2)) --height $((size * 2)) --output "$iconset/icon_${size}x${size}@2x.png" "$root/web/favicon-tile.svg"
done
iconutil --convert icns --output "$app/Contents/Resources/byre.icns" "$iconset"
codesign --force --sign - "$app"
echo "$app"