# byre for macOS This is a design, partly built. What exists under `mac/`: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, the generated palette, the window with its two panes and tab strips (More included), the Lead and task transcripts through `TranscriptBuilder`, the composer with drafts and images, the Markdown port, native flowchart diagrams, pane-focus reporting, the identity banner, every other tab (Changes, Files, Terminal, Inbox, Reviews, Workflow with its stage graph drawn natively, Project, Status and Settings), every overlay (the switcher, keyboard shortcuts, alerts, confirmations, the Merge and Discard dialogs, the More menu and the full-size image view), the Go menu with its Ctrl variants, and notifications. The rest of the work is under "M8 — macOS client" in `TODO.md`. ## Decision The app is a native SwiftUI client. It talks to the same server and API as the web client in `web/`, and reproduces that client's layout and behaviour in SwiftUI views. **No web view, under any circumstances**: no `WKWebView`, no `WebView`, no HTML rendering of any kind, in the window, in a dialog, for Markdown, for diagrams, for the terminal or for previews. A link to a web page opens in the default browser. The server doesn't change. The app connects to byre's `tailscale serve` HTTPS URL, the same one a browser uses, so its requests get the same Tailscale identity headers (see "Identity" in `docs/operations.md`). It never sends identity headers itself, and never shows a login. The first version copies the web client: the same two equal panes, tabs, labels, empty states, task colours, palette, font, keyboard shortcuts and confirmations, driven by the same events. Where the platform forces a difference, it is listed under "Differences from the web client". The app adds nothing the web client lacks. ## 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: 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. `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 Values the web client defines are generated into Swift, never copied by hand, so a change in `web/` or the spec reaches the app on its next build: | Value | Source | Swift | | --- | --- | --- | | API types and client | `api/openapi.yaml` | Generated at build time by the `swift-openapi-generator` plugin (`mac/Sources/Byre/openapi-generator-config.yaml`, idiomatic naming: `display_name` is `displayName`) from the symlink `mac/Sources/Byre/openapi.yaml` to the spec. The output lands in `mac/.build/plugins/outputs/`, not in git | | Palette, light and dark | Custom properties at the top of `web/style.css` (`:root` and its `prefers-color-scheme: dark` block) | `go generate ./mac` (`mac/palette`) writes `Palette.swift`: one dynamic `Color` per hex property, `--accent-tint` as `Palette.accentTint`, resolving light or dark with the window's appearance. Properties that aren't a hex colour (`--task`, `--shadow`, `--font`) are left out. The file is committed, so `swift build` needs no Go; a property missing from either block fails the generator | | 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/`. ## Shape of the code The Swift files follow the TypeScript ones, so a change in one points at the same place in the other: | `web/src/` | `mac/Sources/Byre/` | What it is | | --- | --- | --- | | `api.ts` | `API.swift`, `EventStream.swift` | The generated client over `URLSession`; the SSE client | | `state.ts` | `Store.swift`, `Feed.swift` | The one `@Observable` store, `handleEvent`, per-task feeds, focus | | `app.ts` | `ByreApp.swift`, `Startup.swift` | The `App`, window, menus, startup sequence, identity banner | | `panes.ts` | `Panes.swift`, `PaneFocus.swift` | The two panes, tab strips, More menu, pane-focus reporting | | `task.ts`, `lead.ts` | `Chat.swift`, `TranscriptBuilder.swift`, `Composer.swift`, `MergeDialog.swift` | Transcript and composer for tasks and the lead | | `markdown.ts` | `Markdown.swift`, `MarkdownView.swift` | A line-for-line port of the parser to a block tree, and its views | | `mermaid.ts` | `Diagram.swift` | Native flowchart parser, layout and drawing for Mermaid blocks and inbox `diagram` attachments | | `inbox.ts` | `Inbox.swift` | One item at a time, choices, Other, Let the agent decide | | `changes.ts` | `Changes.swift` | Since main, This turn, pinned snapshots, per-file diffs | | `files.ts` | `Files.swift` | Read-only worktree browser | | `terminal.ts` | `Terminal.swift` | The terminal view and its WebSocket | | `reviews.ts` | `Reviews.swift` | Review cards, finding diffs, Fix and Ignore | | `workflow.ts` | `Workflow.swift` | Stage graph and attempts | | `project.ts`, `todolink.ts` | `Project.swift`, `TodoLink.swift` | Project tab and TODO links | | `status.ts`, `settings.ts` | `Status.swift`, `Settings.swift` | Left-pane tabs | | `switcher.ts` | `Switcher.swift` | ⌘K overlay, the shortcuts help and the Go actions (`runGo`) | | `drafts.ts` | `Drafts.swift` | Composer and answer drafts | | `notifications.ts` | `Notifications.swift` | `UNUserNotificationCenter` delivery | | `dom.ts` | `Dialogs.swift`, `Format.swift` | Overlays (alerts, modals with `showConfirm` and `showChoice`, the switcher's slot, the More menu, the image view) and the window's key monitor, `ago`, Copy, shared button and layout styles, diff lines | | `images.ts` | `Images.swift` | Blob, worktree and `data:` images, their cache and the full-size overlay | ## Store and events `Store` holds what `state` in `web/src/state.ts` holds (me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads, connection) and ports its functions one for one: `handleEvent` with the same `switch` on event kinds, `taskState` with the same precedence and labels, `setFocus` with the optimistic apply and the latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`. `Feed` ports the class of the same name: history paged from `GET /api/tasks/{taskId}/events` 1000 at a time, live events held back until the history is in, duplicates dropped by `seq`, unstored `agent.delta` events passed straight to the streaming view only while the task is selected, and events that arrive together delivered as one batch. The lead's feed starts from the last 50 turns of `GET /api/me/lead/events` and pages back with `loadOlderLead`. The web client's topics become Observation: views read `Store` and `Feed` properties and SwiftUI re-renders them on change. The topics that carry a value are `Listeners` instead, whose `add` returns the function that removes the listener: `Store.events` (every event, the `event` topic), `Feed.appended` (each batch of new stored events, `transcript` and `lead.transcript`) and `Feed.streamed` (each `agent.delta`, `stream` and `lead.stream`). Applying focus selects its task, which is what the web client's hash route does. API calls go through the generated `Client` over `URLSessionTransport`. A middleware turns any response of 400 or more into `ApiError(status, message)`, the message being the body's `Error.message` for JSON and ` : ` otherwise, as `request` in `api.ts` does; the generated client wraps it in `ClientError`, and `errorText` unwraps it. Dates go through one transcoder, ISO 8601 with optional fractional seconds, which reads Go's RFC 3339 times with any number of fraction digits or none. `EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles `id`, `event: ping`, `event: ready` and `data`, and follows `subscribe` in `api.ts`: it tracks the last seq, reconnects from it after the same stale threshold (40 s, checked every 5 s) and retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. As in the web client there are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot. Startup follows `main` in `app.ts`: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: `, as the web page does; Change Server… leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`, as `state.ts` does. The app logs through `os.Logger` with subsystem `run.fc.byre.mac`, as the web client logs to the console: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. There is no URL hash. The web client mirrors focus into `#/task/`; the app keeps focus in `Store` only, and in-app task links call `focusTask` directly. Focus stays shared through the server, so the app and a browser follow each other as two browser tabs do. ## Window and layout One window. The panes follow the "Web UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. The title bar is the standard one; no byre controls go in it or in a toolbar. - **Left pane:** tabs Lead, Status, Settings, with "reconnecting…", the connection dot and the user's name at the end of the strip. - **Right pane:** the task bar (task picker ` ▸ ▾` and the preview links), then the task's tabs in the server's focus order, hiding Inbox, Reviews and Workflow when they have nothing, with a **More ▾** menu for tabs that don't fit, carrying their badges, as in `taskPane`. The pane's accent is the selected task's colour. Overlays (the switcher, alerts, confirmations, the merge dialog, keyboard help) are SwiftUI views drawn above the panes inside the window, positioned as in the web client (the switcher and the Merge and Discard confirmations just below their anchor), not system sheets or alerts. Overlays live in `Overlays` (`Dialogs.swift`), one environment object for the window. An open overlay takes Escape (closing the topmost: alert, modal, image, switcher step or switcher, More menu) and Enter (the alert's OK, the modal's last button when its `canSubmit` holds, the switcher's highlighted row) through a local key monitor, which passes Enter on while an input method holds marked text. The switcher also takes ↑ and ↓ there. The left pane's selected tab lives in `Overlays` too, so ⌘L can switch to Lead and focus its composer. Closing the window leaves the app running with its store and streams (`applicationShouldTerminateAfterLastWindowClosed` returns false), so notifications keep coming; clicking the Dock icon shows the window again. Quit flushes drafts and reports pane focus false before exiting. ## Views 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.** 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: <status>` 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. - **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: the same ordering (tasks with inbox items first, then least recently visited, the selected task last), the `name: title` project prefix, New task, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Inbox when it has items, its unread summary when it has one, and its Transcript otherwise. - **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end, and labelled and disabled as in `taskHead`. `confirmMerge` (`MergeDialog.swift`) is ported whole: the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Inbox tab, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It sits just below the Merge button, or centred when ⌘S opens it with the button off screen. Discard confirms below its button, then clears the focused task. ## Markdown `Markdown.swift` ports `markdown.ts` line for line into a block tree (paragraph, heading, list, choice list, choice grid, quote, rule, table, code, diff, details, diagram, image), because byre's dialect (choice markers, `<details>` sections, `#/task/`, `#/todo/` and `#/suggest/` links, worktree image paths, `NO_REPLY`) matches no library. Inline content becomes an `AttributedString` with code, strong, emphasis and links. Links: `http` and `https` open in the default browser; `#/task/<id>` focuses the task; `#/todo/<project>/<title>` becomes the TODO link with its start and discuss actions or the task's state; `#/suggest/<project>/<title>/<brief>` becomes the same, its start creating the task with the brief and its discuss messaging the lead; anything else is plain text. ## Diagrams Mermaid needs a browser DOM, so the app doesn't use it. `Diagram.swift` draws a ```` ```mermaid ```` block or an inbox `diagram` attachment itself when the source is a flowchart, the only kind agents have written so far. It parses: - the header `flowchart` or `graph` with `LR`, `RL`, `TD`/`TB` or `BT`; - nodes `A`, `A[box]`, `A(rounded)`, `A{diamond}` and `A((circle))`, with `"quoted"` labels; - edges `-->`, `---`, `-.->` and `==>`, chained (`A --> B --> C`), with labels written `-- text -->` or `-->|text|`; - `classDef`, `class` and `:::` for fill, stroke and text colour. It lays the nodes out in layers along the header's direction, orders each layer to reduce crossings, and draws with SwiftUI `Canvas`, using the palette roles the web client gives Mermaid in `mermaid.ts`. Any other diagram type, or a flowchart line it can't parse, shows the source with `diagram not rendered: <reason>` under it, the web client's own fallback for source Mermaid rejects. The layout isn't Mermaid's pixel for pixel. The Workflow tab's stage graph uses the same layout and drawing code, fed from the `WorkflowRun` rather than from Mermaid text. For example, this source from an inbox question: ```text flowchart LR A[ask_human] --> B{blocking?} B -- yes --> C[end turn] B -- no --> D[keep working] ``` draws a box `ask_human`, an arrow to a diamond `blocking?`, and from it an arrow labelled "yes" to a box `end turn` and one labelled "no" to a box `keep working`, in three layers from left to right. ## Images Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/image?path=` for worktree paths and `file://` URLs, `data:image/` URLs decoded in place, other schemes shown as a link with the alt text and never fetched, and "image not available: <src>" on failure. Clicking an image opens it full size in an in-window overlay (a native image view, not a browser). ## Notifications `Notifications.swift` ports `notifications.ts` onto `UNUserNotificationCenter`: the same preferences from `GET /api/me`, the same events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, and the item id or `turn-<seq>` as the request identifier, so a repeat replaces the earlier one as the browser's `tag` does. They show as banners even while byre is the active app, as browser notifications do over the page (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, shows the window (reopening it if it was closed) and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its task until startup has a store, then focuses it. Settings shows the authorization state and a button that requests it, in place of the browser permission flow. As in the browser, nothing notifies while the app isn't running. ## Pane focus `PaneFocus.swift` ports `trackPaneFocus`: it sends `PUT /api/me/pane-focus` with `focused` (the window is key, the app is active and the keyboard focus is inside the right pane) and `drafting` (any right-pane text field other than the terminal is non-empty) whenever either changes, and again when the global stream reconnects. Deactivating the app, closing the window or quitting sends `focused: false`. ## Drafts `Drafts.swift` ports `drafts.ts` with `UserDefaults` in place of `localStorage`. `localStorage` is per origin, so the key adds the server origin, normalized to lower-case scheme and host with the port always written, in front of the web client's key: `byre.drafts.<origin>.<login>`, for example `byre.drafts.https://byre.example.ts.net:443.you@example.com`. Change Server… leaves the old server's drafts in place, and switching back finds them. The structure is the web client's: composer text by `message:<task id>` or `message:lead`, in-flight sends folded back in, checked choices and Other text by inbox item, pruned when a task closes or an item resolves, saved 400 ms after a change and at once on blur, send and quit. Pasted images aren't kept, as in the browser; after a relaunch the composer says how many need re-attaching. ## Keyboard and menus 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 are disabled while an overlay is open, as `bindShortcuts` ignores them while a dialog is open. The Ctrl variants the web client also accepts (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. ## Connection On first launch the window shows one field, Server URL (an `https` origin with no path), and Connect. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is kept in the `byre.server` default and startup runs; later launches start against it directly. Change Server… (in the app menu) flushes drafts, reports pane focus false, drops the store and streams, and shows the connection screen again, its field holding the current origin. Quitting does the same flush and report, waiting up to 2 s for the report before exiting (`applicationShouldTerminate` in `ByreApp.swift`). ## Gaps in SwiftUI Places where SwiftUI alone may not match the web client, each checked side by side against its fallback; the fallback is used only where the check failed: | 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` does this. SwiftUI gets the result through `Font(CTFont)`. ## Differences from the web client Platform differences the copy can't avoid, each with what the web client does and what the app does: | Where | Web client | App | | --- | --- | --- | | Settings, notifications line | `This browser shows notifications while a byre page is open.` | `This Mac shows notifications while byre is running.` | | Settings, notifications not yet allowed | `This browser doesn't show notifications.` and a **Turn on for this browser** button | `This Mac doesn't show notifications from byre.` and a **Turn on for this Mac** button, which asks macOS for permission | | Settings, notifications refused | `Notifications are blocked for this site in the browser's settings.` | `Notifications are blocked for byre in System Settings.` | | Opening a task by address | `#/task/<id>` in the URL bar | None; tasks open from the switcher, links and notifications | | 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 `swift build --package-path mac` is the compile check for changes under `mac/`, alongside the checks `AGENTS.md` already names. No tests are added. Installing the app is a separate action from deploying the byre service. ## Not in version one - Anything the web client doesn't do: a Dock badge, a `byre:` URL scheme, Quick Look, a closed-task browser, editing files. - Several servers or windows. - Automatic updates.