A selection made in one workspace survived switching workspace or tab, so the selection bar and its batch actions (copy, delete) kept acting on items that were no longer visible. Switching workspace, switching tab, or closing search now clears the selection.
256 lines
15 KiB
Markdown
256 lines
15 KiB
Markdown
# 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 sync panel also carries a Launch at Login toggle (the
|
||
app's only settings surface). 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 and Delete;
|
||
right-click = context menu acting on the whole selection (Copy, Copy as List,
|
||
Copy as Lines, 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 <version>` — 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<version> --title "Prompt <version>" --note "<what changed>" --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).
|
||
- 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.
|