# AGENTS.md Prompt is a native macOS app written in Swift/SwiftUI. Single executable SwiftPM package, no external dependencies (all Apple frameworks). It is a Copper-style (shadcn) capture panel: prompts and photos live in sections, clicking an item copies it back to the clipboard. ## Architecture - `Sources/Prompt/App.swift` — `@main` entry using a bare `Settings { EmptyView() }` scene; the app owns its `NSStatusItem` manually (same reason as SimpleTopBar: placement control on notched Macs, seeded via `NSStatusItem Preferred Position Prompt`). Left click toggles the panel, right click shows the menu (accessibility state, launch at login, open data folder, quit). Contains the single-instance guard and `LoginItem` (`SMAppService`): the app auto-registers as a login item on the first bundled launch (once, tracked by `loginItem.autoRegistered` in UserDefaults) so it opens on startup by default; the Launch at Login toggle in the sync panel (the app's only settings surface) is the opt-out. Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit (Cmd+Q) — an accessory app shows no menu bar, but the main menu still services key equivalents, which is the only way to quit from the keyboard while the panel has focus. `applicationShouldHandleReopen` shows the panel so launching an already-running app does something. Supports `--sync-check` and `--diag` (writes the status item's frame and permission state to `/tmp/prompt-diag.txt`, then exits). - `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton. Data model: `Section` → `[PromptItem]`, item content is `.text(String)` or `.image(String)` (filename). Persists to `~/Library/Application Support/Prompt/store.json`; images are PNG files in `Images/` next to it, cached in an `NSCache`. Owns all mutations (add, toggle done, edit, delete, move, merge, clear done) and clipboard writes (`copy`, `copyAsList` for numbered output, `copyAsLines` for plain newlines; both text modes skip image items). Batch mutations (`delete(ids:)`, `setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one action applies to every selected item and image files are cleaned up once. Saves on every mutation. - `Sources/Prompt/Theme.swift` — the panel's appearance-aware colours. Dark mode is pure black rather than the system window grey (the panel is a full-height strip beside your work, where grey reads as a half-lit window); light mode keeps the system window colour. `Theme.background` is also handed to the `NSPanel` so the strip under the transparent titlebar matches, and the row colour stays slightly lighter than black so cards remain visible. - `Sources/Prompt/ComposerView.swift` — the chat-style composer (`NSViewRepresentable` around an `NSTextView`): wraps long prompts, grows to fit up to seven lines, then scrolls. Return submits, Shift+Return inserts a newline, Esc forwards to the panel (clear selection, else hide). Height is measured from the text and pushed into a `height` binding the panel applies as a frame — a representable cannot resize itself through `intrinsicContentSize` once SwiftUI offers a larger size. The vertical scroller is disabled until the text is taller than the max height, so it never flashes during growth. The composer re-asserts first responder on updates only while `canStealFocus` is true; the panel turns it off whenever a modal field (search, edit, new workspace) or the sync overlay owns the keyboard — otherwise every render stole focus back mid-edit. - `Sources/Prompt/PanelView.swift` — the panel UI. Header (workspace switcher, search, add photos), a segmented Workspace/All tab (All lists every prompt labeled with its workspace), item list, in-panel edit modal, composer. There is no in-header close button: the window's own close button hides the panel. Composer `# Name` creates/ switches sections. Click = select (Shift+click for a range); the selection bar offers Copy (single item), Copy as List, Copy as Lines, Copy & Delete (scissors icon) and Delete; right-click = context menu acting on the whole selection (Copy, Copy as List, Copy as Lines, Copy & Delete, Done, Edit, Open, Merge, Move To, Delete). Esc clears the selection before it hides the panel. Selection is scoped to the current view: switching workspace, switching tab, or closing search clears it. Drop target for images/files. - `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel` (`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as `PromptPanel`), and the Cmd+V paste monitor. Editing a note is an in-panel modal in `PanelView`, not a window — a separate edit window used to float loose from the panel and its theme. - `Sources/Prompt/HotkeyManager.swift` — double-Shift detection via a CGEvent tap on `flagsChanged` (left Shift ×2 = capture, right Shift ×2 = toggle panel), plus Carbon `RegisterEventHotKey` fallbacks (Cmd+Shift+Space toggle, Cmd+Shift+C capture) that work without Accessibility. - `Sources/Prompt/Capture.swift` — selection capture: backs up every pasteboard item, simulates Cmd+C, polls `changeCount` for ≤0.5s, reads the string, restores the backup. Runs off the main thread (hotkey dispatches to a global queue); only the store append hops back to main. - `Sources/Prompt/SyncService.swift` — optional sync client. Holds the group code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`, `sync.version`). The URL defaults to `SyncService.defaultServerURL` (`https://prompt.tzgyn.dev`); nothing is sent anywhere until a code exists, because polling only runs when one is set. Pushes the whole store as JSON and polls every 5s. - `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`). Also ships `Server/Dockerfile` (static binary on distroless) and `Server/README.md` (self-hosting). Logs one line per request, creates its schema on startup, shuts down gracefully on SIGTERM so a redeploy cannot cut off a push, and supports `-healthcheck` (pings local `/healthz`) so the shell-less image needs no curl for its Docker `HEALTHCHECK`. ## Sync protocol No authentication by design: a sync group is named by an 8-digit code (`1234-5678`) that doubles as the shared secret. The server stores the client payload as opaque `JSONB`; it never parses note contents. | Endpoint | Behaviour | | --- | --- | | `POST /api/codes` | Generates a unique code and inserts an empty group | | `GET /api/sync/{code}` | `{"version":N,"updatedAt":…,"data":{…}\|null}`, 404 if unknown | | `GET /api/sync/{code}/version` | `{"version":N}` — the cheap poll | | `PUT /api/sync/{code}` | Upserts the payload, returns the incremented version | | `GET /healthz` | Liveness | Client rules: - Payload = `{sections, activeSectionID, images}`, where `images` maps a filename to base64 PNG so photos travel with the notes. - Polling is two-step: `GET /version` every 5s (tiny) and the full payload only when the version moved. Fetching the payload unconditionally would re-download every base64 photo every 5 seconds. - Version counter is server-owned; the client stores the last version it saw in `sync.version` and only applies a remote payload when `remote.version > lastVersion`. - **Never push a payload that came from the server.** `pushNow()` compares a sorted-keys encoding against `lastSyncedPayload` and skips identical payloads. Without this, applying a remote change would immediately push it back, bump the version, and the two Macs would ping-pong forever. The `applyingRemote` flag alone is not enough: the save-triggered push is dispatched asynchronously, so it runs *after* the flag is cleared. - Conflict resolution is last-writer-wins at whole-store granularity. That is acceptable for personal use; it is not a merge. - `apply()` writes incoming images, deletes image files no longer referenced, and replaces the store in one shot. - `Prompt --sync-check` runs `SyncService.runDiagnostic()`: pushes any local change and prints local vs remote versions, then exits. Use it to debug a second Mac without the UI. ## Commands ```bash swift build # debug build swift run Prompt # dev run (menu bar icon appears) ./Prompt.app/Contents/MacOS/Prompt --sync-check # push + report sync state, then exit ./build-app.sh 0.1.0 # release build + .app bundle + icon + codesign ./release.sh 0.1.0 # build-app.sh + dist/Prompt.zip + notarize + sha256 scripts/make-icon.sh # regenerate Prompt.app/Contents/Resources/Icon.icns # sync server (Go, needs Postgres) createdb prompt_sync cd Server && go run . # :8080, PROMPT_SYNC_DATABASE_URL / PROMPT_SYNC_ADDR ``` There is no test target and no linter/formatter configured. Verification = `swift build`, `go build ./...`, and manual run (`--sync-check` for sync). ## Bundle build & signing `build-app.sh` does: release build → handwritten `Info.plist` (NOT generated from `Package.swift`) → `make-icon.sh` → `codesign`. It resolves a `Developer ID Application` identity from the login keychain and falls back to ad-hoc signing (`-`) if none is present. `LSUIElement` is true → no Dock icon. Bundle id is `dev.tzgyn.prompt`. ## Release process Notarization is **required**: an unnotarized build is not a release. Never publish one, and never suggest quarantine workarounds (`xattr -dr com.apple.quarantine`, `brew install --cask --no-quarantine`). `release.sh` enforces this — it fails if it cannot notarize, and refuses to print a sha256 unless `spctl` reports `source=Notarized Developer ID`. `PROMPT_SKIP_NOTARY=1` exists only for a local dev build and prints a "NOT FOR PUBLICATION" warning. 1. `./release.sh ` — builds, notarizes, staples, zips `Prompt.app` to `dist/Prompt.zip`, and prints its sha256 (it does not tag or upload). Uses the `PROMPT_NOTARY_*` env vars, defaulting to the shared Kapture App Store Connect key at `~/.config/kapture/AuthKey_K5KXMMSB28.p8`. 2. Commit and push `main`. 3. `tea releases create --tag v --title "Prompt " --note "" --asset dist/Prompt.zip --repo TZGyn/prompt --login gitea.tzgyn.dev`. 4. Add/update `~/.cache/homebrew-tap/Casks/prompt.rb` (`version` + `sha256`), push the tap, then `git -C /opt/homebrew/Library/Taps/tzgyn/homebrew-tap pull --ff-only` and `brew audit --cask tzgyn/tap/prompt`. 5. Verify by downloading the release asset back, comparing the sha256 with the cask, and unzipping with `ditto -x -k` (not `unzip`, which adds `._*` files and breaks the signature check) then `spctl -a -vv Prompt.app`. **Always print the upgrade script after any release or version bump:** ```bash brew update brew upgrade --cask prompt ``` ## Gotchas - **`@Published` publishes on every assignment, even when the value is unchanged.** The 5-second sync poll used to assign `lastError = nil` unconditionally; `PanelView` observes `SyncService`, so the entire panel (rows, overlays, composer) rebuilt every 5 seconds, forever. Over five days of uptime that was ~185 CPU-minutes and ~190 MB of footprint, and every click started to lag. Poll state now goes through `setError`, which no-ops when equal, and the header's sync dot is its own view so sync publishes cannot invalidate the list. - **Never assign `NSApp.mainMenu`.** SwiftUI installs a menu whose Edit items are how AppKit routes Cmd+C/V/X/A/Z; replacing it with a hand-built menu silently breaks pasting, copying and select-all in the composer (this shipped once and had to be reverted). `installMenuItems()` only *augments* the existing menu: it adds Hide Panel (Cmd+W) and removes SwiftUI's Settings item, whose empty scene would otherwise shadow the in-panel Cmd+, sync panel. - **Cmd+V is handled by the paste monitor, not the menu.** The panel is a `.nonactivatingPanel`, so it can be key while the app is not active, and menu key equivalents are not reliably delivered in that state. The monitor dispatches `paste:` to the responder chain itself for text, and adds the clipboard image to the store when there is no text. `Prompt --diag` dumps the menu so the wiring can be checked without a keyboard. - **A row is not a `Button`** because it contains a checkbox `Button`, and nested buttons never receive clicks. The row uses `contentShape` + `onTapGesture` so a click anywhere on the card selects it. Selection is driven by the card click, the checkbox, Cmd+click (toggle) and Shift+click (range); the context menu acts on the whole selection via `effectiveIDs(for:)`. Copying is an explicit action (selection bar or context menu), never a side effect of a click — that was changed deliberately. - **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete, plain Cmd+C). SwiftUI key equivalents are matched before text input and would steal characters from the composer TextField. Only Cmd-modified shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C, Option+Cmd+C). This is why Copy has no in-panel shortcut: click-to-copy is the primary gesture, matching Copper's intent. - The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what lets the composer TextField take focus. Don't replace it with a plain NSWindow config without rechecking that typing works without the app becoming active. - The Cmd+V paste monitor only intercepts when the pasteboard has an image AND no string, so pasting text into the composer is never swallowed. - CGEvent tap callbacks run off-main; `HotkeyManager` hops to main before firing. The tap is re-enabled on `tapDisabledByTimeout`. - `Store` is `@MainActor`; anything reaching it from monitors/callbacks must go through `Task { @MainActor in }`. - **The composer steals focus via `updateNSView`, so gate it.** It used to re-assert first responder on every render, which yanked focus out of the edit dialog (and search/sync fields) on every keystroke. The gate is `canStealFocus`; any new modal text field must be included in it. - **`doCommandBy` has no fall-through once the delegate implements it.** Returning false for `insertNewline` on Shift+Return killed the event instead of inserting a newline, so Shift+Return never actually worked. The composer now inserts it explicitly, and reads the Shift state from the `keyDown` currently being handled because `NSApp.currentEvent` is not reliably populated when the command fires. - `captureSelection()` blocks up to 0.5s polling the pasteboard — keep it off the main thread (the hotkey handler already dispatches to a global queue). - **`SMAppService.mainApp.status` is `.notFound` for a bundle BTM has never seen**, not `.notRegistered`, and `register()` still works from that state. Only treat `.enabled` and `.requiresApproval` as "already done"; checking for `.notRegistered` silently skips the registration. `Prompt --diag` prints the raw status. - The double-Shift tap needs Accessibility; without it the event tap silently fails and only the Carbon fallbacks work. The panel banner and the status menu both surface the permission state, and `AppDelegate` shows a launch alert when it is missing — otherwise a user who hides the panel is locked out (no hotkey, no Dock icon) and reports it as "the app won't come back". Check with `Prompt --diag` (`accessibility: false` is the tell). - `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS would otherwise block it with a confusing error. - The server keeps `data` as opaque `JSONB`, so the payload shape can evolve without a migration — but both Macs should run the same app version, since a newer client writing unknown fields will round-trip them to an older one. - `.gitignore` covers `.build/`, `Prompt.app/`, `dist/` — build artifacts should never be committed.