diff --git a/.gitignore b/.gitignore index 25f9eed..c567cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /internal/claude/testdata/transcripts/ /internal/codex/testdata/transcripts/ /.preview/ +/mac/.build/ diff --git a/TODO.md b/TODO.md index d1d7c70..8adda0d 100644 --- a/TODO.md +++ b/TODO.md @@ -16,8 +16,6 @@ # M8 — macOS client -- macOS client spike - See `docs/macos.md`. SwiftUI package under `mac/` built with `swift build` and bundled by script, pointed at a development byre (never the service). Settle each row of "Gaps in SwiftUI": composer Enter/Shift-Enter/IME, image paste and drop, a long real transcript scrolling and staying stuck to the bottom, cross-message selection, SwiftTerm terminal with Ctrl keys, Core Text loading `web/fonts/`, SVG images. Record which fallbacks were needed. - macOS client store and API Generated client from `api/openapi.yaml`, hand-written SSE client, `Store` and `Feed` ported from `state.ts`, startup sequence, connection screen with 401/403/TLS/unreachable errors and Change Server…, `Palette.swift` generated from `web/style.css`. - macOS client panes and transcript @@ -27,4 +25,4 @@ - macOS client overlays, menus and notifications Switcher, keyboard help, alert and confirm overlays, Merge and Discard dialogs, Go menu shortcuts with the Ctrl variants and terminal passthrough, UNUserNotificationCenter notifications. - macOS client parity review - Side by side with the web client on the same server, window size and appearance, using disposable tasks: every tab, switcher, Inbox choice types, merge/discard dialogs, light/dark, narrow windows, IME. Document build, install and storage in `docs/operations.md`. + Side by side with the web client on the same server, window size and appearance, using disposable tasks: every tab, switcher, Inbox choice types, merge/discard dialogs, light/dark, narrow windows. Document build, install and storage in `docs/operations.md`. diff --git a/docs/macos.md b/docs/macos.md index 0403a22..c09e2d0 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -1,6 +1,6 @@ # byre for macOS -This is a design. Nothing in it is implemented: there is no macOS client. The work to build it is under "M8 — macOS client" in `TODO.md`. +This is a design. What exists so far is the spike under `mac/`: a harness window with one tab per row of "Gaps in SwiftUI" (composer, transcript, terminal, font, SVG), not the client. The work to build the client is under "M8 — macOS client" in `TODO.md`. ## Decision @@ -13,12 +13,14 @@ The first version copies the web client: the same two equal panes, tabs, labels, ## Stack - Swift 6, SwiftUI, Observation (`@Observable`). Minimum macOS is the major version byre's host runs (`sw_vers --productVersion` prints it); there is no back-deployment. -- AppKit only through `NSViewRepresentable`, and only where the "Gaps in SwiftUI" table below says so. +- AppKit only through `NSViewRepresentable`, and only where the "Gaps in SwiftUI" table below says so: the terminal. - Foundation's `URLSession` for REST, the SSE streams and the terminal WebSocket. - `UserNotifications` for notifications. - Third-party code: Apple's `swift-openapi-generator` (with `swift-openapi-runtime` and `swift-openapi-urlsession`) for the API types and client, and SwiftTerm for the terminal. -The host has the Command Line Tools and no Xcode (`xcode-select --print-path` prints which), so the project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. A script in `mac/` assembles `byre.app` (executable, `Info.plist` with a fixed bundle identifier, the font and the icon) and signs it ad hoc; notification permission needs the bundle identifier. +The host has the Command Line Tools and no Xcode (`xcode-select --print-path` prints which), so the project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. `mac/bundle.sh` builds the release configuration and assembles `mac/.build/byre.app` (the executable, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac`, the `web/fonts/` files and an `.icns` rendered from `web/favicon-tile.svg`) and signs it ad hoc; notification permission needs the bundle identifier. The app keeps its server origin in the `byre.server` default (`defaults read run.fc.byre.mac byre.server` prints it). + +SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@Animatable` and the rest as macros implemented in the `SwiftUIMacros` plugin, which ships with Xcode and not with the Command Line Tools (`ls /Library/Developer/CommandLineTools/usr/lib/swift/host/plugins` lists the plugins there). `@State` is reached through its property-wrapper type instead, as `typealias ViewState = State` and `@ViewState var …`; other SwiftUI macros aren't used. `@Observable` and `@Bindable` (Observation's plugin, which the Command Line Tools have), `@Environment` and `@FocusState` work as usual. ## One source for shared values @@ -28,7 +30,7 @@ Values the web client defines are generated into Swift, never copied by hand, so | --- | --- | --- | | API types and client | `api/openapi.yaml` | Generated at build time by the `swift-openapi-generator` plugin from a symlink to the spec | | Palette, light and dark | Custom properties at the top of `web/style.css` (`:root` and its `prefers-color-scheme: dark` block) | `go generate ./mac` writes `Palette.swift`: one dynamic `Color` per property, resolving light or dark with the window's appearance | -| Font | `web/fonts/` | The build script copies the files into the bundle and the app registers them with Core Text at launch | +| Font | `web/fonts/` | The build script copies the woff2 files into the bundle; the app loads each with `CTFontManagerCreateFontDescriptorsFromURL` (see "Gaps in SwiftUI") | | App icon | `web/favicon-tile.svg` | The build script renders it to an `.icns` with `rsvg-convert` and `iconutil` | Behaviour constants (the model list in `web/src/task.ts`, reconnect and refresh timings in `api.ts`, `changes.ts` and `files.ts`, the image limits in `task.ts`) live in TypeScript, where nothing can generate them. The Swift port keeps each in one place, named as in the TypeScript, and a change to one in `web/` needs the same change in `mac/`. @@ -87,7 +89,7 @@ Closing the window leaves the app running with its store and streams, so notific Each view does what its TypeScript counterpart does, with the same text, refresh triggers and empty states. - **Transcript and Lead.** `TranscriptBuilder` ports `appendEvents` and `renderEvent` from `task.ts` as a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, and notes for each event kind the web client draws. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a `LazyVStack`, stuck to the bottom unless the user has scrolled more than 80 points up, with pending sends shown at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill), Sync from main, Merge and Discard, with the same enabling rules and tooltips. -- **Composer.** Text, the model menu (filtered by vendor as in `modelSelect`), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images up to the web client's limit become thumbnails with ×, uploaded to `POST /api/blobs` on send. A failed send puts the text and images back and shows the error. +- **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered by vendor as in `modelSelect`), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images up to the web client's limit become thumbnails with ×, uploaded to `POST /api/blobs` on send. A failed send puts the text and images back and shows the error. - **Inbox.** The selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error as in the browser. The Changes summary line sits above it. - **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Ports of their files, including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - **Terminal.** A terminal view attached to `GET /api/tasks/{taskId}/terminal` with `URLSessionWebSocketTask`, following `terminal.ts`: binary input and output, `TerminalSize` JSON on resize, close code 4000 for `shell ended: ` with New shell, 1011 for a start failure, reconnect after the same delay while the task is active, one socket per open tab and selected task. Colours come from the palette as in the web client. Ctrl combinations typed in it go to the shell. @@ -138,7 +140,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Keyboard and menus -The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has the standard items; a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands do nothing while an overlay is open, as `bindShortcuts` ignores them while a dialog is open. The Ctrl variants the web client also accepts work the same way through a local key monitor, except while the terminal has focus, where Ctrl combinations go to the shell. Escape closes the open overlay. +The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands do nothing while an overlay is open, as `bindShortcuts` ignores them while a dialog is open. The Ctrl variants the web client also accepts work the same way through a local key monitor, except while the terminal has focus, where Ctrl combinations go to the shell. Escape closes the open overlay. ## Connection @@ -146,17 +148,19 @@ On first launch the window shows one field, Server URL (an `https` origin with n ## Gaps in SwiftUI -Places where SwiftUI alone may not match the web client. Each is checked in the spike before the rest is built, and the fallback is used only if the check fails: +Places where SwiftUI alone may not match the web client. Each is checked in the spike before the rest is built, and the fallback is used only if the check fails. The spike's tabs put the SwiftUI way and the fallback side by side and log each key press, paste, drop and upload they see: -| Need | SwiftUI | Fallback | -| --- | --- | --- | -| Enter sends, Shift-Enter newline, nothing during IME composition | `TextEditor` with `onKeyPress` | `NSTextView` in an `NSViewRepresentable`, checking `hasMarkedText()` | -| Image paste and drop into the composer | `onPasteCommand` and `onDrop` | The same `NSTextView`, overriding `paste(_:)` and drag handling | -| Transcript of a long task (thousands of events) scrolling smoothly and staying stuck to the bottom | `LazyVStack` in a `ScrollView` | `List` with its own row reuse | -| Selecting text across several messages | `textSelection(.enabled)` selects within one `Text` only | None found in SwiftUI; listed as a difference below if the spike confirms it | -| The terminal | No terminal view in SwiftUI | SwiftTerm's AppKit `TerminalView` in an `NSViewRepresentable` | -| Font from `web/fonts/` | Core Text registration | Convert the files to TTF in the build script if Core Text refuses them | -| SVG images from the worktree image endpoint | `NSImage(data:)` | Show the "image not available" text | +| Need | SwiftUI | Fallback | Spike result | +| --- | --- | --- | --- | +| Enter sends, Shift-Enter newline, nothing during IME composition | `TextEditor` with `onKeyPress` | `NSTextView` in an `NSViewRepresentable`, sending from `doCommand(by: insertNewline:)`, which the input method never reaches while it holds marked text | No fallback: `onKeyPress(.return)` runs before the `TextEditor`'s `NSTextView` and its input context. It sends, and returns `.ignored` for Shift so the text view adds the newline (checked by hand). Because it runs first, it must also return `.ignored` while the focused text view's `hasMarkedText()` is true, or Return sends the draft mid-composition. An in-process test that set marked text with `setMarkedText` and delivered a Return `NSEvent` to the window showed both: an unguarded handler sent the draft with the marked text still pending, and the guarded one sent nothing and passed Return on to the text view. From there the input method handles Return as it does in any `NSTextView`, so the `NSTextView` fallback would behave the same. The guard reads the key window's first responder, which is the composer while the user types | +| Image paste and drop into the composer | `onPasteCommand` and `onDrop` | The same `NSTextView`, overriding `paste(_:)` and drag handling | No fallback, but not through these two modifiers: `TextEditor`'s own text view takes ⌘V and drops before `onPasteCommand` and `onDrop` and inserts the file's path as text. Instead the app replaces Edit's pasteboard group (`CommandGroup(replacing: .pasteboard)` with Cut, Copy, Paste and Select All sent to the first responder): Paste attaches the clipboard's image when the composer has focus (`focusedValue`) and otherwise sends the ordinary `paste:`, so text paste works in every field and the terminal. Drops land on a `Color.clear` overlay on the `TextEditor` with `onDrop` and `allowsHitTesting(false)`: it still receives the drag, and clicks and typing pass through to the editor (with hit testing on, the editor can't be clicked). Both attach thumbnails and upload to `POST /api/blobs` | +| Transcript of a long task (thousands of events) scrolling smoothly and staying stuck to the bottom | `LazyVStack` in a `ScrollView` | `List` with its own row reuse | No fallback: on a 5258-event task, `LazyVStack` scrolls smoothly, stays at the bottom while rows arrive and a message streams (`ScrollPosition.scrollTo(edge: .bottom)` on each change while within 80 points of the bottom, tracked with `onScrollGeometryChange`), and stays put when scrolled up. `List` passed too | +| Selecting text across several messages | `textSelection(.enabled)` selects within one `Text` only | None found in SwiftUI; listed as a difference below if the spike confirms it | Confirmed: a drag stops at the edge of one message. Listed under "Differences from the web client" | +| The terminal | No terminal view in SwiftUI | SwiftTerm's AppKit `TerminalView` in an `NSViewRepresentable` | Fallback used, as planned: SwiftTerm over `URLSessionWebSocketTask` gives a working shell; Ctrl-C, Ctrl-L, Ctrl-K and Ctrl-R reach it. A local `keyDown` monitor takes Ctrl-K/P/L/S// as Go shortcuts except while the first responder is the `TerminalView` | +| Font from `web/fonts/` | Core Text | Convert the files to TTF in the build script if Core Text refuses them | No fallback: Core Text reads the woff2 files and their `wght` axis (200–800) | +| SVG images from the worktree image endpoint | `NSImage(data:)` | Show the "image not available" text | No fallback: `NSImage(data:)` decodes the SVG from the worktree image endpoint (`_NSSVGImageRep`) and draws it sharp at any size | + +The font files need one thing beyond loading. The latin and latin-ext files are subsets of one variable font with the same PostScript names, so registering both and looking the font up by name finds only the latin file, and characters such as `ő` and `Ł` fall back to a system font. The app instead makes a descriptor from each file's URL, sets the weight with `CTFontDescriptorCreateCopyWithVariation`, and puts the latin-ext descriptor in the latin one's `kCTFontCascadeListAttribute`; characters in neither file then fall back to the system cascade (`→` to Menlo). `Fonts.swift` in the spike does this. SwiftUI gets the result through `Font(CTFont)`. ## Differences from the web client @@ -169,6 +173,7 @@ Platform differences the copy can't avoid, each with what the web client does an | Clicking a transcript image | Opens the image in a new browser tab | Opens it in an in-window overlay | | Preview links (`expose_port`) | Open in a new browser tab | Open in the default browser | | Window frame | Browser chrome and tabs | One macOS window with a standard title bar | +| Selecting transcript text | A drag selects across any number of messages | A drag selects within one message; each message is copied on its own | ## Building and checking diff --git a/docs/operations.md b/docs/operations.md index 68ac9db..4abb9d8 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -13,7 +13,7 @@ - `internal/tailscale`: `tailscale serve` for task previews (see "Previews"). - `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows"). - `web/`: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). `web/src/*.ts` is the source; `web/src/api.gen.ts` holds types generated from `api/openapi.yaml`, and `web/src/api.ts` is the typed client over them. `go generate ./web` runs `npm ci`, regenerates the types and compiles `web/dist/`, which is committed so `go install` needs no Node. The binary embeds `index.html`, `style.css`, `favicon.svg` (a lead dot in the accent colour with spokes to five task-coloured dots; its colours are saturated versions of the `web/style.css` accent and task hues, not copies of the palette, since the palette's pastels blur together at 16px; they switch with the light/dark scheme), `favicon.png` (the same icon in its dark colours on an opaque dark `--surface` square at 180×180, rendered from `web/favicon-tile.svg`, which is not embedded; opaque because Orion puts a transparent icon on a white tile; linked as a second icon and as the `apple-touch-icon`, which Orion prefers over the SVG for its tab icon; Orion's icon store (`~/Library/Application Support/Orion/Defaults/website_icons`, kept apart from its cache) keys icons by hostname without the port, so another server on the same hostname can supply byre's tab icon; `favicon-tile.svg` repeats the shapes and dark colours of `favicon.svg`, so change both together, then regenerate the PNG with `rsvg-convert --width 180 --height 180 --output web/favicon.png web/favicon-tile.svg`), `dist/`, `src/` (for source maps), `fonts/` and `vendor/` (Mermaid, see "Inbox"; xterm.js, see "Terminals"). `byre serve --dev` serves `./web` from disk instead (run from the repo root); pair it with `npm --prefix web exec tsc -- --watch`. -- There is no macOS client yet; `docs/macos.md` is its design. +- There is no macOS client yet; `docs/macos.md` is its design. `mac/` holds the spike that checks the design's SwiftUI gaps: `mac/bundle.sh` builds `mac/.build/byre.app`, which connects to the byre `https` origin typed into it (the same `tailscale serve` URL a browser uses). ## Data diff --git a/mac/Info.plist b/mac/Info.plist new file mode 100644 index 0000000..b62f82b --- /dev/null +++ b/mac/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleIdentifier + run.fc.byre.mac + CFBundleName + byre + CFBundleExecutable + Byre + CFBundlePackageType + APPL + CFBundleIconFile + byre + CFBundleShortVersionString + 0 + CFBundleVersion + 0 + LSMinimumSystemVersion + 27.0 + NSHighResolutionCapable + + + diff --git a/mac/Package.resolved b/mac/Package.resolved new file mode 100644 index 0000000..5629100 --- /dev/null +++ b/mac/Package.resolved @@ -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 +} diff --git a/mac/Package.swift b/mac/Package.swift new file mode 100644 index 0000000..d3a282c --- /dev/null +++ b/mac/Package.swift @@ -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")] + ), + ] +) diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift new file mode 100644 index 0000000..215aafe --- /dev/null +++ b/mac/Sources/Byre/ByreApp.swift @@ -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 + } +} diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift new file mode 100644 index 0000000..c401fe6 --- /dev/null +++ b/mac/Sources/Byre/Composer.swift @@ -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) {} +} diff --git a/mac/Sources/Byre/FontAndSVG.swift b/mac/Sources/Byre/FontAndSVG.swift new file mode 100644 index 0000000..5951fac --- /dev/null +++ b/mac/Sources/Byre/FontAndSVG.swift @@ -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))" + } + } +} diff --git a/mac/Sources/Byre/Fonts.swift b/mac/Sources/Byre/Fonts.swift new file mode 100644 index 0000000..81c50de --- /dev/null +++ b/mac/Sources/Byre/Fonts.swift @@ -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)) + } +} diff --git a/mac/Sources/Byre/Server.swift b/mac/Sources/Byre/Server.swift new file mode 100644 index 0000000..1497422 --- /dev/null +++ b/mac/Sources/Byre/Server.swift @@ -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(_ 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 + } +} diff --git a/mac/Sources/Byre/SpikeView.swift b/mac/Sources/Byre/SpikeView.swift new file mode 100644 index 0000000..634c872 --- /dev/null +++ b/mac/Sources/Byre/SpikeView.swift @@ -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() + } + } + } +} diff --git a/mac/Sources/Byre/Terminal.swift b/mac/Sources/Byre/Terminal.swift new file mode 100644 index 0000000..42bbd95 --- /dev/null +++ b/mac/Sources/Byre/Terminal.swift @@ -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) { + 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) {} +} diff --git a/mac/Sources/Byre/Transcript.swift b/mac/Sources/Byre/Transcript.swift new file mode 100644 index 0000000..ce64c50 --- /dev/null +++ b/mac/Sources/Byre/Transcript.swift @@ -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 { + 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 + } +} diff --git a/mac/bundle.sh b/mac/bundle.sh new file mode 100755 index 0000000..69c27c5 --- /dev/null +++ b/mac/bundle.sh @@ -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"