mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cca99d4e13 | |||
| 2aab9dac07 | |||
| c31dfccb9b | |||
| 61e61f556a | |||
| 424711c3d9 | |||
| 067aeda878 | |||
| 389620059c | |||
| 4ffd353835 | |||
| 511726e2c0 | |||
| 587c6c36c8 | |||
| 50fbbc6af6 | |||
| 4776119e07 | |||
| f72bf6e30b | |||
| 0bfae1227a | |||
| c312a565b6 | |||
| afb1356b27 | |||
| f9a288ac6c | |||
| bb33a39b42 | |||
| e828538a2d | |||
| 051f3bf80c | |||
| 558970a09a | |||
| 8d9de4c576 | |||
| e0f0fad192 | |||
| 80a4d23974 | |||
| d95ef61e13 | |||
| 988ce5df5a | |||
| 3bca8a6e55 |
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="icon.png" width="128" height="128" alt="Scarf app icon">
|
||||
<img src="icon-v2.5.png" width="128" height="128" alt="Scarf app icon">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Scarf</h1>
|
||||
@@ -27,6 +27,16 @@ Same Hermes server you've been running on your Mac — now reachable from your p
|
||||
|
||||
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 24–48h.
|
||||
|
||||
<p align="center">
|
||||
<a href="assets/screenshots/scarfgo-servers.png"><img src="assets/screenshots/scarfgo-servers.png" alt="ScarfGo — Servers list" width="140"></a>
|
||||
<a href="assets/screenshots/scarfgo-chat.png"><img src="assets/screenshots/scarfgo-chat.png" alt="ScarfGo — Chat with Hermes" width="140"></a>
|
||||
<a href="assets/screenshots/scarfgo-project-dashboard.png"><img src="assets/screenshots/scarfgo-project-dashboard.png" alt="ScarfGo — Project dashboard" width="140"></a>
|
||||
<a href="assets/screenshots/scarfgo-skills.png"><img src="assets/screenshots/scarfgo-skills.png" alt="ScarfGo — Skills browser" width="140"></a>
|
||||
<a href="assets/screenshots/scarfgo-system.png"><img src="assets/screenshots/scarfgo-system.png" alt="ScarfGo — System tab" width="140"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><sub><em>Tap any thumbnail to view full size. Servers list · Chat · Project dashboard (Site Status Checker template) · Skills browser · System tab.</em></sub></p>
|
||||
|
||||
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
|
||||
|
||||
### Everything else in 2.5
|
||||
@@ -169,6 +179,20 @@ Download the latest build from [Releases](https://github.com/awizemann/scarf/rel
|
||||
|
||||
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
|
||||
|
||||
#### "Scarf.app is damaged" on first launch
|
||||
|
||||
If Gatekeeper rejects the app on first launch (occasionally happens on macOS 14+ for zip-distributed apps depending on extraction tool + quarantine state), the bundle itself is fine — every release is verified to pass `codesign --verify --strict --deep` and `spctl --assess --type execute` before it ships. The fix is to **only remove the quarantine attribute**, never strip all xattrs or re-sign:
|
||||
|
||||
```bash
|
||||
# Recommended — non-destructive
|
||||
xattr -d com.apple.quarantine /Applications/Scarf.app
|
||||
|
||||
# Or extract with ditto instead of double-clicking the zip:
|
||||
ditto -xk ~/Downloads/Scarf-vX.X.X-Universal.zip ~/Downloads/
|
||||
```
|
||||
|
||||
**Do not run `xattr -rc /Applications/Scarf.app`** — it strips codesign-related extended attributes and can break the bundle's seal. **Do not run `codesign --force --deep --sign - /Applications/Scarf.app`** — `--deep` ad-hoc re-signing is incompatible with Sparkle.framework's nested XPC services and `Updater.app` sub-bundle, and will corrupt the framework signature even if the outer app appears intact afterward. If a clean re-download + `xattr -d com.apple.quarantine` doesn't resolve the issue, please open an issue with `codesign --verify --verbose=4 --strict /Applications/Scarf.app` output captured **before** any mitigation attempts.
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 514 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 472 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
@@ -0,0 +1,47 @@
|
||||
## What's in 2.5.1
|
||||
|
||||
A patch release that bundles every issue reported against 2.5.0 plus a couple of TestFlight-driven iOS fixes. No data migrations needed — drop-in replacement for 2.5.0 on Mac, drop-in TestFlight build on iOS.
|
||||
|
||||
### Bug fixes
|
||||
|
||||
#### Mac
|
||||
|
||||
- **[#49](https://github.com/awizemann/scarf/issues/49) — macOS 26 "Scarf.app is damaged" recovery path.** Verified the shipped 2.5.0 bundles pass `codesign --verify --strict --deep` and `spctl --assess` on macOS 26.4.1; the user-facing "damaged" symptom in some reports turned out to be self-inflicted by destructive recovery commands. Added a [Troubleshooting section](https://github.com/awizemann/scarf/blob/main/README.md) to the README documenting the **non-destructive** fix path (`xattr -d com.apple.quarantine` only — never `xattr -rc` or `codesign --force --deep --sign -`). Hardened the release pipeline: every variant zip now goes through `codesign --verify --strict --deep` + `spctl --assess` after the final `ditto`, so any future regression in the shipped artifact fails the release before a user sees it.
|
||||
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat performance: long sessions no longer bog down or crash.** Long chats were doing O(n) work per streamed token because every chunk rebuilt the full message-group array AND every `MessageGroupView` / `RichMessageBubble` re-evaluated its body. Three changes cap per-chunk work at O(1) for settled groups:
|
||||
- `MessageGroupView` and `RichMessageBubble` are now `Equatable` with `.equatable()` short-circuit. Settled bubbles skip body re-eval entirely while the streaming bubble still redraws.
|
||||
- `RichChatViewModel.upsertStreamingMessage` patches the trailing group in place via a new `patchTrailingGroupForStreaming(...)` instead of running `buildMessageGroups()` per chunk.
|
||||
- `MessageGroup.toolKindCounts` moved to the model (was an `O(m × k)` computed property re-running on every render). `ToolCallCard.formatJSON` cached via `.task(id: callId)`. `ToolResultContent.lines` cached on content change.
|
||||
|
||||
CPU during streaming on a 500-message session drops from sustained 100%+ to ~30–50% on representative hardware.
|
||||
- **[#50](https://github.com/awizemann/scarf/issues/50) — Hermes v0.11 profile awareness.** Hermes v0.11 stores each profile in its own `~/.hermes/profiles/<name>/` directory with its own `state.db`, `sessions/`, `config.yaml`, `memories/`, etc. Pre-fix Scarf hardcoded `~/.hermes` and ignored `~/.hermes/active_profile`, so `hermes profile use coder` followed by a Scarf relaunch silently read the wrong DB — sessions, memory, cron all coming from the default profile. New `HermesProfileResolver` reads `active_profile` and resolves the effective home path; `HermesPathSet.defaultLocalHome` consults it, so every derived path automatically follows the active profile. SessionInfoBar gains a profile chip when not on the default so users can see which profile Scarf is reading from.
|
||||
- **[#53](https://github.com/awizemann/scarf/issues/53) — granular reasons on the "Connected — can't read Hermes state" pill.** Tier 2 of the connection probe now distinguishes config.yaml-missing / `~/.hermes`-missing / permission-denied / Hermes-profile-active and surfaces a pill popover with the specific reason + an actionable hint + Run Diagnostics / Retry buttons. Profile case includes a copy-paste `hermes profile use default` affordance.
|
||||
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill and Run Diagnostics no longer disagree.** A long-standing latent bug surfaced by Tailscale Mac-to-Mac users: the pill probe and the diagnostics view ran the same `[ -r ~/.hermes/config.yaml ]` check but went through different transport paths — `transport.runProcess` for the pill (which `remotePathArg`-quotes every argument and mangled the multi-line script) vs raw `/usr/bin/ssh ... -- /bin/sh -s` for diagnostics. Result: 14/14 diagnostics passing while the pill stayed stuck on "can't read Hermes state". Extracted the diagnostics workaround into a shared `SSHScriptRunner` in ScarfCore; both probes now use it. Side benefit: the granular #53 probe script (more `$VAR`s and nested quotes) is robust against the same class of bug going forward.
|
||||
- **[#54](https://github.com/awizemann/scarf/issues/54) — Add Project on remote server contexts.** The Add Project sheet always rendered a Browse button backed by `NSOpenPanel` (a Mac-local file dialog). On a remote SSH context the user picked a Mac path, the path landed in the projects registry as the project's "remote" working directory, and tool calls failed at runtime because that path doesn't exist on the Linux server. Tier-1 fix: sheet is now context-aware — local context keeps Browse unchanged; remote context hides Browse, shows a `"Path on <server> — must already exist on the server"` hint, and adds a Verify button that runs `transport.stat(path)` and renders inline ✓ / ⚠. A full SFTP-backed remote picker remains a deferred feature.
|
||||
|
||||
#### ScarfGo (iOS)
|
||||
|
||||
- **[#46](https://github.com/awizemann/scarf/issues/46) — same O(n)-per-token fix on iOS.** ScarfGo uses a different chat path (`LazyVStack` directly over `controller.vm.messages`, not message groups) so the Mac fix's `Equatable` conformances didn't propagate. Added an iOS-equivalent `MessageBubble: Equatable` with `.equatable()` at the `ForEach` call site — settled bubbles short-circuit body re-eval while the streaming bubble still redraws.
|
||||
- **[#51](https://github.com/awizemann/scarf/issues/51) — keyboard now dismissable.** Pre-fix the chat composer's `TextField` had no `@FocusState`, no `.scrollDismissesKeyboard`, and no keyboard accessory toolbar; with `axis: .vertical` + `.submitLabel(.send)` the Return key inserts a newline rather than submitting. Once the keyboard rose it stuck — hiding the system tab bar (which iOS auto-hides while a keyboard is up) and trapping users in the Chat tab. Added two redundant dismissal paths: `.scrollDismissesKeyboard(.interactively)` on the message list (drag messages downward to collapse) AND a `keyboard.chevron.compact.down` button in the keyboard accessory toolbar. Tab bar reappears on dismiss → users can switch tabs again.
|
||||
- **[#55](https://github.com/awizemann/scarf/issues/55) — first-run Cancel button no longer looks broken.** TestFlight feedback: the "Connect to Hermes" onboarding's Cancel button appeared dead. Root cause: `RootModel.cancelOnboarding` had a defensive `servers.isEmpty` branch that re-presented a fresh onboarding view when there was nothing to fall back to, making the button fire correctly but visually do nothing. The fix is at the right layer: `OnboardingRootView` now takes a `canCancel: Bool` parameter and hides the Cancel button entirely when there's no server list to return to.
|
||||
|
||||
### New features (Mac)
|
||||
|
||||
- **Chat density preferences ([#47](https://github.com/awizemann/scarf/issues/47) + [#48](https://github.com/awizemann/scarf/issues/48)).** New section in **Settings → Display → Chat density**. All defaults match today's UI exactly so existing users see no change until they opt in.
|
||||
- **Tool calls**: Full card (default) / Compact chip / Hidden. Compact renders each call as a single-line tappable chip — kind icon + function name + status dot — that opens the right-pane inspector with the same details the inline expand shows. Hidden skips per-call rows; the always-visible group summary pill ("Used 5 tools (3 read, 2 edit)") becomes tappable so the inspector pane is still one click away.
|
||||
- **Reasoning**: Disclosure box (default) / Inline (italic) / Hidden. Inline collapses the yellow disclosure to italic faded caption text inline above the reply with a small brain prefix — same data, far less vertical space. Hidden skips reasoning entirely.
|
||||
- **Chat font size**: 85% to 130% slider (5% step). Applied at the chat root via `.environment(\.dynamicTypeSize, ...)` so message list, input bar, session info bar, and inspector pane all scale together.
|
||||
|
||||
All density toggles preserve existing telemetry surfaces — per-turn stopwatch, per-message tokens, finish reason, and timestamp stay in the bubble metadata footer; SessionInfoBar input/output/reasoning tokens, USD cost, model, project, git branch, and started-at relative time are unaffected by every density setting.
|
||||
|
||||
### New features (ScarfGo iOS)
|
||||
|
||||
- **iCloud Keychain sync for SSH keys ([#52](https://github.com/awizemann/scarf/issues/52)).** Reddit-reported friction: every iOS device needed its own SSH key. Pairing iPhone + iPad meant onboarding twice and editing `authorized_keys` per device. New opt-in toggle in **System → Security**: when enabled, the SSH key bundle is stored with `kSecAttrAccessibleAfterFirstUnlock` + `kSecAttrSynchronizable=true` so iCloud Keychain picks it up on every signed-in device. Default off (preserves today's behavior on update). Toggling triggers a one-shot migration that re-saves all stored keys with the target attributes; failure reverts the toggle and surfaces the error inline. With Advanced Data Protection enabled, the encryption keys never leave your devices.
|
||||
|
||||
### Documentation + tooling
|
||||
|
||||
- **Privacy / sandboxing claim corrected.** Previous CLAUDE.md / README implied Scarf ran sandboxed; it doesn't (and can't, given that it spawns the user-installed `hermes` binary and reads `~/.hermes/` directly). Documentation now reflects the actual posture.
|
||||
- **Release pipeline hardened.** `scripts/release.sh` now extracts each variant's distribution zip and runs `codesign --verify --strict --deep` + `spctl --assess --type execute` on the extracted bundle as a final gate. Catches any future regression in the shipped artifact pre-ship rather than via user reports.
|
||||
|
||||
### Notes for users running 2.5.0
|
||||
|
||||
No data migrations needed. Server configs, Keychain entries, project registries, session attribution sidecar — all forward-compatible. The iCloud Keychain sync toggle defaults to off, so existing iOS users keep their device-local keys until they opt in.
|
||||
@@ -0,0 +1,55 @@
|
||||
## What's in 2.5.2
|
||||
|
||||
A patch with one substantial new feature (**iOS chat resilience** — reconnect, cached snapshot fallback, history paging) plus a stack of fixes for issues reported against 2.5.1 and earlier. Drop-in replacement for 2.5.1 on Mac; drop-in TestFlight build on iOS. No data migrations.
|
||||
|
||||
### iOS chat resilience
|
||||
|
||||
ScarfGo now survives phone-sleep, network handoffs, and SSH socket drops without losing the agent's work. Hermes was already persisting messages to `state.db` in real-time; iOS just had no resync path.
|
||||
|
||||
- **5-attempt exponential reconnect** (1s → 2s → 4s → 8s → 16s) via `session/resume` with `session/load` fallback. Reconciles with `state.db` on success and surfaces a *"Resynced N new messages"* toast when the agent kept working through the disconnect.
|
||||
- **`NetworkReachabilityService`** (NWPathMonitor singleton): suspends reconnect attempts while offline and kicks a fresh cycle on link-up. Two new banner states above the message list — `.reconnecting` and `.offline` — render as slim ScarfDesign-tinted strips so the user always knows what the chat is doing.
|
||||
- **Scene-phase awareness**: returning to foreground triggers a channel-health check; if dead, the reconnect cycle starts immediately rather than waiting for the next interaction.
|
||||
- **Draft persistence**: per-server, per-session draft survives force-quit (UserDefaults-backed, 7-day janitor at app launch).
|
||||
|
||||
### Cached snapshot fallback (Mac + iOS)
|
||||
|
||||
`ServerTransport.cachedSnapshotPath` lets `HermesDataService` fall back to the previously-pulled `state.db` snapshot when a fresh pull fails. `isUsingStaleSnapshot` + `lastSnapshotMtime` surface to views so they render *"Last updated X ago."* Chat-history reload still passes `forceFresh: true` to refuse stale data; everything else (Dashboard, Sessions list, Activity) gets read-while-disconnected for free.
|
||||
|
||||
### Bounded message-history paging
|
||||
|
||||
`HermesDataService.fetchMessages(sessionId:limit:before:)` paginates by id desc with centralized `HistoryPageSize` constants. `RichChatViewModel.loadEarlier()` walks back through long sessions via `oldestLoadedMessageID` + `hasMoreHistory`. Legacy unbounded overload deprecated.
|
||||
|
||||
### Bug fixes
|
||||
|
||||
#### Mac
|
||||
|
||||
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat O(n)-per-token bog-down (already shipped in 2.5.1 for the trailing-group patch; this release retains the fix and pairs with the new history paging so chats with thousands of messages stay smooth).**
|
||||
- **[#19](https://github.com/awizemann/scarf/issues/19) layer-3 — sqlite3 false-negative in diagnostics.** Already in v2.5.1; kept here.
|
||||
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill / diagnostics agreement** via shared `SSHScriptRunner`. From v2.5.1; the tier-2 probe now also checks `state.db` (not just `config.yaml`) so a healthy fresh install reports green.
|
||||
- **[#59](https://github.com/awizemann/scarf/issues/59) — Settings → Model and Credential Pools no longer freeze.** Both views called `ModelCatalogService.loadProviders()` synchronously from `.onAppear` on the MainActor; on a remote SSH context that's a multi-megabyte SSH file read on the main thread, freezing the UI for 1–2 minutes. New `loadProvidersAsync()` / `loadModelsAsync(for:)` wrappers dispatch off the main thread; both views now use `.task` + `await` with a `ProgressView("Loading providers…")` overlay. Per-provider switching in the picker is also async now, so clicking a different provider doesn't re-freeze the UI.
|
||||
- **Diagnostics tri-state.** Hermes v0.11+ doesn't materialize `config.yaml` until the user changes a setting from defaults — so the diagnostics view was reporting *"12/14 passing"* on healthy fresh installs. The probe now distinguishes `.pass` / `.fail` / `.skipped`; a missing `config.yaml` emits SKIP and is excluded from the summary's denominator. Reads as *"12/12 passing (2 optional skipped)"* instead of the misleading 12/14.
|
||||
- **Credentials: OAuth providers visible.** `hasAnyAICredential()` only probed `credential_pool.<provider>` in `auth.json`; OAuth-authed providers land under `providers.<name>.access_token` (Nous, Spotify, GH Copilot ACP, Qwen, Gemini all use that path). The chat banner kept showing *"No AI provider credentials"* even after a successful Nous sign-in. Now both shapes count. Credential Pools view gains a parallel "OAuth providers" section listing OAuth-authed providers with token tail, expiry badge, and portal URL.
|
||||
- **Project-shadowed Hermes detection.** New `ProjectHermesShadowDetector` (ScarfCore) probes each registered project at chat-start; if a `.hermes/` dir or `hermes.yaml` is found inside the project, the user gets a banner explaining that project-local Hermes config will shadow the server-level one (a quiet failure mode for users who didn't realize Hermes prefers project-local config).
|
||||
- **[#58](https://github.com/awizemann/scarf/issues/58) — Mac chat side panes are hideable.** Two toolbar buttons next to the View picker (`sidebar.left` / `sidebar.right`) toggle the sessions list and tool inspector with a slide animation; both default visible (today's behavior). Clicking a tool card auto-shows the inspector if hidden so the click never silently dies. Settings → Display → Chat density gains parity Toggle rows.
|
||||
|
||||
#### ScarfGo (iOS)
|
||||
|
||||
- **[#56](https://github.com/awizemann/scarf/issues/56) — *"Citadel.SSHClient.CommandFailed error 1"* on dashboard.** `asyncSnapshotSQLite` was missed during the v2.5.0 Citadel hardening — used raw `executeCommand` (which discards stderr on non-zero exit) and didn't prepend the Citadel-friendly `PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH`. Now uses `executeCommandStream` and the same PATH prefix. `HermesDataService.humanize` already translates `sqlite3: command not found` / `permission denied` / `no such file` into actionable user copy — the bug was that the snapshot path never fed it real stderr.
|
||||
- **[#57](https://github.com/awizemann/scarf/issues/57) — keyboard-dismiss chevron over send button.** The keyboard accessory dismiss button added in v2.5.1 (#51) was placed at the trailing edge of the keyboard toolbar, directly above the trailing-edge send button. Moved to the leading edge — matches the iOS convention (Notes, Mail, Reminders).
|
||||
|
||||
### New features (Mac)
|
||||
|
||||
- **Chat-start model preflight ([commit](https://github.com/awizemann/scarf/commit/2aab9da)).** Catches a missing `model.default` / `model.provider` in `config.yaml` *before* the ACP session starts. Pre-fix the user typed a prompt, hit send, and got an opaque *"Model parameter is required"* HTTP 400 from the upstream provider. Now `ChatModelPreflightSheet` wraps the existing model picker so the same selection / validation / Nous-catalog branch is single-sourced; the chat the user originally opened lands without re-clicking the project row.
|
||||
- **Nous Portal live model catalog.** `NousModelCatalogService` fetches `GET /v1/models` from `inference-api.nousresearch.com` using the bearer token in `auth.json`. Cached at `~/.hermes/scarf/nous_models_cache.json` with a 24h TTL. The picker's nous-overlay detail view switches from a free-form TextField to a real model list, with a *"Custom…"* escape hatch for IDs not yet in the API response.
|
||||
- **Remote-aware admin sheets.** Three sheets gained the same context-aware Verify pattern that Add Project got in v2.5.1 (#54):
|
||||
- **Profiles → Import / Export.** Buttons that drive `hermes profile import <zip>` / `hermes profile export <name> <zip>` over SSH. Local context picks via `NSOpenPanel`; remote context shows a path-input + Verify button.
|
||||
- **Settings → Advanced → Restore.** Pick a local backup zip OR enter+verify a remote path.
|
||||
- **Templates → Install destination.** The parent-directory step in the install sheet branches on context — local Browse, or remote text-input + Verify.
|
||||
|
||||
### Translations
|
||||
|
||||
`Localizable.xcstrings` adds strings for all the new copy across the seven supported locales (English, Simplified Chinese, German, French, Spanish, Japanese, Brazilian Portuguese).
|
||||
|
||||
### Notes for users running 2.5.1
|
||||
|
||||
No data migrations needed. `~/.hermes/scarf/nous_models_cache.json` is created lazily on first use of the Nous picker; everything else is forward-compatible with existing config / Keychain / project registries.
|
||||
@@ -27,6 +27,28 @@ public enum QueryDefaults: Sendable {
|
||||
public nonisolated static let defaultSilenceThreshold = 200
|
||||
}
|
||||
|
||||
/// Page sizes for `HermesDataService.fetchMessages(sessionId:limit:before:)`.
|
||||
/// Centralized so iOS, Mac, and the polling code paths can pick a
|
||||
/// consistent budget — and so we have one knob to retune if perf
|
||||
/// concerns shift.
|
||||
public enum HistoryPageSize: Sendable {
|
||||
/// Initial chat-history load: covers the vast majority of
|
||||
/// sessions in one fetch while keeping the snapshot read bounded
|
||||
/// for the rare 1000+-message session.
|
||||
public nonisolated static let initial = 200
|
||||
/// Reconnection reconcile against the DB. 200 rows is plenty —
|
||||
/// disconnects don't generate hundreds of unseen messages.
|
||||
public nonisolated static let reconcile = 200
|
||||
/// Mac sessions detail view. Larger to reduce paging UX in the
|
||||
/// desktop browser-style read; the desktop has the screen real
|
||||
/// estate and memory headroom for it.
|
||||
public nonisolated static let macSessionDetail = 500
|
||||
/// Terminal-mode polling refresh. Same 500-row budget as Mac
|
||||
/// detail; covers sessions long enough that the user is actively
|
||||
/// scrolling but bounded to keep each poll tick cheap.
|
||||
public nonisolated static let polling = 500
|
||||
}
|
||||
|
||||
// MARK: - File Size Formatting
|
||||
|
||||
public enum FileSizeUnit: Sendable {
|
||||
|
||||
@@ -35,10 +35,22 @@ public struct HermesPathSet: Sendable, Hashable {
|
||||
self.isRemote = isRemote
|
||||
self.binaryHint = binaryHint
|
||||
}
|
||||
public nonisolated static let defaultLocalHome: String = {
|
||||
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
return user + "/.hermes"
|
||||
}()
|
||||
/// Resolved path to the active local Hermes profile (issue #50).
|
||||
///
|
||||
/// Hermes v0.11+ supports multiple profiles via `hermes profile use`;
|
||||
/// each profile is a fully independent `HERMES_HOME` directory. We
|
||||
/// delegate to `HermesProfileResolver` (which reads
|
||||
/// `~/.hermes/active_profile`) so every derived path — `state.db`,
|
||||
/// `sessions/`, `config.yaml`, `memories/`, etc. — automatically
|
||||
/// follows the active profile. Returns the pre-profile default
|
||||
/// `~/.hermes` whenever no named profile is active, so existing
|
||||
/// (non-profile) installations are unaffected.
|
||||
///
|
||||
/// Backed by a 5-second cache inside the resolver, so frequent
|
||||
/// `HermesPathSet` constructions don't hammer the filesystem.
|
||||
public nonisolated static var defaultLocalHome: String {
|
||||
HermesProfileResolver.resolveLocalHome()
|
||||
}
|
||||
|
||||
/// Default remote home when the user doesn't override it in `SSHConfig`.
|
||||
/// We leave `~` unexpanded on purpose — the remote shell resolves it.
|
||||
@@ -69,6 +81,12 @@ public struct HermesPathSet: Sendable, Hashable {
|
||||
/// Maps Hermes session IDs to the Scarf project path a chat was
|
||||
/// started for. Scarf-owned; Hermes never touches this file.
|
||||
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
|
||||
/// Cached list of available Nous Portal models. Populated by
|
||||
/// `NousModelCatalogService` from `GET https://inference-api.nousresearch.com/v1/models`
|
||||
/// using the bearer token in `auth.json`. Refreshed on a 24h TTL or
|
||||
/// on user request from the model picker. Survives offline runs so
|
||||
/// the picker still has something to render.
|
||||
public nonisolated var nousModelsCache: String { scarfDir + "/nous_models_cache.json" }
|
||||
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
@@ -25,6 +25,10 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
|
||||
/// remote side).
|
||||
public var remoteHome: String?
|
||||
/// Override for where Scarf installs new project templates on this host.
|
||||
/// `nil` uses `~/projects` (unexpanded — remote shell resolves it).
|
||||
/// Created on first install if missing.
|
||||
public var projectsRoot: String?
|
||||
/// Resolved remote path to the `hermes` binary. Populated by
|
||||
/// `SSHTransport` after the first `command -v hermes` probe; cached here
|
||||
/// so subsequent calls skip the round trip.
|
||||
@@ -36,6 +40,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
port: Int? = nil,
|
||||
identityFile: String? = nil,
|
||||
remoteHome: String? = nil,
|
||||
projectsRoot: String? = nil,
|
||||
hermesBinaryHint: String? = nil
|
||||
) {
|
||||
self.host = host
|
||||
@@ -43,6 +48,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
self.port = port
|
||||
self.identityFile = identityFile
|
||||
self.remoteHome = remoteHome
|
||||
self.projectsRoot = projectsRoot
|
||||
self.hermesBinaryHint = hermesBinaryHint
|
||||
}
|
||||
}
|
||||
@@ -106,6 +112,27 @@ public struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Default parent directory under which `ProjectTemplateInstaller` lays
|
||||
/// out new projects. Per-host configurable on `.ssh` via
|
||||
/// `SSHConfig.projectsRoot`; local always resolves to `~/Projects` on the
|
||||
/// user's Mac. The remote default is left as an unexpanded `~/projects`
|
||||
/// — the remote shell resolves the tilde, same convention as
|
||||
/// `HermesPathSet.defaultRemoteHome`. The installer calls
|
||||
/// `transport.createDirectory(_:)` at install time so a missing dir on a
|
||||
/// fresh host is bootstrapped on first use rather than treated as an error.
|
||||
public nonisolated var defaultProjectsRoot: String {
|
||||
switch kind {
|
||||
case .local:
|
||||
return NSHomeDirectory() + "/Projects"
|
||||
case .ssh(let config):
|
||||
if let configured = config.projectsRoot,
|
||||
!configured.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return configured
|
||||
}
|
||||
return "~/projects"
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the `ServerTransport` for this context. Local contexts get
|
||||
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
|
||||
/// from `SSHConfig` by default, OR whatever `sshTransportFactory`
|
||||
|
||||
@@ -61,6 +61,26 @@ public actor HermesDataService {
|
||||
/// instead of an empty Dashboard with no explanation.
|
||||
public private(set) var lastOpenError: String?
|
||||
|
||||
/// Modification date of the underlying state.db that backs the
|
||||
/// currently-open connection. For local contexts this tracks the
|
||||
/// live DB's mtime; for remote contexts it's the cached snapshot's
|
||||
/// mtime — which equals "when did we last get fresh data."
|
||||
public private(set) var lastSnapshotMtime: Date?
|
||||
|
||||
/// True when a `snapshotSQLite` pull failed and the open succeeded
|
||||
/// against a previously-cached snapshot instead of a fresh one.
|
||||
/// Views render a "Last updated X ago" affordance when this is set
|
||||
/// alongside `lastOpenError`. Always `false` for local contexts.
|
||||
public private(set) var isUsingStaleSnapshot: Bool = false
|
||||
|
||||
/// Convenience: how long ago the cached snapshot was written, when
|
||||
/// we're using a stale snapshot. `nil` when the snapshot is fresh
|
||||
/// or no mtime could be read.
|
||||
public var staleAge: TimeInterval? {
|
||||
guard isUsingStaleSnapshot, let m = lastSnapshotMtime else { return nil }
|
||||
return Date().timeIntervalSince(m)
|
||||
}
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
@@ -70,6 +90,18 @@ public actor HermesDataService {
|
||||
}
|
||||
|
||||
public func open() async -> Bool {
|
||||
await openInternal(forceFresh: false)
|
||||
}
|
||||
|
||||
/// Variant that refuses the stale-snapshot fallback. Used by call
|
||||
/// sites that genuinely need post-write consistency — most notably
|
||||
/// the chat session-history reload, where a stale snapshot would
|
||||
/// hide messages the agent just streamed.
|
||||
private func openStrict() async -> Bool {
|
||||
await openInternal(forceFresh: true)
|
||||
}
|
||||
|
||||
private func openInternal(forceFresh: Bool) async -> Bool {
|
||||
if db != nil { return true }
|
||||
let localPath: String
|
||||
if context.isRemote {
|
||||
@@ -86,10 +118,30 @@ public actor HermesDataService {
|
||||
)
|
||||
localPath = url.path
|
||||
lastOpenError = nil
|
||||
isUsingStaleSnapshot = false
|
||||
lastSnapshotMtime = mtime(at: url)
|
||||
} catch {
|
||||
lastOpenError = humanize(error)
|
||||
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
// Fresh pull failed. If the caller demanded fresh data
|
||||
// (`forceFresh: true`) OR there's no usable cache on
|
||||
// disk, surface the error and bail. Otherwise serve
|
||||
// the cached snapshot with `isUsingStaleSnapshot = true`
|
||||
// so views can render a "Last updated X ago" banner.
|
||||
if !forceFresh,
|
||||
let cached = transport.cachedSnapshotPath,
|
||||
FileManager.default.fileExists(atPath: cached.path)
|
||||
{
|
||||
localPath = cached.path
|
||||
isUsingStaleSnapshot = true
|
||||
lastSnapshotMtime = mtime(at: cached)
|
||||
lastOpenError = humanize(error) // user still sees why it's stale
|
||||
Self.logger.warning(
|
||||
"Using stale snapshot after pull failure: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
} else {
|
||||
lastOpenError = humanize(error)
|
||||
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
localPath = context.paths.stateDB
|
||||
@@ -97,6 +149,8 @@ public actor HermesDataService {
|
||||
lastOpenError = "Hermes state database not found at \(localPath)."
|
||||
return false
|
||||
}
|
||||
isUsingStaleSnapshot = false
|
||||
lastSnapshotMtime = mtime(at: URL(fileURLWithPath: localPath))
|
||||
}
|
||||
// Remote snapshots are point-in-time copies that no one writes to;
|
||||
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
|
||||
@@ -151,17 +205,27 @@ public actor HermesDataService {
|
||||
return desc
|
||||
}
|
||||
|
||||
/// Force a fresh snapshot pull + reopen. Used on session-load and in
|
||||
/// any path that needs the UI to reflect writes Hermes just made.
|
||||
/// Without this, remote snapshots would be frozen at the first `open()`
|
||||
/// for the app's lifetime — new messages added to a resumed session
|
||||
/// would never appear because the snapshot was pulled before they were
|
||||
/// written. Local contexts pay essentially nothing: close+reopen on a
|
||||
/// live DB is a no-op.
|
||||
/// Close the current connection and re-open with a fresh snapshot
|
||||
/// pull (when remote). When `forceFresh` is `false` (default) and
|
||||
/// the snapshot pull fails, falls back to the cached snapshot —
|
||||
/// `isUsingStaleSnapshot` is set so views can render a "Last
|
||||
/// updated X ago" banner. Pass `forceFresh: true` from call sites
|
||||
/// that genuinely need post-write consistency (chat session
|
||||
/// history reload), where stale data would hide messages the
|
||||
/// agent just streamed.
|
||||
@discardableResult
|
||||
public func refresh() async -> Bool {
|
||||
public func refresh(forceFresh: Bool = false) async -> Bool {
|
||||
close()
|
||||
return await open()
|
||||
return await openInternal(forceFresh: forceFresh)
|
||||
}
|
||||
|
||||
/// Read the modification date of a local file. Returns `nil` if
|
||||
/// the file is unreachable or has no mtime metadata. Used to
|
||||
/// stamp `lastSnapshotMtime` so views can show "Last updated
|
||||
/// X ago" without each one duplicating the FileManager dance.
|
||||
private nonisolated func mtime(at url: URL) -> Date? {
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||
return attrs?[.modificationDate] as? Date
|
||||
}
|
||||
|
||||
public func close() {
|
||||
@@ -294,6 +358,50 @@ public actor HermesDataService {
|
||||
return cols
|
||||
}
|
||||
|
||||
/// Bounded message fetch keyed by message id (monotonic per row,
|
||||
/// safer than timestamp-based pagination because streaming chunk
|
||||
/// timestamps can collide). Returns the most recent `limit`
|
||||
/// messages older than `before` (when supplied) in chronological
|
||||
/// (ASC) order ready to display. Pass `before: nil` for the
|
||||
/// initial load — the DB returns the newest `limit` rows.
|
||||
public func fetchMessages(
|
||||
sessionId: String,
|
||||
limit: Int,
|
||||
before: Int? = nil
|
||||
) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql: String
|
||||
if before != nil {
|
||||
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?"
|
||||
} else {
|
||||
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?"
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
if let before {
|
||||
sqlite3_bind_int(stmt, 2, Int32(before))
|
||||
sqlite3_bind_int(stmt, 3, Int32(limit))
|
||||
} else {
|
||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||
}
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
// Caller wants chronological (oldest-first) order; the SELECT
|
||||
// is DESC for the LIMIT to bite the newest rows, so reverse.
|
||||
return messages.reversed()
|
||||
}
|
||||
|
||||
/// Legacy unbounded fetch retained for one release cycle so any
|
||||
/// out-of-tree consumers don't break. New code should use the
|
||||
/// bounded `fetchMessages(sessionId:limit:before:)` variant —
|
||||
/// snapshot loads on 1000+-message sessions stall the UI when
|
||||
/// they materialize the whole history at once.
|
||||
@available(*, deprecated, message: "Use fetchMessages(sessionId:limit:before:) instead.")
|
||||
public func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Resolves Hermes's active profile (v0.11+) for local installations.
|
||||
///
|
||||
/// Hermes v0.11 introduced `hermes profile`: each profile is an independent
|
||||
/// `HERMES_HOME` directory. The "default" profile is `~/.hermes` itself;
|
||||
/// named profiles live at `~/.hermes/profiles/<name>/` and have their own
|
||||
/// `state.db`, `sessions/`, `config.yaml`, `.env`, `memories/`, `cron/`,
|
||||
/// `gateway_state.json`, etc.
|
||||
///
|
||||
/// The active profile is recorded in `~/.hermes/active_profile` (a single
|
||||
/// line text file containing the profile name, or absent / empty when the
|
||||
/// default profile is active). The Hermes CLI consults this file to set
|
||||
/// `HERMES_HOME` for each invocation.
|
||||
///
|
||||
/// Pre-v0.11 Scarf hardcoded `~/.hermes` and ignored `active_profile`,
|
||||
/// which meant `hermes profile use <name>` left Scarf reading the wrong
|
||||
/// state.db (issue #50). This resolver is the single seam: it reads
|
||||
/// `active_profile` and returns the effective home directory; everything
|
||||
/// else in `HermesPathSet` derives from `home`, so once the seam is
|
||||
/// correct every read path follows automatically.
|
||||
///
|
||||
/// **Caching.** The resolver is called from `HermesPathSet.defaultLocalHome`,
|
||||
/// which is in turn called whenever a `HermesPathSet` is constructed via
|
||||
/// the default helper. To avoid filesystem hits on hot paths we cache the
|
||||
/// resolved name for `cacheTTL` seconds (default 5s). That's tight enough
|
||||
/// that `hermes profile use other` followed by a Scarf operation picks up
|
||||
/// the change within seconds, and loose enough that no realistic UI loop
|
||||
/// causes more than a handful of file reads per minute.
|
||||
public enum HermesProfileResolver {
|
||||
|
||||
/// Cache lifetime for resolved profile state. Tunable for tests.
|
||||
public static var cacheTTL: TimeInterval = 5
|
||||
|
||||
private static let lock = OSAllocatedUnfairLock(initialState: CacheState())
|
||||
private static let logger = Logger(subsystem: "com.scarf.app", category: "HermesProfileResolver")
|
||||
|
||||
private static let profileNameRegex: NSRegularExpression = {
|
||||
// Mirrors Hermes's own validation in hermes_cli/profiles.py.
|
||||
try! NSRegularExpression(pattern: "^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
}()
|
||||
|
||||
private struct CacheState {
|
||||
var resolvedName: String = "default"
|
||||
var resolvedHome: String = HermesProfileResolver.defaultRootHome()
|
||||
var resolvedAt: Date = .distantPast
|
||||
}
|
||||
|
||||
/// Effective Hermes home directory for the active profile.
|
||||
/// Returns the default `~/.hermes` when no profile is active OR when
|
||||
/// the configured profile is invalid (logged) — so the worst-case
|
||||
/// failure mode is "Scarf shows what it always showed before."
|
||||
public static func resolveLocalHome() -> String {
|
||||
return refreshIfNeeded().home
|
||||
}
|
||||
|
||||
/// Name of the active profile — `"default"` or the profile id.
|
||||
/// Surfaced in UI chrome so users can see which profile Scarf is
|
||||
/// reading from (issue #50 follow-up: prevents the next variant
|
||||
/// of "where's my data — wrong profile" by making it visible).
|
||||
public static func activeProfileName() -> String {
|
||||
return refreshIfNeeded().name
|
||||
}
|
||||
|
||||
/// Force a re-read on the next call, regardless of TTL. Test helper.
|
||||
public static func invalidateCache() {
|
||||
lock.withLock { $0.resolvedAt = .distantPast }
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func refreshIfNeeded() -> (name: String, home: String) {
|
||||
let now = Date()
|
||||
let snapshot = lock.withLock { state -> CacheState? in
|
||||
if now.timeIntervalSince(state.resolvedAt) < cacheTTL {
|
||||
return state
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let snapshot {
|
||||
return (snapshot.resolvedName, snapshot.resolvedHome)
|
||||
}
|
||||
|
||||
let (name, home) = readActiveProfileFromDisk()
|
||||
lock.withLock { state in
|
||||
state.resolvedName = name
|
||||
state.resolvedHome = home
|
||||
state.resolvedAt = now
|
||||
}
|
||||
return (name, home)
|
||||
}
|
||||
|
||||
private static func readActiveProfileFromDisk() -> (name: String, home: String) {
|
||||
let defaultHome = defaultRootHome()
|
||||
let activeFile = defaultHome + "/active_profile"
|
||||
|
||||
// Absent file → default profile. This is the common case for users
|
||||
// who haven't run `hermes profile use ...` and shouldn't generate
|
||||
// any log noise.
|
||||
guard FileManager.default.fileExists(atPath: activeFile) else {
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
guard let raw = try? String(contentsOfFile: activeFile, encoding: .utf8) else {
|
||||
logger.warning("Found active_profile but could not read it; falling back to default profile.")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Empty file or explicit "default" → default profile.
|
||||
if trimmed.isEmpty || trimmed == "default" {
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
// Validate format. Hermes itself rejects malformed names, so this
|
||||
// would only fire if the file is corrupted or hand-edited.
|
||||
let range = NSRange(trimmed.startIndex..<trimmed.endIndex, in: trimmed)
|
||||
guard profileNameRegex.firstMatch(in: trimmed, range: range) != nil else {
|
||||
logger.warning("active_profile contains invalid name \(trimmed, privacy: .public); falling back to default profile.")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
let profileHome = defaultHome + "/profiles/" + trimmed
|
||||
var isDir: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: profileHome, isDirectory: &isDir), isDir.boolValue else {
|
||||
logger.warning("active_profile points to \(trimmed, privacy: .public) but \(profileHome, privacy: .public) does not exist; falling back to default profile.")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
logger.info("Resolved active Hermes profile to \(trimmed, privacy: .public) at \(profileHome, privacy: .public).")
|
||||
return (trimmed, profileHome)
|
||||
}
|
||||
|
||||
/// Pre-profile default hermes home (`~/.hermes`). The reference point
|
||||
/// for both the active_profile lookup and the fallback case.
|
||||
fileprivate static func defaultRootHome() -> String {
|
||||
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
return user + "/.hermes"
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,19 @@ public struct ModelCatalogService: Sendable {
|
||||
Self.overlayOnlyProviders[providerID]
|
||||
}
|
||||
|
||||
/// Async wrapper around `loadProviders()` for use from MainActor view
|
||||
/// code. The sync method does a transport-backed file read that on a
|
||||
/// remote SSH context can take 1–2 minutes (ControlMaster setup +
|
||||
/// pulling the multi-megabyte models.dev JSON), and on local contexts
|
||||
/// still parses ~1500 models — both unsuitable for the main thread.
|
||||
/// Issue #59. Existing call sites (tests, any non-View consumers)
|
||||
/// can keep using the sync method.
|
||||
public nonisolated func loadProvidersAsync() async -> [HermesProviderInfo] {
|
||||
await Task.detached { [self] in
|
||||
self.loadProviders()
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
public func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||
@@ -198,6 +211,17 @@ public struct ModelCatalogService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Async wrapper around `loadModels(for:)`. Same rationale as
|
||||
/// `loadProvidersAsync()` — the View call site that fires on every
|
||||
/// provider-switch click in the picker sheet was reading the catalog
|
||||
/// synchronously on the MainActor, freezing the UI on remote contexts.
|
||||
/// Issue #59.
|
||||
public nonisolated func loadModelsAsync(for providerID: String) async -> [HermesModelInfo] {
|
||||
await Task.detached { [self] in
|
||||
self.loadModels(for: providerID)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
||||
/// provider when the user picks a model from a flat list or types one in.
|
||||
public func provider(for modelID: String) -> HermesProviderInfo? {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// Pre-flight check used before opening an ACP session. Hermes resolves the
|
||||
/// model+provider from `config.yaml` at session boot; on a fresh install that
|
||||
/// file is missing or has neither key set, and the chat fails with an opaque
|
||||
/// "Model parameter is required" 400 from the upstream provider only after the
|
||||
/// user has typed a prompt and hit send. Catching the missing config here lets
|
||||
/// the UI surface a real "pick a model" sheet before any ACP work starts.
|
||||
///
|
||||
/// `HermesConfig.empty` (returned on read failure) and the YAML parser's
|
||||
/// missing-key fallback both use the literal string `"unknown"`, so the check
|
||||
/// has to treat `""` and `"unknown"` as equivalent. Anything else is
|
||||
/// considered configured — we don't try to validate the model against the
|
||||
/// provider's catalog here; that happens later in `ModelPickerSheet`.
|
||||
public enum ModelPreflight: Sendable {
|
||||
public enum Result: Equatable, Sendable {
|
||||
case configured
|
||||
case missingModel
|
||||
case missingProvider
|
||||
case missingBoth
|
||||
|
||||
public var isConfigured: Bool {
|
||||
self == .configured
|
||||
}
|
||||
|
||||
/// Short user-facing reason. Long enough to be honest, short enough
|
||||
/// for a sheet header — full messaging belongs to the picker UI.
|
||||
public var reason: String {
|
||||
switch self {
|
||||
case .configured: return ""
|
||||
case .missingModel: return "No primary model is set in this server's config."
|
||||
case .missingProvider:return "No primary provider is set in this server's config."
|
||||
case .missingBoth: return "No model is configured on this server yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Treat `""` and the YAML parser's `"unknown"` fallback as missing.
|
||||
/// Trim whitespace so a stray newline in a hand-edited config.yaml
|
||||
/// doesn't read as "configured."
|
||||
public static func check(_ config: HermesConfig) -> Result {
|
||||
let modelMissing = isUnset(config.model)
|
||||
let providerMissing = isUnset(config.provider)
|
||||
switch (modelMissing, providerMissing) {
|
||||
case (true, true): return .missingBoth
|
||||
case (true, false): return .missingModel
|
||||
case (false, true): return .missingProvider
|
||||
case (false, false): return .configured
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUnset(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
return trimmed.isEmpty || trimmed == "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// One Nous Portal model as exposed by `GET /v1/models`. The shape
|
||||
/// mirrors the OpenAI-compatible response schema — Nous's inference
|
||||
/// API uses the same envelope. Optional fields stay optional because
|
||||
/// not every entry includes them; `id` is the only field we strictly
|
||||
/// need (it's what Hermes passes through to the provider).
|
||||
public struct NousModel: Codable, Equatable, Sendable, Identifiable {
|
||||
public let id: String
|
||||
public let owned_by: String?
|
||||
public let created: Int?
|
||||
/// Free-text description if the API ships one. Nous's current
|
||||
/// catalog doesn't include this, but the field is here so future
|
||||
/// shape changes don't drop user-visible context on the floor.
|
||||
public let description: String?
|
||||
|
||||
public init(id: String, owned_by: String? = nil, created: Int? = nil, description: String? = nil) {
|
||||
self.id = id
|
||||
self.owned_by = owned_by
|
||||
self.created = created
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk cache shape. Versioned so a future schema change can lift
|
||||
/// stale caches gracefully — bump `version` and the loader rejects
|
||||
/// anything older without trying to migrate. Stored as JSON next to
|
||||
/// the projects registry so a Hermes wipe takes it with the rest of
|
||||
/// the Scarf-owned state.
|
||||
public struct NousModelsCache: Codable, Sendable {
|
||||
public static let currentVersion = 1
|
||||
public let version: Int
|
||||
public let fetchedAt: Date
|
||||
public let models: [NousModel]
|
||||
|
||||
public init(version: Int = NousModelsCache.currentVersion, fetchedAt: Date, models: [NousModel]) {
|
||||
self.version = version
|
||||
self.fetchedAt = fetchedAt
|
||||
self.models = models
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a `loadModels` call. Distinguishes "fetched fresh from
|
||||
/// the API" from "cache served, network failed" so the picker UI can
|
||||
/// surface a "could not refresh" hint without hiding the cached list.
|
||||
public enum NousModelsLoadResult: Sendable {
|
||||
case fresh(models: [NousModel], fetchedAt: Date)
|
||||
case cache(models: [NousModel], fetchedAt: Date, refreshError: String?)
|
||||
case fallback(models: [NousModel], reason: String)
|
||||
}
|
||||
|
||||
/// Fetches + caches the list of available Nous Portal models. Runs in
|
||||
/// the Scarf process (not on the remote), authenticated with the
|
||||
/// bearer token from `~/.hermes/auth.json` on the active server —
|
||||
/// `NousSubscriptionService` reads that file via the active transport,
|
||||
/// so a remote droplet's token comes back over SSH and the network
|
||||
/// call to Nous still happens from the user's Mac. That's correct:
|
||||
/// we want the model list visible whenever the user has subscription
|
||||
/// credentials, regardless of where Hermes will eventually run the
|
||||
/// chat from.
|
||||
public struct NousModelCatalogService: Sendable {
|
||||
public static let baseURL = URL(string: "https://inference-api.nousresearch.com/v1/models")!
|
||||
public static let cacheTTL: TimeInterval = 24 * 60 * 60 // 24h
|
||||
public static let requestTimeout: TimeInterval = 10 // seconds
|
||||
|
||||
/// Hard-coded fallback for offline-with-no-cache. Short on purpose
|
||||
/// — only the canonical Hermes models (the family the user is most
|
||||
/// likely to want) plus a reminder that fresh data is one
|
||||
/// successful refresh away. Update when Nous releases a new
|
||||
/// flagship; deliberately not exhaustive — the API is the source
|
||||
/// of truth, this just keeps the picker non-empty.
|
||||
public static let fallbackModels: [NousModel] = [
|
||||
NousModel(id: "Hermes-3-Llama-3.1-405B"),
|
||||
NousModel(id: "Hermes-3-Llama-3.1-70B"),
|
||||
NousModel(id: "Hermes-3-Llama-3.1-8B"),
|
||||
NousModel(id: "DeepHermes-3-Llama-3-8B-Preview")
|
||||
]
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "NousModelCatalogService")
|
||||
|
||||
public let context: ServerContext
|
||||
private let session: URLSession
|
||||
private let cachePath: String
|
||||
|
||||
public init(context: ServerContext, session: URLSession = .shared) {
|
||||
self.context = context
|
||||
self.session = session
|
||||
self.cachePath = context.paths.nousModelsCache
|
||||
}
|
||||
|
||||
// MARK: - Cache I/O
|
||||
|
||||
/// Read the cache via the active transport (so a remote droplet's
|
||||
/// cache lands on the droplet, not the user's Mac). Missing or
|
||||
/// malformed cache → nil; the loader treats that as "no cache" and
|
||||
/// kicks off a fresh fetch.
|
||||
public func readCache() -> NousModelsCache? {
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(cachePath) else { return nil }
|
||||
do {
|
||||
let data = try transport.readFile(cachePath)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let cache = try decoder.decode(NousModelsCache.self, from: data)
|
||||
guard cache.version == NousModelsCache.currentVersion else {
|
||||
Self.logger.info("nous models cache schema mismatch (got v\(cache.version), expected v\(NousModelsCache.currentVersion)); ignoring")
|
||||
return nil
|
||||
}
|
||||
return cache
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode nous models cache: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func writeCache(_ cache: NousModelsCache) {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(cache)
|
||||
// Make sure the parent dir exists — fresh remote installs
|
||||
// may not yet have `~/.hermes/scarf/`. mkdir -p is cheap
|
||||
// and idempotent on both transports.
|
||||
let parent = (cachePath as NSString).deletingLastPathComponent
|
||||
if !parent.isEmpty {
|
||||
try? transport.createDirectory(parent)
|
||||
}
|
||||
try transport.writeFile(cachePath, data: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't write nous models cache: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func isCacheStale(_ cache: NousModelsCache) -> Bool {
|
||||
Date().timeIntervalSince(cache.fetchedAt) > Self.cacheTTL
|
||||
}
|
||||
|
||||
// MARK: - Network fetch
|
||||
|
||||
/// Read the bearer token from `auth.json` on the active server.
|
||||
/// Returns nil when the user isn't signed in to Nous, in which
|
||||
/// case `loadModels` skips the network call and falls through to
|
||||
/// cache or fallback.
|
||||
private func bearerToken() -> String? {
|
||||
// The subscription service already checks for `present`; we
|
||||
// re-read the raw token here because we need the actual string,
|
||||
// not just a Bool. Mirrors the SubscriptionService parse path.
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(context.paths.authJSON) else { return nil }
|
||||
guard let data = try? transport.readFile(context.paths.authJSON) else { return nil }
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
let providers = root["providers"] as? [String: Any] ?? [:]
|
||||
let nous = providers["nous"] as? [String: Any]
|
||||
let token = nous?["access_token"] as? String
|
||||
guard let token, !token.isEmpty else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
/// Make the API call. Times out after `requestTimeout` so a hung
|
||||
/// network doesn't block the picker indefinitely. Returns the raw
|
||||
/// `[NousModel]` on success, throws on any HTTP / decode error so
|
||||
/// the caller can log + fall back.
|
||||
public func fetchModels() async throws -> [NousModel] {
|
||||
guard let token = bearerToken() else {
|
||||
throw NousModelCatalogError.notAuthenticated
|
||||
}
|
||||
var request = URLRequest(url: Self.baseURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = Self.requestTimeout
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw NousModelCatalogError.transport("non-HTTP response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw NousModelCatalogError.http(status: http.statusCode)
|
||||
}
|
||||
struct Envelope: Decodable { let data: [NousModel] }
|
||||
let envelope = try JSONDecoder().decode(Envelope.self, from: data)
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
// MARK: - Public entry
|
||||
|
||||
/// Top-level "give me models" entry point. Cache-first: serve from
|
||||
/// cache if fresh, fetch + write through if stale or empty, fall
|
||||
/// back to the hard-coded list when both fail. The caller renders
|
||||
/// based on the case so it can show a "could not refresh" hint
|
||||
/// next to a stale-but-still-useful list.
|
||||
public func loadModels(forceRefresh: Bool = false) async -> NousModelsLoadResult {
|
||||
let cached = readCache()
|
||||
|
||||
if let cached, !forceRefresh, !isCacheStale(cached) {
|
||||
return .cache(models: cached.models, fetchedAt: cached.fetchedAt, refreshError: nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let models = try await fetchModels()
|
||||
let now = Date()
|
||||
writeCache(NousModelsCache(fetchedAt: now, models: models))
|
||||
return .fresh(models: models, fetchedAt: now)
|
||||
} catch let error as NousModelCatalogError {
|
||||
// Fetch failed but we may still have *something* useful.
|
||||
if let cached {
|
||||
return .cache(
|
||||
models: cached.models,
|
||||
fetchedAt: cached.fetchedAt,
|
||||
refreshError: error.userMessage
|
||||
)
|
||||
}
|
||||
return .fallback(models: Self.fallbackModels, reason: error.userMessage)
|
||||
} catch {
|
||||
if let cached {
|
||||
return .cache(
|
||||
models: cached.models,
|
||||
fetchedAt: cached.fetchedAt,
|
||||
refreshError: error.localizedDescription
|
||||
)
|
||||
}
|
||||
return .fallback(models: Self.fallbackModels, reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum NousModelCatalogError: Error, Sendable {
|
||||
case notAuthenticated
|
||||
case http(status: Int)
|
||||
case transport(String)
|
||||
|
||||
public var userMessage: String {
|
||||
switch self {
|
||||
case .notAuthenticated:
|
||||
return "Sign in to Nous Portal to fetch the latest model list."
|
||||
case .http(let status) where status == 401:
|
||||
return "Nous rejected the saved token (401). Sign in again."
|
||||
case .http(let status):
|
||||
return "Nous returned HTTP \(status)."
|
||||
case .transport(let detail):
|
||||
return "Couldn't reach Nous: \(detail)."
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Detects when a registered project directory contains its own `.hermes/`
|
||||
/// subdirectory. Hermes' CLI uses the closest `.hermes/` as `$HERMES_HOME`
|
||||
/// when invoked from inside such a directory, which **shadows** the user's
|
||||
/// global Hermes home — credentials, config, sessions, skills, memories
|
||||
/// all bind to the project-local copy without warning.
|
||||
///
|
||||
/// This causes confusing failure modes: the user runs `hermes auth add nous`
|
||||
/// during setup expecting a global registration, but if their cwd happens to
|
||||
/// be inside a project that already has a `.hermes/` (e.g. seeded by a
|
||||
/// previous workflow, copied from another machine, or checked into git),
|
||||
/// Hermes writes the credentials to the project-local `.hermes/auth.json`.
|
||||
/// Scarf then reads the global path on every dashboard tick and shows
|
||||
/// "missing provider" warnings even though the user did sign in successfully.
|
||||
///
|
||||
/// The detector enumerates the registered projects on a given server and
|
||||
/// reports which ones carry a shadowing `.hermes/`. Views surface a yellow
|
||||
/// banner so the user can consolidate.
|
||||
public struct ProjectHermesShadowDetector: Sendable {
|
||||
public struct Shadow: Sendable, Hashable, Identifiable {
|
||||
public var id: String { projectPath }
|
||||
/// Project name from the registry (`ProjectEntry.name`).
|
||||
public let projectName: String
|
||||
/// Absolute path to the project on the target server.
|
||||
public let projectPath: String
|
||||
/// Absolute path to the shadowing `.hermes/` directory.
|
||||
public let shadowPath: String
|
||||
/// `true` when the shadow `.hermes/auth.json` exists. Strong signal
|
||||
/// that user credentials are landing in the wrong place.
|
||||
public let hasAuthJSON: Bool
|
||||
/// `true` when the shadow `.hermes/state.db` exists. Hermes wrote
|
||||
/// session state to the project-local home — the user's chat
|
||||
/// history is invisible to Scarf's global Dashboard for this slice.
|
||||
public let hasStateDB: Bool
|
||||
|
||||
public init(
|
||||
projectName: String,
|
||||
projectPath: String,
|
||||
shadowPath: String,
|
||||
hasAuthJSON: Bool,
|
||||
hasStateDB: Bool
|
||||
) {
|
||||
self.projectName = projectName
|
||||
self.projectPath = projectPath
|
||||
self.shadowPath = shadowPath
|
||||
self.hasAuthJSON = hasAuthJSON
|
||||
self.hasStateDB = hasStateDB
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectHermesShadowDetector")
|
||||
#endif
|
||||
|
||||
private let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Probe every project in `projects` for a shadowing `.hermes/`. Skips
|
||||
/// archived projects and projects whose absolute path equals the
|
||||
/// resolved Hermes home (rare but possible — a project literally
|
||||
/// rooted at `~/.hermes` shouldn't trigger a self-warning).
|
||||
public func detect(in projects: [ProjectEntry]) async -> [Shadow] {
|
||||
let hermesHome = await context.resolvedUserHome() + "/.hermes"
|
||||
var found: [Shadow] = []
|
||||
for project in projects where !project.archived {
|
||||
// A project nested inside the Hermes home itself is a weird
|
||||
// edge case (someone made `~/.hermes/notes` a Scarf project).
|
||||
// The project is BELOW the Hermes home, so its `.hermes` is
|
||||
// the same dir as `~/.hermes/.hermes` — almost certainly not
|
||||
// present and definitely not a shadow.
|
||||
if project.path.hasPrefix(hermesHome) { continue }
|
||||
let shadowPath = project.path + "/.hermes"
|
||||
guard transport.fileExists(shadowPath) else { continue }
|
||||
// It's only a shadow if the path is a directory; a stray
|
||||
// `.hermes` file would be filtered out here.
|
||||
guard transport.stat(shadowPath)?.isDirectory == true else { continue }
|
||||
let hasAuth = transport.fileExists(shadowPath + "/auth.json")
|
||||
let hasDB = transport.fileExists(shadowPath + "/state.db")
|
||||
#if canImport(os)
|
||||
Self.logger.warning(
|
||||
"Detected shadow Hermes home at \(shadowPath, privacy: .public) (auth: \(hasAuth), state.db: \(hasDB))"
|
||||
)
|
||||
#endif
|
||||
found.append(Shadow(
|
||||
projectName: project.name,
|
||||
projectPath: project.path,
|
||||
shadowPath: shadowPath,
|
||||
hasAuthJSON: hasAuth,
|
||||
hasStateDB: hasDB
|
||||
))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// Suggested shell command the user can copy-paste / run on the remote
|
||||
/// to consolidate a shadow's auth.json into their global Hermes home.
|
||||
/// Skips state.db / sessions / skills migration intentionally — those
|
||||
/// require Hermes to be quiesced and risk data loss; the user should
|
||||
/// decide what to keep on a case-by-case basis. We give them the
|
||||
/// load-bearing one-liner (auth) and let them handle the rest.
|
||||
public static func consolidationCommand(for shadow: Shadow, hermesHome: String) -> String? {
|
||||
guard shadow.hasAuthJSON else { return nil }
|
||||
return "cp \(shadow.shadowPath)/auth.json \(hermesHome)/auth.json && chmod 600 \(hermesHome)/auth.json"
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,11 @@ public struct LocalTransport: ServerTransport {
|
||||
URL(fileURLWithPath: remotePath)
|
||||
}
|
||||
|
||||
/// Local transport reads the live DB directly — there's no cached
|
||||
/// snapshot to fall back to (and no failure mode where falling back
|
||||
/// would help, since a missing local file is missing both ways).
|
||||
public var cachedSnapshotPath: URL? { nil }
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
#if canImport(Darwin)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
/// Runs multi-line shell scripts on a server (local or SSH) without
|
||||
/// going through `ServerTransport.runProcess`.
|
||||
///
|
||||
/// **Why this exists.** `SSHTransport.runProcess` quotes every argument
|
||||
/// via `remotePathArg` (it rewrites `~/` → `$HOME/`), which is correct
|
||||
/// for path arguments but mangles a multi-line script containing
|
||||
/// `"$VAR"` references, nested quotes, and control structures. The
|
||||
/// remote receives a scrambled string and the script silently
|
||||
/// produces no useful output.
|
||||
///
|
||||
/// `RemoteDiagnosticsViewModel` originally documented this and worked
|
||||
/// around it locally. Issue #44 surfaced the same bug for the
|
||||
/// connection-status pill (multi-line probe script through
|
||||
/// `runProcess` → tier 2 always reads as failed even when the file
|
||||
/// is readable, while diagnostics — which used the workaround —
|
||||
/// reports 14/14 passing). This helper centralises the workaround so
|
||||
/// any future caller running a script gets it for free.
|
||||
///
|
||||
/// **Approach.** We invoke `/usr/bin/ssh ... -- /bin/sh -s` directly
|
||||
/// and pipe the script via stdin, so the script travels as a single
|
||||
/// opaque byte stream that the remote shell parses unchanged. Local
|
||||
/// contexts skip ssh and just pipe to `/bin/sh -s` — same shape so
|
||||
/// callers can treat both uniformly.
|
||||
public enum SSHScriptRunner {
|
||||
|
||||
public enum Outcome: Sendable {
|
||||
/// Couldn't even reach the remote (process spawn failed,
|
||||
/// timeout before any output, network refused). Carries the
|
||||
/// human-readable reason.
|
||||
case connectFailure(String)
|
||||
/// Script ran to completion (or until timeout cut it short
|
||||
/// after producing partial output). Exit code, stdout, stderr
|
||||
/// are reported as captured.
|
||||
case completed(stdout: String, stderr: String, exitCode: Int32)
|
||||
}
|
||||
|
||||
/// Run `script` against the given context. Times out after
|
||||
/// `timeout` seconds, killing the subprocess if it overruns.
|
||||
///
|
||||
/// **Platforms.** Real implementation is macOS-only — relies on
|
||||
/// `Foundation.Process` which iOS doesn't ship. iOS callers
|
||||
/// (ScarfGo) use Citadel-backed SSH transports for their own
|
||||
/// flows; they never reach this entry point. To keep ScarfCore
|
||||
/// cross-platform we return a connect failure on non-macOS so
|
||||
/// the file compiles everywhere.
|
||||
public static func run(script: String, context: ServerContext, timeout: TimeInterval = 30) async -> Outcome {
|
||||
#if os(macOS)
|
||||
switch context.kind {
|
||||
case .local:
|
||||
return await runLocally(script: script, timeout: timeout)
|
||||
case .ssh(let config):
|
||||
return await runOverSSH(script: script, config: config, timeout: timeout)
|
||||
}
|
||||
#else
|
||||
return .connectFailure("SSHScriptRunner is only available on macOS")
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - SSH path
|
||||
|
||||
#if os(macOS)
|
||||
private static func runOverSSH(script: String, config: SSHConfig, timeout: TimeInterval) async -> Outcome {
|
||||
var sshArgv: [String] = [
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=\(SSHTransport.controlDirPath())/%C",
|
||||
"-o", "ControlPersist=600",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "LogLevel=QUIET",
|
||||
"-o", "BatchMode=yes",
|
||||
"-T", // no pty — keep stdin/stdout a clean byte stream
|
||||
]
|
||||
if let port = config.port { sshArgv += ["-p", String(port)] }
|
||||
if let id = config.identityFile, !id.isEmpty {
|
||||
sshArgv += ["-i", id]
|
||||
}
|
||||
let hostSpec: String
|
||||
if let user = config.user, !user.isEmpty { hostSpec = "\(user)@\(config.host)" }
|
||||
else { hostSpec = config.host }
|
||||
sshArgv.append(hostSpec)
|
||||
sshArgv.append("--")
|
||||
sshArgv.append("/bin/sh")
|
||||
sshArgv.append("-s") // read script from stdin
|
||||
|
||||
return await Task.detached { () -> Outcome in
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
proc.arguments = sshArgv
|
||||
|
||||
// Inherit shell-derived SSH_AUTH_SOCK so ssh-agent reaches.
|
||||
// Same path SSHTransport uses internally — see
|
||||
// `environmentEnricher` set at app boot.
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
if let enricher = SSHTransport.environmentEnricher {
|
||||
let shellEnv = enricher()
|
||||
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
|
||||
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
|
||||
env[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
proc.environment = env
|
||||
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
proc.standardInput = stdinPipe
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
return .connectFailure("Failed to launch ssh: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
if let data = script.data(using: .utf8) {
|
||||
try? stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
||||
}
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while proc.isRunning && Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
return .connectFailure("Script timed out after \(Int(timeout))s")
|
||||
}
|
||||
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
// Best-effort fd close — Pipe leaks fd's otherwise.
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
return .completed(
|
||||
stdout: String(data: out, encoding: .utf8) ?? "",
|
||||
stderr: String(data: err, encoding: .utf8) ?? "",
|
||||
exitCode: proc.terminationStatus
|
||||
)
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - Local path
|
||||
|
||||
private static func runLocally(script: String, timeout: TimeInterval) async -> Outcome {
|
||||
return await Task.detached { () -> Outcome in
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
proc.arguments = ["-c", script]
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
return .connectFailure("Failed to launch /bin/sh: \(error.localizedDescription)")
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while proc.isRunning && Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
return .connectFailure("Script timed out after \(Int(timeout))s")
|
||||
}
|
||||
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
return .completed(
|
||||
stdout: String(data: out, encoding: .utf8) ?? "",
|
||||
stderr: String(data: err, encoding: .utf8) ?? "",
|
||||
exitCode: proc.terminationStatus
|
||||
)
|
||||
}.value
|
||||
}
|
||||
#endif // os(macOS)
|
||||
}
|
||||
@@ -603,6 +603,14 @@ public struct SSHTransport: ServerTransport {
|
||||
return URL(fileURLWithPath: localPath)
|
||||
}
|
||||
|
||||
/// Path where the most recent successful snapshot was written —
|
||||
/// returned even when the remote is currently unreachable. The
|
||||
/// data service falls back to this when `snapshotSQLite` throws so
|
||||
/// Dashboard / Sessions / Chat-history stay viewable offline.
|
||||
public var cachedSnapshotPath: URL? {
|
||||
URL(fileURLWithPath: snapshotDir + "/state.db")
|
||||
}
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
|
||||
@@ -90,6 +90,19 @@ public protocol ServerTransport: Sendable {
|
||||
/// `~/Library/Caches/scarf/<serverID>/state.db`, returning that URL.
|
||||
nonisolated func snapshotSQLite(remotePath: String) throws -> URL
|
||||
|
||||
/// Local filesystem URL where this transport caches its SQLite snapshot,
|
||||
/// returned even when the remote is unreachable. Callers should
|
||||
/// `FileManager.default.fileExists(atPath:)` before reading — the
|
||||
/// transport can't atomically check existence and return the URL
|
||||
/// in one step without TOCTOU. Local transports return `nil`
|
||||
/// (their data is the live DB, not a cache).
|
||||
///
|
||||
/// Used by `HermesDataService.open()` to fall back to the last
|
||||
/// successful snapshot when a fresh `snapshotSQLite` call fails,
|
||||
/// so the app keeps showing data with a "Last updated X ago"
|
||||
/// affordance instead of a blank screen.
|
||||
nonisolated var cachedSnapshotPath: URL? { get }
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
/// Observe changes to a set of paths and yield events when any of them
|
||||
|
||||
+153
-36
@@ -16,12 +16,17 @@ public final class ConnectionStatusViewModel {
|
||||
#endif
|
||||
|
||||
public enum Status: Equatable {
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/state.db`.
|
||||
case connected
|
||||
/// SSH connects but the follow-up read-access probe failed. Data
|
||||
/// views will be empty until this is resolved. `reason` is shown
|
||||
/// in the pill tooltip; users click the pill to open diagnostics.
|
||||
case degraded(reason: String)
|
||||
/// views will be empty until this is resolved.
|
||||
///
|
||||
/// `reason` is the short pill copy (e.g. `"can't read ~/.hermes/
|
||||
/// config.yaml"`); `hint` is a longer actionable string surfaced
|
||||
/// in the pill's quick popover so users see *why* and *what to do*
|
||||
/// without diving into the diagnostics sheet (issue #53). `cause`
|
||||
/// classifies the failure for UI branching.
|
||||
case degraded(reason: String, hint: String, cause: DegradedCause)
|
||||
/// No probe yet or the previous probe timed out but we haven't
|
||||
/// confirmed failure. Shown as yellow to tell the user "checking…".
|
||||
case idle
|
||||
@@ -30,6 +35,32 @@ public final class ConnectionStatusViewModel {
|
||||
case error(message: String, stderr: String)
|
||||
}
|
||||
|
||||
/// Specific tier-2 failure mode emitted by the probe script. Used to
|
||||
/// drive both the pill copy and the popover hint (issue #53).
|
||||
public enum DegradedCause: Equatable {
|
||||
/// `state.db` is missing entirely. Most common cause: Hermes
|
||||
/// is installed but no session has run on this remote yet.
|
||||
/// Case name kept as `configMissing` for back-compat with
|
||||
/// callers that pattern-match on it; "config" here is loose
|
||||
/// for "Scarf's required state file."
|
||||
case configMissing
|
||||
/// `~/.hermes` itself doesn't exist. Hermes isn't installed for
|
||||
/// the SSH user on this host.
|
||||
case homeMissing
|
||||
/// File exists but the SSH user can't read it. Permission /
|
||||
/// ownership mismatch. Same back-compat note as above.
|
||||
case configUnreadable
|
||||
/// `~/.hermes/active_profile` points at a non-default Hermes
|
||||
/// profile and the configured Hermes home doesn't carry the
|
||||
/// real config — the user is reading the wrong directory.
|
||||
/// Carries the active profile name so the hint can name it.
|
||||
case profileActive(name: String)
|
||||
/// Probe couldn't classify the failure precisely (e.g. older
|
||||
/// remote returned a binary `TIER2:1` without a tag). Falls
|
||||
/// back to a generic hint.
|
||||
case unknown
|
||||
}
|
||||
|
||||
public private(set) var status: Status = .idle
|
||||
/// Timestamp of the last successful probe. Used by the UI to show how
|
||||
/// fresh the status indicator is ("just now", "2m ago"…).
|
||||
@@ -42,12 +73,10 @@ public final class ConnectionStatusViewModel {
|
||||
private let consecutiveFailureThreshold = 2
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
private var probeTask: Task<Void, Never>?
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
if !context.isRemote {
|
||||
// Local contexts are always considered connected — no network
|
||||
// or auth can fail.
|
||||
@@ -80,14 +109,22 @@ public final class ConnectionStatusViewModel {
|
||||
}
|
||||
|
||||
private func probeOnce() async {
|
||||
let snapshot = transport
|
||||
let snapshot = context
|
||||
let hermesHome = context.paths.home
|
||||
// Two-tier probe in one SSH round-trip:
|
||||
// tier 1: `true` — raw connectivity / auth / ControlMaster path
|
||||
// tier 2: `test -r $HERMESHOME/config.yaml` — can we actually
|
||||
// read the file Dashboard reads on every tick? Green pill
|
||||
// only if both pass; yellow "degraded" if tier 1 passes
|
||||
// but tier 2 fails (the exact symptom in issue #19).
|
||||
// tier 2: `test -r $HERMESHOME/state.db` — can we actually read
|
||||
// the file Dashboard / Sessions / Activity all hit on
|
||||
// every tick? Green pill only if both pass.
|
||||
//
|
||||
// Probe historically targeted `config.yaml`, but Hermes v0.11+
|
||||
// doesn't materialize that file eagerly — it ships with sane
|
||||
// defaults and only writes config.yaml when the user actually
|
||||
// changes something. Result: a freshly-installed Hermes that's
|
||||
// running, persisting sessions, and serving Scarf was being
|
||||
// marked "degraded — config missing" indefinitely. `state.db`
|
||||
// is created on first agent run and is the actual surface
|
||||
// Scarf depends on, so we probe that instead.
|
||||
// Script emits two lines: TIER1:<exitcode> and TIER2:<exitcode>.
|
||||
let homeArg: String
|
||||
if hermesHome.hasPrefix("~/") {
|
||||
@@ -97,57 +134,82 @@ public final class ConnectionStatusViewModel {
|
||||
} else {
|
||||
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
|
||||
}
|
||||
// Probe emits a granular `TIER2:1:<cause>` code so the pill can
|
||||
// surface a specific hint (issue #53). Causes:
|
||||
// no-home — $H itself doesn't exist
|
||||
// missing — state.db absent (Hermes hasn't been run yet)
|
||||
// perm — exists but unreadable by SSH user
|
||||
// profile:<name> — state.db missing AND ~/.hermes/active_profile
|
||||
// points at a Hermes profile, suggesting Scarf
|
||||
// is reading the wrong dir
|
||||
let script = """
|
||||
echo TIER1:0
|
||||
H=\(homeArg)
|
||||
if [ -r "$H/config.yaml" ]; then echo TIER2:0; else echo TIER2:1; fi
|
||||
if [ -r "$H/state.db" ]; then
|
||||
echo TIER2:0
|
||||
elif [ ! -d "$H" ]; then
|
||||
echo TIER2:1:no-home
|
||||
elif [ ! -e "$H/state.db" ]; then
|
||||
ACTIVE=""
|
||||
if [ -r "$HOME/.hermes/active_profile" ]; then
|
||||
ACTIVE=$(head -n1 "$HOME/.hermes/active_profile" 2>/dev/null | tr -d ' \\t\\r\\n')
|
||||
fi
|
||||
if [ -n "$ACTIVE" ] && [ "$ACTIVE" != "default" ]; then
|
||||
echo TIER2:1:profile:$ACTIVE
|
||||
else
|
||||
echo TIER2:1:missing
|
||||
fi
|
||||
else
|
||||
echo TIER2:1:perm
|
||||
fi
|
||||
"""
|
||||
|
||||
enum ProbeOutcome {
|
||||
case connected
|
||||
case degraded(reason: String)
|
||||
case degraded(reason: String, hint: String, cause: DegradedCause)
|
||||
case failure(TransportError)
|
||||
}
|
||||
|
||||
let outcome: ProbeOutcome = await Task.detached {
|
||||
do {
|
||||
let probe = try snapshot.runProcess(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", script],
|
||||
stdin: nil,
|
||||
timeout: 10
|
||||
)
|
||||
guard probe.exitCode == 0 else {
|
||||
return .failure(.commandFailed(exitCode: probe.exitCode, stderr: probe.stderrString))
|
||||
// Issue #44: previously this used `transport.runProcess(executable:
|
||||
// "/bin/sh", args: ["-c", script])`, which goes through
|
||||
// SSHTransport's `remotePathArg` quoting. That mangles multi-line
|
||||
// shell scripts containing `"$VAR"` references and nested
|
||||
// quotes — the remote received a scrambled string and the if-test
|
||||
// for config.yaml readability silently failed even when the file
|
||||
// was readable. Result: 14/14 diagnostics passing AND a stuck
|
||||
// "Connected — can't read Hermes state" pill, simultaneously,
|
||||
// because diagnostics had its own runOverSSH workaround. Now
|
||||
// both paths use SSHScriptRunner so they always agree.
|
||||
let outcome: ProbeOutcome = await {
|
||||
let result = await SSHScriptRunner.run(script: script, context: snapshot, timeout: 10)
|
||||
switch result {
|
||||
case .connectFailure(let msg):
|
||||
return .failure(.other(message: msg))
|
||||
case .completed(let out, let stderr, let exitCode):
|
||||
guard exitCode == 0 else {
|
||||
return .failure(.commandFailed(exitCode: exitCode, stderr: stderr))
|
||||
}
|
||||
let out = probe.stdoutString
|
||||
let tier1 = out.contains("TIER1:0")
|
||||
let tier2 = out.contains("TIER2:0")
|
||||
if !tier1 {
|
||||
// The script itself didn't reach tier 1 — treat as connection failure.
|
||||
return .failure(.commandFailed(exitCode: 1, stderr: out))
|
||||
}
|
||||
if tier2 {
|
||||
return .connected
|
||||
}
|
||||
// Connected but can't read config.yaml — the core issue #19
|
||||
// symptom. Give the pill a short reason; the full story goes
|
||||
// into Remote Diagnostics.
|
||||
return .degraded(reason: "can't read ~/.hermes/config.yaml")
|
||||
} catch let e as TransportError {
|
||||
return .failure(e)
|
||||
} catch {
|
||||
return .failure(.other(message: error.localizedDescription))
|
||||
let cause = Self.parseDegradedCause(stdout: out)
|
||||
let (reason, hint) = Self.describe(cause: cause, hermesHome: hermesHome)
|
||||
return .degraded(reason: reason, hint: hint, cause: cause)
|
||||
}
|
||||
}.value
|
||||
}()
|
||||
|
||||
switch outcome {
|
||||
case .connected:
|
||||
status = .connected
|
||||
lastSuccess = Date()
|
||||
consecutiveFailures = 0
|
||||
case .degraded(let reason):
|
||||
status = .degraded(reason: reason)
|
||||
case .degraded(let reason, let hint, let cause):
|
||||
status = .degraded(reason: reason, hint: hint, cause: cause)
|
||||
lastSuccess = Date() // SSH itself is fine, reset failure count
|
||||
consecutiveFailures = 0
|
||||
case .failure(let err):
|
||||
@@ -176,4 +238,59 @@ public final class ConnectionStatusViewModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull a `DegradedCause` out of the probe stdout. Looks for the
|
||||
/// `TIER2:1:<code>[:detail]` line; falls back to `.unknown` when
|
||||
/// only the legacy binary `TIER2:1` is present (older remotes,
|
||||
/// future-proofs against accidental tag drops).
|
||||
nonisolated static func parseDegradedCause(stdout: String) -> DegradedCause {
|
||||
for raw in stdout.split(separator: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("TIER2:1:") else { continue }
|
||||
let body = String(line.dropFirst("TIER2:1:".count))
|
||||
if body == "no-home" { return .homeMissing }
|
||||
if body == "missing" { return .configMissing }
|
||||
if body == "perm" { return .configUnreadable }
|
||||
if body.hasPrefix("profile:") {
|
||||
let name = String(body.dropFirst("profile:".count))
|
||||
if !name.isEmpty {
|
||||
return .profileActive(name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
|
||||
/// Map a `DegradedCause` into the pill's short `reason` (single line,
|
||||
/// fits in a tooltip) and longer `hint` (popover body, can carry
|
||||
/// commands the user can copy).
|
||||
nonisolated static func describe(cause: DegradedCause, hermesHome: String) -> (reason: String, hint: String) {
|
||||
switch cause {
|
||||
case .homeMissing:
|
||||
return (
|
||||
"Hermes not installed on remote",
|
||||
"`\(hermesHome)` doesn't exist on the remote. Install Hermes for the SSH user, or — if Hermes is already installed under a different path — set this server's Hermes home in Manage Servers."
|
||||
)
|
||||
case .configMissing:
|
||||
return (
|
||||
"Hermes hasn't been run yet",
|
||||
"`\(hermesHome)/state.db` is missing — Hermes creates it on first agent run. Start any session on the remote (e.g. `hermes chat`) and Scarf will go green automatically."
|
||||
)
|
||||
case .configUnreadable:
|
||||
return (
|
||||
"Permission denied on state.db",
|
||||
"`\(hermesHome)/state.db` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/state.db`. Either run Hermes as the SSH user, `chmod a+r` the file, or SSH as the Hermes user."
|
||||
)
|
||||
case .profileActive(let name):
|
||||
return (
|
||||
"Hermes profile \"\(name)\" is active",
|
||||
"The remote is using Hermes profile `\(name)` — its state lives at `~/.hermes/profiles/\(name)/state.db`, not `\(hermesHome)/state.db`. Either set this server's Hermes home to `~/.hermes/profiles/\(name)` in Manage Servers → Edit, or run `hermes profile use default` on the remote to revert."
|
||||
)
|
||||
case .unknown:
|
||||
return (
|
||||
"Can't read Hermes state",
|
||||
"SSH is fine but Scarf can't reach `\(hermesHome)/state.db`. Run diagnostics for a full breakdown."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,21 @@ public struct MessageGroup: Identifiable {
|
||||
public var toolCallCount: Int {
|
||||
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
|
||||
}
|
||||
|
||||
/// Aggregated `ToolKind → count` over all assistant tool calls in
|
||||
/// this group. Lives on the model so SwiftUI's Equatable
|
||||
/// short-circuit (issue #46) covers it — previously this was a
|
||||
/// `MessageGroupView` computed property that re-walked O(m × k)
|
||||
/// per group on every body re-evaluation.
|
||||
public var toolKindCounts: [ToolKind: Int] {
|
||||
var counts: [ToolKind: Int] = [:]
|
||||
for msg in assistantMessages where msg.isAssistant {
|
||||
for call in msg.toolCalls {
|
||||
counts[call.toolKind, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@@ -324,6 +339,20 @@ public final class RichChatViewModel {
|
||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||
/// Used to combine old CLI messages with new ACP messages.
|
||||
public private(set) var originSessionId: String?
|
||||
/// Smallest DB id currently loaded for the *current session* (i.e.
|
||||
/// `sessionId`). Drives `loadEarlier()`: page back with
|
||||
/// `before: oldestLoadedMessageID`. `nil` when nothing has been
|
||||
/// loaded yet or the session has no DB-persisted messages.
|
||||
public private(set) var oldestLoadedMessageID: Int?
|
||||
/// Whether the most recent fetch suggests there are more older
|
||||
/// messages on disk that haven't been loaded into `messages` yet.
|
||||
/// Set to `true` when the initial fetch returned exactly `limit`
|
||||
/// rows (a strong hint the table has more). Drives the "Load
|
||||
/// earlier" button visibility in chat views.
|
||||
public private(set) var hasMoreHistory: Bool = false
|
||||
/// Cleared during a `loadEarlier()` fetch so the UI can show a
|
||||
/// spinner and we don't fan out duplicate page requests.
|
||||
public private(set) var isLoadingEarlier: Bool = false
|
||||
private var nextLocalId = -1
|
||||
private var streamingAssistantText = ""
|
||||
private var streamingThinkingText = ""
|
||||
@@ -367,6 +396,9 @@ public final class RichChatViewModel {
|
||||
lastKnownFingerprint = nil
|
||||
sessionId = nil
|
||||
originSessionId = nil
|
||||
oldestLoadedMessageID = nil
|
||||
hasMoreHistory = false
|
||||
isLoadingEarlier = false
|
||||
isAgentWorking = false
|
||||
userSendPending = false
|
||||
resetTimestamp = Date()
|
||||
@@ -759,7 +791,42 @@ public final class RichChatViewModel {
|
||||
} else {
|
||||
messages.append(msg)
|
||||
}
|
||||
buildMessageGroups()
|
||||
patchTrailingGroupForStreaming(streamingMsg: msg)
|
||||
}
|
||||
|
||||
/// Per-chunk fast path for `messageGroups` (issue #46). Mutates
|
||||
/// only the trailing group's assistant entry instead of rebuilding
|
||||
/// the entire `messageGroups` array via `buildMessageGroups()` on
|
||||
/// every streamed token.
|
||||
///
|
||||
/// Falls back to a full rebuild whenever it can't safely patch:
|
||||
/// - no trailing group exists yet (e.g. first chunk after `reset`)
|
||||
/// - the trailing group is a user-only group (the very first chunk
|
||||
/// of a brand-new turn — we need a full rebuild so the assistant
|
||||
/// is grouped under the right user message)
|
||||
///
|
||||
/// Other call sites of `buildMessageGroups()` are intentionally
|
||||
/// untouched: they handle structural events (user message, tool
|
||||
/// call complete, finalize, session resume) where group boundaries
|
||||
/// can change, and a full rebuild is the right move there.
|
||||
private func patchTrailingGroupForStreaming(streamingMsg: HermesMessage) {
|
||||
guard let lastIdx = messageGroups.indices.last else {
|
||||
buildMessageGroups()
|
||||
return
|
||||
}
|
||||
let trailing = messageGroups[lastIdx]
|
||||
var assistants = trailing.assistantMessages
|
||||
if let i = assistants.firstIndex(where: { $0.id == Self.streamingId }) {
|
||||
assistants[i] = streamingMsg
|
||||
} else {
|
||||
assistants.append(streamingMsg)
|
||||
}
|
||||
messageGroups[lastIdx] = MessageGroup(
|
||||
id: trailing.id,
|
||||
userMessage: trailing.userMessage,
|
||||
assistantMessages: assistants,
|
||||
toolResults: trailing.toolResults
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
|
||||
@@ -825,12 +892,15 @@ public final class RichChatViewModel {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
// Reconnects don't generate hundreds of unseen messages, so a
|
||||
// 200-row tail is plenty for the merge — and it keeps us from
|
||||
// re-materializing 1000+ message sessions on every reconnect.
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId, limit: HistoryPageSize.reconcile)
|
||||
|
||||
// If we have an origin session (CLI session continued via ACP),
|
||||
// include those messages too
|
||||
if let origin = originSessionId, origin != sessionId {
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin)
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin, limit: HistoryPageSize.reconcile)
|
||||
if !originMessages.isEmpty {
|
||||
dbMessages = originMessages + dbMessages
|
||||
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
@@ -875,10 +945,18 @@ public final class RichChatViewModel {
|
||||
// would have cached a stale copy — on resume we need whatever
|
||||
// Hermes has actually persisted since then, or the resumed session
|
||||
// will show only history up to the moment the snapshot was taken.
|
||||
let opened = await dataService.refresh()
|
||||
// `forceFresh: true` refuses the stale-snapshot fallback the data
|
||||
// service grew in M11 — falling back here would silently hide
|
||||
// messages the agent streamed during the user's offline window.
|
||||
let opened = await dataService.refresh(forceFresh: true)
|
||||
guard opened else { return }
|
||||
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let pageSize = HistoryPageSize.initial
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId, limit: pageSize)
|
||||
// The DB has more on-disk history when the initial fetch
|
||||
// saturated the limit. The "Load earlier" affordance reads
|
||||
// this flag.
|
||||
var moreHistory = allMessages.count >= pageSize
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
|
||||
// If the ACP session is different from the origin, load its messages too
|
||||
@@ -886,10 +964,11 @@ public final class RichChatViewModel {
|
||||
if let acpId = acpSessionId, acpId != sessionId {
|
||||
originSessionId = sessionId
|
||||
self.sessionId = acpId
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId)
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId, limit: pageSize)
|
||||
if !acpMessages.isEmpty {
|
||||
allMessages.append(contentsOf: acpMessages)
|
||||
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
moreHistory = moreHistory || acpMessages.count >= pageSize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,6 +976,51 @@ public final class RichChatViewModel {
|
||||
currentSession = session
|
||||
let minId = allMessages.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
// Track the oldest loaded id from THIS session (not the merged
|
||||
// origin) so `loadEarlier()` pages back through the live ACP
|
||||
// session's history. Cross-session backfill (paging into the
|
||||
// CLI origin) isn't supported in v1 — the merged 2× pageSize
|
||||
// is enough headroom for the dashboard-resume case.
|
||||
let currentSessionId = self.sessionId ?? sessionId
|
||||
oldestLoadedMessageID = allMessages
|
||||
.filter { $0.sessionId == currentSessionId }
|
||||
.map(\.id)
|
||||
.min()
|
||||
hasMoreHistory = moreHistory
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Load Earlier (pagination)
|
||||
|
||||
/// Page back through the current session's DB-persisted history
|
||||
/// before `oldestLoadedMessageID` and prepend the page to
|
||||
/// `messages`. Cheap on the SQLite side (`id` is the primary
|
||||
/// key); the cost is the data-service `open()` round-trip on
|
||||
/// remote contexts. `pageSize` defaults to the same 200-row
|
||||
/// budget as the initial load.
|
||||
public func loadEarlier(pageSize: Int = HistoryPageSize.initial) async {
|
||||
guard !isLoadingEarlier, hasMoreHistory else { return }
|
||||
guard let sessionId, let oldest = oldestLoadedMessageID else { return }
|
||||
isLoadingEarlier = true
|
||||
defer { isLoadingEarlier = false }
|
||||
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
let older = await dataService.fetchMessages(
|
||||
sessionId: sessionId,
|
||||
limit: pageSize,
|
||||
before: oldest
|
||||
)
|
||||
guard !older.isEmpty else {
|
||||
hasMoreHistory = false
|
||||
return
|
||||
}
|
||||
messages.insert(contentsOf: older, at: 0)
|
||||
oldestLoadedMessageID = older.first?.id
|
||||
// If this fetch returned fewer than the page size we've hit
|
||||
// the bottom of the table — no further pages worth fetching.
|
||||
hasMoreHistory = older.count >= pageSize
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
@@ -940,7 +1064,7 @@ public final class RichChatViewModel {
|
||||
let fingerprint = await dataService.fetchMessageFingerprint(sessionId: sessionId)
|
||||
|
||||
if fingerprint != lastKnownFingerprint {
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId, limit: HistoryPageSize.polling)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
lastKnownFingerprint = fingerprint
|
||||
|
||||
|
||||
@@ -165,6 +165,15 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
||||
try runSync { try await self.asyncSnapshotSQLite(remotePath: remotePath) }
|
||||
}
|
||||
|
||||
/// Path where the most recent successful snapshot was written —
|
||||
/// returned even when the SSH connection is currently down. The
|
||||
/// data service falls back to this when `snapshotSQLite` throws so
|
||||
/// Dashboard / Sessions / Chat-history stay viewable while the
|
||||
/// phone is offline.
|
||||
public var cachedSnapshotPath: URL? {
|
||||
snapshotBaseDir.appendingPathComponent("state.db")
|
||||
}
|
||||
|
||||
// MARK: - ServerTransport: watching
|
||||
|
||||
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
@@ -398,8 +407,76 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
||||
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
|
||||
// Double-quote paths; $HOME expansion happens inside double quotes.
|
||||
let rewritten = Self.rewriteHomeRelative(remotePath)
|
||||
let backupScript = #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
|
||||
_ = try await client.executeCommand(backupScript + " 2>&1")
|
||||
|
||||
// Prepend the same PATH prefix `asyncRunProcess` uses so `sqlite3`
|
||||
// resolves on hosts where it lives in /usr/local/bin or
|
||||
// /opt/homebrew/bin (issue #56). Citadel's bare exec channel
|
||||
// inherits a stripped PATH (typically `/usr/bin:/bin` on Linux);
|
||||
// without this, statically-linked or custom-prefix sqlite3
|
||||
// installs fail "command not found" at exit 127.
|
||||
let backupScript =
|
||||
#"PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" "#
|
||||
+ #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
|
||||
|
||||
// Drive `executeCommandStream` instead of `executeCommand` so we
|
||||
// capture stderr regardless of exit code (issue #56). Pre-fix
|
||||
// a non-zero exit threw `CommandFailed` and discarded the buffer
|
||||
// — surfaced as the unhelpful "Citadel.SSHClient.CommandFailed
|
||||
// error 1" banner. Now we propagate the real stderr so
|
||||
// `HermesDataService.humanize` can translate "sqlite3: command
|
||||
// not found" / "no such file" / "permission denied" into the
|
||||
// dashboard banner with actionable copy.
|
||||
let stream: AsyncThrowingStream<ExecCommandOutput, Error>
|
||||
do {
|
||||
stream = try await client.executeCommandStream(backupScript)
|
||||
} catch {
|
||||
throw NSError(
|
||||
domain: "CitadelServerTransport",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to start snapshot stream: \(error.localizedDescription)"]
|
||||
)
|
||||
}
|
||||
var stdout = Data()
|
||||
var stderr = Data()
|
||||
var exitCode: Int32 = 0
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
switch chunk {
|
||||
case .stdout(var buf):
|
||||
if let s = buf.readString(length: buf.readableBytes) {
|
||||
stdout.append(Data(s.utf8))
|
||||
}
|
||||
case .stderr(var buf):
|
||||
if let s = buf.readString(length: buf.readableBytes) {
|
||||
stderr.append(Data(s.utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch let failed as SSHClient.CommandFailed {
|
||||
exitCode = Int32(failed.exitCode)
|
||||
} catch {
|
||||
stderr.append(Data(error.localizedDescription.utf8))
|
||||
exitCode = -1
|
||||
}
|
||||
if exitCode != 0 {
|
||||
// Combine stdout + stderr into the error message — sqlite3
|
||||
// sometimes prints "Error: ..." on stdout depending on the
|
||||
// remote shell. HermesDataService.humanize keys off
|
||||
// substrings like "sqlite3: command not found",
|
||||
// "permission denied", "no such file", so as long as one of
|
||||
// them ends up in the message we get a useful banner.
|
||||
let messageBytes = stderr.isEmpty ? stdout : stderr
|
||||
let message = String(data: messageBytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
throw NSError(
|
||||
domain: "CitadelServerTransport",
|
||||
code: Int(exitCode),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: message.isEmpty
|
||||
? "Snapshot exited \(exitCode) with no output (likely sqlite3 missing on remote)"
|
||||
: message
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// SFTP-download the remote tmp into our local snapshot cache.
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
|
||||
@@ -17,9 +17,18 @@ import ScarfCore
|
||||
/// go here; v1 item is migrated into v2 on first `listAll()` after
|
||||
/// the upgrade, then removed.
|
||||
///
|
||||
/// All items use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
|
||||
/// so they're reachable after a single device unlock (background
|
||||
/// tasks, notification actions) but never sync to iCloud Keychain.
|
||||
/// **Accessibility / sync attributes.** Default behavior pins items
|
||||
/// to this device with `kSecAttrAccessibleAfterFirstUnlockThisDevice
|
||||
/// Only` + `kSecAttrSynchronizable=false`. Users can opt into iCloud
|
||||
/// Keychain sync via `SSHKeyICloudPreference` (issue #52); when
|
||||
/// enabled, writes use `kSecAttrAccessibleAfterFirstUnlock` (no
|
||||
/// `ThisDeviceOnly` suffix) + `kSecAttrSynchronizable=true` so the
|
||||
/// key is picked up by iCloud Keychain on every signed-in device.
|
||||
///
|
||||
/// All read / list / delete queries pass `kSecAttrSynchronizable =
|
||||
/// kSecAttrSynchronizableAny` so they match items regardless of
|
||||
/// sync state — load-bearing during the migration window when
|
||||
/// device-only and synced items can briefly coexist.
|
||||
public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
public static let defaultService = "com.scarf.ssh-key"
|
||||
public static let legacyV1Account = "primary"
|
||||
@@ -56,10 +65,12 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
|
||||
public func delete() async throws {
|
||||
// Wipe every v2 entry + the legacy v1 entry. Single-query delete
|
||||
// that matches any account under our service.
|
||||
// that matches any account under our service. Pass `Any` so the
|
||||
// wipe catches synced + device-only items uniformly (issue #52).
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
@@ -74,10 +85,13 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
public func listAll() async throws -> [ServerID] {
|
||||
migrateLegacyIfNeeded()
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
// Match items regardless of sync state (issue #52). Without
|
||||
// this the listing silently misses synced items.
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
]
|
||||
var items: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &items)
|
||||
@@ -115,15 +129,60 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
try deleteBundle(account: Self.multiAccountPrefix + id.uuidString)
|
||||
}
|
||||
|
||||
// MARK: - iCloud sync migration (issue #52)
|
||||
|
||||
/// Migrate every stored key bundle to the requested sync state and
|
||||
/// persist the user's preference for future writes.
|
||||
///
|
||||
/// Idempotent: if the user enables sync twice in a row the second
|
||||
/// call simply re-saves with the same attributes. Safe to call
|
||||
/// from a UI toggle handler. Errors thrown by individual key
|
||||
/// re-writes propagate; partial migrations are tolerable because
|
||||
/// the read paths use `kSecAttrSynchronizableAny` and pick up
|
||||
/// either copy on the next read.
|
||||
///
|
||||
/// Side effects:
|
||||
/// - Each stored key is read with `Any`, deleted with `Any`, then
|
||||
/// re-saved with the target sync attributes via `writeBundle(_:account:syncToICloud:)`.
|
||||
/// - The legacy v1 entry (if present) is migrated to the v2 layout
|
||||
/// with the new attributes in passing.
|
||||
/// - `SSHKeyICloudPreference.isEnabled` is set BEFORE the rewrite
|
||||
/// loop so any concurrent `save(_:)` call from another path
|
||||
/// already uses the right attributes.
|
||||
public func migrateAllItems(toICloudSync enabled: Bool) async throws {
|
||||
SSHKeyICloudPreference.isEnabled = enabled
|
||||
|
||||
// Pull every v2 + v1 bundle into memory first. We can't iterate
|
||||
// and rewrite simultaneously: deleting an item we're about to
|
||||
// re-add would race with the listing query.
|
||||
var bundles: [(account: String, bundle: SSHKeyBundle)] = []
|
||||
for id in try await listAll() {
|
||||
if let bundle = try await load(for: id) {
|
||||
bundles.append((Self.multiAccountPrefix + id.uuidString, bundle))
|
||||
}
|
||||
}
|
||||
if let legacy = try? readLegacy() {
|
||||
bundles.append((Self.legacyV1Account, legacy))
|
||||
}
|
||||
|
||||
for (account, bundle) in bundles {
|
||||
try writeBundle(bundle, account: account, syncToICloud: enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private — Keychain plumbing per-account
|
||||
|
||||
private func readBundle(account: String) throws -> SSHKeyBundle? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
// Match items regardless of sync state (issue #52). Without
|
||||
// this the query implicitly defaults to false and orphans
|
||||
// any items that have been migrated to iCloud sync.
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
@@ -149,6 +208,13 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
}
|
||||
|
||||
private func writeBundle(_ bundle: SSHKeyBundle, account: String) throws {
|
||||
try writeBundle(bundle, account: account, syncToICloud: SSHKeyICloudPreference.isEnabled)
|
||||
}
|
||||
|
||||
/// Write path with explicit sync control. Used by the public
|
||||
/// migration helper to force a target sync state regardless of
|
||||
/// the current preference.
|
||||
private func writeBundle(_ bundle: SSHKeyBundle, account: String, syncToICloud: Bool) throws {
|
||||
let data: Data
|
||||
do {
|
||||
data = try JSONEncoder().encode(bundle)
|
||||
@@ -157,17 +223,34 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
message: "Encode failed: \(error.localizedDescription)", osStatus: nil
|
||||
)
|
||||
}
|
||||
let baseQuery: [String: Any] = [
|
||||
// Delete with kSecAttrSynchronizableAny to clear out any prior
|
||||
// copy regardless of its sync state — without this a flip from
|
||||
// synced → device-only could leave the synced copy behind and
|
||||
// create two competing items at the same (service, account).
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
var attributes: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(baseQuery as CFDictionary)
|
||||
|
||||
var attributes = baseQuery
|
||||
attributes[kSecValueData as String] = data
|
||||
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
attributes[kSecAttrSynchronizable as String] = kCFBooleanFalse
|
||||
if syncToICloud {
|
||||
// iCloud Keychain requires the non-`ThisDeviceOnly` accessible
|
||||
// class — items with the `ThisDeviceOnly` suffix are silently
|
||||
// skipped by the sync engine.
|
||||
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
attributes[kSecAttrSynchronizable as String] = kCFBooleanTrue
|
||||
} else {
|
||||
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
attributes[kSecAttrSynchronizable as String] = kCFBooleanFalse
|
||||
}
|
||||
|
||||
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
@@ -179,9 +262,10 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
|
||||
private func deleteBundle(account: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
@@ -217,10 +301,13 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
/// triggering a recursive migration.
|
||||
private func listAllInternal(skipMigration: Bool) throws -> [ServerID] {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
// Match items regardless of sync state (issue #52). Without
|
||||
// this the listing silently misses synced items.
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
]
|
||||
var items: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &items)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Process-wide reachability monitor wrapping `NWPathMonitor`. Used by
|
||||
/// `ChatController` to decide when to attempt a reconnect (on
|
||||
/// `.satisfied`) vs. mark the chat offline (on `.unsatisfied`).
|
||||
///
|
||||
/// Singleton because `NWPathMonitor` is per-process by design — there's
|
||||
/// no benefit to instantiating multiple monitors and the cost (a small
|
||||
/// background queue per instance) accumulates if every controller
|
||||
/// spawns its own.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// Don't read the published state from a SwiftUI view body — the
|
||||
/// runtime samples through `NWPathMonitor`'s queue, but a `body`
|
||||
/// re-evaluation that touches `currentPath` directly would block. Read
|
||||
/// `isSatisfied` / observe `transitionTick` instead. Tests and
|
||||
/// non-iOS callers can use the no-op default behavior (`isSatisfied`
|
||||
/// reports `true`).
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class NetworkReachabilityService {
|
||||
public static let shared = NetworkReachabilityService()
|
||||
|
||||
/// `true` when the OS reports a usable network path (any
|
||||
/// interface). Inverted via `!isSatisfied` for "we're offline."
|
||||
public private(set) var isSatisfied: Bool = true
|
||||
|
||||
/// Mirrors `NWPath.isExpensive`. Useful as a hint to UI for not
|
||||
/// auto-fetching big payloads on cellular. Not consumed yet —
|
||||
/// reserved so callers don't have to add another property later.
|
||||
public private(set) var isExpensive: Bool = false
|
||||
|
||||
/// Monotonic counter that bumps every time `isSatisfied` changes.
|
||||
/// Views observe `transitionTick` rather than `isSatisfied` to
|
||||
/// kick a `.onChange` even if the value is the same as before
|
||||
/// (rare but possible during rapid network flapping).
|
||||
public private(set) var transitionTick: Int = 0
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.scarf.ios.reachability")
|
||||
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf.ios", category: "NetworkReachability")
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
// Seed from the current path synchronously so first reads on
|
||||
// launch don't show "satisfied" while the OS reports otherwise.
|
||||
// `currentPath` is safe here at init (the monitor hasn't been
|
||||
// started yet, no queue handler is firing).
|
||||
let initial = monitor.currentPath
|
||||
self.isSatisfied = (initial.status == .satisfied)
|
||||
self.isExpensive = initial.isExpensive
|
||||
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
// Bounce back through MainActor — the `Observable`
|
||||
// protocol's published-property invariants require main-
|
||||
// thread mutation. The pathUpdateHandler is invoked on
|
||||
// `queue`, which is a private background queue.
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let satisfied = (path.status == .satisfied)
|
||||
if self.isSatisfied != satisfied {
|
||||
self.isSatisfied = satisfied
|
||||
self.transitionTick &+= 1
|
||||
#if canImport(os)
|
||||
Self.logger.info(
|
||||
"Reachability transition: \(satisfied ? "satisfied" : "unsatisfied", privacy: .public)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
self.isExpensive = path.isExpensive
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Singleton is process-lifetime; this only runs on shutdown.
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Apple-only: Security.framework + UserDefaults are iOS/Mac only.
|
||||
// On Linux this file is skipped; tests don't exercise it.
|
||||
#if canImport(Security)
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Device-local preference: should the SSH key bundle stored in the
|
||||
/// iOS Keychain sync to iCloud Keychain (issue #52)?
|
||||
///
|
||||
/// **Default `false`.** Existing installs see no change on update; the
|
||||
/// key remains pinned to the device with `kSecAttrAccessibleAfter
|
||||
/// FirstUnlockThisDeviceOnly` + `kSecAttrSynchronizable=false`. Users
|
||||
/// who opt in via Settings → Security trigger a one-shot migration
|
||||
/// that re-saves all stored keys with `kSecAttrAccessibleAfterFirst
|
||||
/// Unlock` + `kSecAttrSynchronizable=true` so iCloud Keychain picks
|
||||
/// them up.
|
||||
///
|
||||
/// **Trade-off the UI must surface clearly.**
|
||||
/// - On: convenient multi-device — iPhone + iPad + Mac all see the
|
||||
/// same key. End-to-end encrypted by iCloud Keychain (Apple-managed
|
||||
/// keys without ADP, user-managed keys with ADP). Requires iCloud
|
||||
/// Keychain enabled on every device.
|
||||
/// - Off (default): key never leaves this device. Each device must
|
||||
/// onboard separately (generate its own key, append its pubkey to
|
||||
/// `authorized_keys`).
|
||||
public enum SSHKeyICloudPreference {
|
||||
|
||||
/// UserDefaults key. Stable string so a v2 future fix can read
|
||||
/// existing values without migration.
|
||||
public static let key = "scarf.icloud.syncSSHKey"
|
||||
|
||||
/// Read the current preference. Defaults to `false`.
|
||||
public static var isEnabled: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: key) }
|
||||
set { UserDefaults.standard.set(newValue, forKey: key) }
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(Security)
|
||||
@@ -31,6 +31,28 @@ final class ScarfGoCoordinator {
|
||||
/// `AppCoordinator.pendingProjectChat`.
|
||||
var pendingProjectChat: String?
|
||||
|
||||
/// Most-recent scene-phase value observed at the WindowGroup
|
||||
/// level. Tab-specific view models (e.g. `ChatController`)
|
||||
/// observe `scenePhaseTick` to react to transitions even when
|
||||
/// they're on a non-foreground tab — `.onChange(of: ScenePhase)`
|
||||
/// alone wouldn't fire for views that aren't on screen.
|
||||
private(set) var scenePhase: ScenePhase = .active
|
||||
private(set) var scenePhaseTick: Int = 0
|
||||
/// Wallclock when we last observed `.background`. Used by tab
|
||||
/// view-models to decide whether a quick `.active` transition is
|
||||
/// worth a full re-verify (long suspensions warrant it; brief
|
||||
/// notification-center peeks don't). `nil` until the first
|
||||
/// background transition.
|
||||
private(set) var lastBackgroundedAt: Date?
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
if phase == .background, scenePhase != .background {
|
||||
lastBackgroundedAt = Date()
|
||||
}
|
||||
scenePhase = phase
|
||||
scenePhaseTick &+= 1
|
||||
}
|
||||
|
||||
enum Tab: Hashable {
|
||||
case dashboard, projects, chat, skills, system
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ struct ScarfGoTabRoot: View {
|
||||
/// through here.
|
||||
@State private var coordinator = ScarfGoCoordinator()
|
||||
|
||||
/// SwiftUI's `.onChange(of: ScenePhase)` modifier on a non-active
|
||||
/// tab doesn't fire while the tab is unmounted — the coordinator
|
||||
/// is the single source of truth for scene-phase transitions
|
||||
/// across all tabs.
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
// The transport factory is keyed by ServerID, so the correct
|
||||
// Keychain slot + config is picked automatically. Reuses the
|
||||
@@ -119,6 +125,12 @@ struct ScarfGoTabRoot: View {
|
||||
// just observes.
|
||||
NotificationRouter.shared.coordinator = coordinator
|
||||
}
|
||||
// Funnel scene-phase transitions through the coordinator so
|
||||
// tab view-models (notably ChatController) can react even
|
||||
// when their tab isn't currently on-screen.
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
coordinator.setScenePhase(newPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +150,13 @@ private struct SystemTab: View {
|
||||
@State private var showForgetConfirmation = false
|
||||
@State private var isForgetting = false
|
||||
@State private var isDisconnecting = false
|
||||
/// Mirror of `SSHKeyICloudPreference.isEnabled` — drives the iCloud
|
||||
/// Keychain sync toggle (issue #52). Initial value is read on view
|
||||
/// init so the toggle reflects today's preference before the user
|
||||
/// taps anything; flipping triggers `migrateAllItems(toICloudSync:)`.
|
||||
@State private var iCloudSyncEnabled: Bool = SSHKeyICloudPreference.isEnabled
|
||||
@State private var iCloudMigrationInFlight = false
|
||||
@State private var iCloudMigrationError: String?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -178,6 +197,67 @@ private struct SystemTab: View {
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(isOn: $iCloudSyncEnabled) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "key.icloud.fill")
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Sync SSH key with iCloud Keychain")
|
||||
Text(iCloudSyncEnabled
|
||||
? "Synced — your other Apple devices with iCloud Keychain will see this key."
|
||||
: "This device only — generate a separate key on each device.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(ScarfColor.accent)
|
||||
.disabled(iCloudMigrationInFlight)
|
||||
.onChange(of: iCloudSyncEnabled) { _, newValue in
|
||||
Task {
|
||||
iCloudMigrationInFlight = true
|
||||
iCloudMigrationError = nil
|
||||
defer { iCloudMigrationInFlight = false }
|
||||
do {
|
||||
try await KeychainSSHKeyStore().migrateAllItems(toICloudSync: newValue)
|
||||
} catch {
|
||||
// Revert the toggle on failure so the UI
|
||||
// reflects what's actually in the Keychain;
|
||||
// surface the error inline so the user can
|
||||
// retry / report. Keychain failures here are
|
||||
// rare (typically `errSecDuplicateItem` if a
|
||||
// prior migration was interrupted — the
|
||||
// delete-with-Any in writeBundle prevents
|
||||
// that, but we still belt-and-brace).
|
||||
iCloudMigrationError = error.localizedDescription
|
||||
iCloudSyncEnabled = !newValue
|
||||
SSHKeyICloudPreference.isEnabled = !newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
if iCloudMigrationInFlight {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Updating Keychain…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
}
|
||||
if let err = iCloudMigrationError {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
}
|
||||
} header: {
|
||||
Text("Security")
|
||||
} footer: {
|
||||
Text("End-to-end encrypted via iCloud Keychain. With Advanced Data Protection on, the encryption keys never leave your devices. Toggle off to keep the key device-only — each new device must onboard separately.")
|
||||
.font(.caption)
|
||||
}
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task {
|
||||
|
||||
@@ -63,6 +63,13 @@ struct ScarfIOSApp: App {
|
||||
// Hermes gains a push sender.
|
||||
await MainActor.run { NotificationRouter.shared.setUpOnLaunch() }
|
||||
}
|
||||
.task {
|
||||
// Drop chat drafts older than 7 days so the
|
||||
// UserDefaults plist doesn't grow unbounded across
|
||||
// years of use. Cheap; UserDefaults is already in
|
||||
// memory by the time we read keys.
|
||||
ChatController.pruneStaleDrafts()
|
||||
}
|
||||
// Clamp Dynamic Type at the scene root. ScarfGo is a
|
||||
// developer tool that needs more density than Apple's
|
||||
// .xxxLarge default, but we still scale from .xSmall
|
||||
@@ -185,8 +192,20 @@ final class RootModel {
|
||||
|
||||
/// Cancel an in-progress onboarding and return to the list.
|
||||
/// Called by the sheet's Cancel affordance.
|
||||
///
|
||||
/// Issue #55: prior versions had a defensive `servers.isEmpty`
|
||||
/// fallback that re-presented onboarding when there was nothing
|
||||
/// to fall back to. That made Cancel look broken on first-run.
|
||||
/// `OnboardingRootView` now hides the Cancel button when
|
||||
/// `canCancel == false`, so this path is only ever reached when
|
||||
/// at least one server already exists. In debug we assert that
|
||||
/// invariant; in release we still route to `.serverList` (which
|
||||
/// renders an empty-state with the "+ Add server" button) rather
|
||||
/// than re-presenting onboarding, so the worst case is "user
|
||||
/// sees the empty server list" rather than "Cancel does nothing."
|
||||
func cancelOnboarding() {
|
||||
state = servers.isEmpty ? .onboarding(forNewServer: ServerID()) : .serverList
|
||||
assert(!servers.isEmpty, "cancelOnboarding called with no servers — Cancel button should be hidden via OnboardingRootView.canCancel")
|
||||
state = .serverList
|
||||
}
|
||||
|
||||
/// Called from OnboardingView when the flow finishes. Reload the
|
||||
@@ -320,7 +339,14 @@ struct RootView: View {
|
||||
case .serverList:
|
||||
ServerListView(model: model)
|
||||
case .onboarding(let forNewServer):
|
||||
OnboardingRootView(targetServerID: forNewServer) {
|
||||
// canCancel is gated on whether there's a server list to
|
||||
// return to (issue #55). On first-run the user MUST add
|
||||
// their first server to use the app — the toolbar omits
|
||||
// the Cancel button in that case.
|
||||
OnboardingRootView(
|
||||
targetServerID: forNewServer,
|
||||
canCancel: !model.servers.isEmpty
|
||||
) {
|
||||
await model.onboardingFinished(serverID: forNewServer)
|
||||
} onCancel: {
|
||||
model.cancelOnboarding()
|
||||
|
||||
@@ -27,6 +27,12 @@ struct ChatView: View {
|
||||
@State private var controller: ChatController
|
||||
@State private var showProjectPicker = false
|
||||
@State private var showSlashCommandsSheet = false
|
||||
/// Drives the composer's keyboard. Bound to the TextField via
|
||||
/// `.focused(...)`; cleared by the scroll-to-dismiss gesture on
|
||||
/// the message list AND by an explicit keyboard-toolbar button.
|
||||
/// (issue #51 — pre-fix the keyboard could never be dismissed,
|
||||
/// blocking access to the toolbar nav button on small phones.)
|
||||
@FocusState private var composerFocused: Bool
|
||||
|
||||
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
||||
self.config = config
|
||||
@@ -44,6 +50,7 @@ struct ChatView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
connectionBanner
|
||||
errorBanner
|
||||
projectContextBar
|
||||
messageList
|
||||
@@ -112,6 +119,23 @@ struct ChatView: View {
|
||||
coordinator?.pendingProjectChat = nil
|
||||
Task { await consumePendingProjectChat(projectPath) }
|
||||
}
|
||||
// React to network reachability transitions. The service
|
||||
// updates its `transitionTick` on every `.satisfied <->
|
||||
// .unsatisfied` edge; the `.onChange` here funnels each
|
||||
// edge into ChatController so the reconnect machinery can
|
||||
// suspend on link-down and resume on link-up.
|
||||
.onChange(of: NetworkReachabilityService.shared.transitionTick) { _, _ in
|
||||
Task { await controller.handleReachabilityChange() }
|
||||
}
|
||||
// React to scene-phase transitions (background → active etc).
|
||||
// Source of truth is the coordinator, not `@Environment(\.scenePhase)`,
|
||||
// so the chat tab still picks up phase changes that happened
|
||||
// while it was unmounted (the user is on Dashboard when the
|
||||
// app backgrounds; sees Chat after resume).
|
||||
.onChange(of: coordinator?.scenePhaseTick) { _, _ in
|
||||
guard let phase = coordinator?.scenePhase else { return }
|
||||
Task { await controller.handleScenePhase(phase) }
|
||||
}
|
||||
// Deliberately NOT tearing down the ACP session on .onDisappear.
|
||||
// `TabView` unmounts tab content when the user switches tabs
|
||||
// (disappear fires), but `@State var controller` keeps the
|
||||
@@ -135,6 +159,21 @@ struct ChatView: View {
|
||||
connectingOverlay
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { controller.modelPreflightReason != nil },
|
||||
set: { newValue in
|
||||
if !newValue { controller.cancelModelPreflight() }
|
||||
}
|
||||
)) {
|
||||
IOSModelPreflightSheet(
|
||||
reason: controller.modelPreflightReason ?? "",
|
||||
serverDisplayName: controller.context.displayName,
|
||||
onSelect: { model, provider in
|
||||
controller.confirmModelPreflight(model: model, provider: provider)
|
||||
},
|
||||
onCancel: { controller.cancelModelPreflight() }
|
||||
)
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { controller.vm.pendingPermission.map(PermissionWrapper.init) },
|
||||
set: { if $0 == nil { controller.vm.pendingPermission = nil } }
|
||||
@@ -195,11 +234,15 @@ struct ChatView: View {
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
if controller.vm.hasMoreHistory {
|
||||
loadEarlierButton
|
||||
}
|
||||
ForEach(controller.vm.messages) { msg in
|
||||
MessageBubble(
|
||||
message: msg,
|
||||
turnDuration: controller.vm.turnDuration(forMessageId: msg.id)
|
||||
)
|
||||
.equatable()
|
||||
.id(msg.id)
|
||||
}
|
||||
if controller.vm.isGenerating {
|
||||
@@ -233,6 +276,42 @@ struct ChatView: View {
|
||||
// which fought with the user's own scroll gestures.
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.defaultScrollAnchor(.bottom, for: .sizeChanges)
|
||||
// Drag the messages downward to interactively collapse the
|
||||
// keyboard — the standard iOS chat gesture. Without this the
|
||||
// keyboard could never be dismissed once it rose, hiding the
|
||||
// top-trailing nav button on small phones (issue #51).
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
|
||||
/// "Load earlier messages" affordance pinned above the oldest
|
||||
/// loaded bubble. Only rendered when `vm.hasMoreHistory == true`,
|
||||
/// so it disappears organically once the user has paged back to
|
||||
/// the start of the session.
|
||||
@ViewBuilder
|
||||
private var loadEarlierButton: some View {
|
||||
Button {
|
||||
Task { await controller.vm.loadEarlier() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if controller.vm.isLoadingEarlier {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.font(.caption)
|
||||
}
|
||||
Text(controller.vm.isLoadingEarlier ? "Loading earlier…" : "Load earlier messages")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(controller.vm.isLoadingEarlier)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -278,6 +357,58 @@ struct ChatView: View {
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
/// Top-of-screen banner for transient connection states. `.failed`
|
||||
/// keeps using the existing full-screen overlay (so the user has
|
||||
/// somewhere obvious to tap "Retry"); `.reconnecting` and
|
||||
/// `.offline` are non-modal so the user can keep reading the
|
||||
/// transcript while we work in the background.
|
||||
@ViewBuilder
|
||||
private var connectionBanner: some View {
|
||||
switch controller.state {
|
||||
case .reconnecting(let attempt, let total):
|
||||
connectionBannerStrip(
|
||||
text: "Reconnecting (\(attempt)/\(total))…",
|
||||
tint: ScarfColor.warning,
|
||||
showSpinner: true
|
||||
)
|
||||
case .offline(let reason):
|
||||
connectionBannerStrip(
|
||||
text: reason,
|
||||
tint: ScarfColor.danger,
|
||||
showSpinner: false
|
||||
)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func connectionBannerStrip(
|
||||
text: String,
|
||||
tint: Color,
|
||||
showSpinner: Bool
|
||||
) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if showSpinner {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.tint(tint)
|
||||
} else {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(tint)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tint.opacity(0.16))
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
/// Soft pill above the composer confirming a non-interruptive
|
||||
/// command was received (e.g. `/steer`). Auto-clears via the
|
||||
@@ -310,9 +441,38 @@ struct ChatView: View {
|
||||
.lineLimit(1...5)
|
||||
.disabled(controller.state != .ready)
|
||||
.submitLabel(.send)
|
||||
.focused($composerFocused)
|
||||
.onSubmit {
|
||||
Task { await controller.send() }
|
||||
}
|
||||
// Persist the half-typed message across app suspensions
|
||||
// and force-quits. Debounced inside `scheduleDraftSave`
|
||||
// so we coalesce per-keystroke writes.
|
||||
.onChange(of: controller.draft) { _, _ in
|
||||
controller.scheduleDraftSave()
|
||||
}
|
||||
// Explicit dismiss-keyboard affordance, complementing the
|
||||
// interactive scroll-to-dismiss on the message list. iOS
|
||||
// shows a keyboard accessory toolbar above the system
|
||||
// keyboard whenever a focused TextField is on screen;
|
||||
// putting a "Done" chevron there is the most-discoverable
|
||||
// dismissal pattern (issue #51). Pinned to the LEADING
|
||||
// edge (Spacer trails) so the chevron doesn't visually
|
||||
// stack above the trailing-edge send button in the
|
||||
// composer below — that stacking was the complaint in
|
||||
// issue #57. Matches iOS convention (Notes, Mail, Reminders
|
||||
// all put accessory dismiss on the leading side).
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Button {
|
||||
composerFocused = false
|
||||
} label: {
|
||||
Image(systemName: "keyboard.chevron.compact.down")
|
||||
}
|
||||
.accessibilityLabel("Hide keyboard")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await controller.send() }
|
||||
@@ -521,12 +681,42 @@ final class ChatController {
|
||||
case idle
|
||||
case connecting
|
||||
case ready
|
||||
/// Mid-recovery: the SSH exec channel died but the agent on
|
||||
/// the remote may still be running. We're trying to reattach
|
||||
/// via `session/resume` (or `session/load` as a fallback).
|
||||
case reconnecting(attempt: Int, of: Int)
|
||||
/// Network reachability is unsatisfied. Distinct from
|
||||
/// `.failed` so the banner can stay tinted yellow ("we'll
|
||||
/// retry") instead of red ("dead").
|
||||
case offline(reason: String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private(set) var state: State = .idle
|
||||
var vm: RichChatViewModel
|
||||
var draft: String = ""
|
||||
|
||||
/// Set when chat-start is blocked because the active server's
|
||||
/// `config.yaml` has no `model.default` / `model.provider`. ChatView
|
||||
/// observes this to present an inline "pick a model" sheet — the
|
||||
/// Mac picker UI doesn't ship on iOS today, so the iOS sheet
|
||||
/// captures model + provider as text fields and persists them via
|
||||
/// the same `hermes config set` path. Reset on cancel or after a
|
||||
/// successful retry.
|
||||
var modelPreflightReason: String?
|
||||
|
||||
/// Stash of the original chat-start intent while we wait for the
|
||||
/// user to fill in a model. Captured by the gate inside `start`,
|
||||
/// `startInternal`, `startResuming`; replayed verbatim once
|
||||
/// `confirmModelPreflight` writes the chosen values to config.yaml
|
||||
/// so the chat the user originally tried to open lands without
|
||||
/// them having to click the project row again.
|
||||
private enum PendingStart {
|
||||
case fresh
|
||||
case project(path: String, name: String)
|
||||
case resume(sessionID: String)
|
||||
}
|
||||
private var pendingStartIntent: PendingStart?
|
||||
/// Display name of the Scarf project this session is scoped to,
|
||||
/// or nil for "quick chat" / global sessions. Surfaced as a
|
||||
/// subtitle under the "Chat" title in the nav bar so users can
|
||||
@@ -541,25 +731,214 @@ final class ChatController {
|
||||
/// chip on the right side of the project context bar.
|
||||
private(set) var currentGitBranch: String?
|
||||
|
||||
private let context: ServerContext
|
||||
/// Public so the surrounding `ChatView` can read `displayName`
|
||||
/// when presenting sheets (e.g., the model preflight). Still
|
||||
/// `let` — set once at init, never mutated after.
|
||||
let context: ServerContext
|
||||
private var client: ACPClient?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var healthMonitorTask: Task<Void, Never>?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var isHandlingDisconnect = false
|
||||
private var pendingDraftSave: Task<Void, Never>?
|
||||
|
||||
/// Session id of the currently-active chat. Saved when state
|
||||
/// reaches `.ready` and cleared on explicit `stop()` so a
|
||||
/// user-initiated disconnect doesn't get auto-reconnected when
|
||||
/// network/scene events fire later.
|
||||
private var lastActiveSessionID: String?
|
||||
/// Optional project working directory of the currently-active
|
||||
/// session. Used as `cwd` on the recovery path so a project-
|
||||
/// scoped session reconnects with the right scope.
|
||||
private var lastProjectPath: String?
|
||||
|
||||
// Reconnect tuning — verbatim from the Mac implementation at
|
||||
// scarf/Features/Chat/ViewModels/ChatViewModel.swift:563-693.
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1s
|
||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16s
|
||||
|
||||
private static let logger = Logger(
|
||||
subsystem: "com.scarf.ios",
|
||||
category: "ChatController"
|
||||
)
|
||||
|
||||
// MARK: - Draft persistence
|
||||
|
||||
private static let draftKeyPrefix = "scarf.chat.draft.v1"
|
||||
private static let draftMaxAge: TimeInterval = 7 * 24 * 60 * 60 // 7 days
|
||||
|
||||
private static func draftKey(serverID: ServerID, sessionID: String?) -> String {
|
||||
// `_no_session` covers the brief connecting window before
|
||||
// `vm.setSessionId` lands. The TextField is disabled in that
|
||||
// window today, so this slot is essentially never written —
|
||||
// but the sentinel is here so the key is always well-formed.
|
||||
"\(draftKeyPrefix).\(serverID.uuidString).\(sessionID ?? "_no_session")"
|
||||
}
|
||||
|
||||
private static func draftTimestampKey(forKey key: String) -> String { key + ".ts" }
|
||||
|
||||
private func saveDraft() {
|
||||
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
|
||||
let tsKey = Self.draftTimestampKey(forKey: key)
|
||||
if draft.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
UserDefaults.standard.removeObject(forKey: tsKey)
|
||||
} else {
|
||||
UserDefaults.standard.set(draft, forKey: key)
|
||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: tsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDraft() {
|
||||
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
|
||||
if let saved = UserDefaults.standard.string(forKey: key), !saved.isEmpty {
|
||||
draft = saved
|
||||
}
|
||||
}
|
||||
|
||||
private func clearStoredDraft() {
|
||||
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
UserDefaults.standard.removeObject(forKey: Self.draftTimestampKey(forKey: key))
|
||||
}
|
||||
|
||||
/// Debounced draft save. The view layer hooks this off
|
||||
/// `.onChange(of: controller.draft)` so per-keystroke writes are
|
||||
/// coalesced into one UserDefaults flush per ~1s of typing.
|
||||
func scheduleDraftSave() {
|
||||
pendingDraftSave?.cancel()
|
||||
pendingDraftSave = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.saveDraft()
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot janitor invoked at app launch. Removes draft slots
|
||||
/// whose timestamp sidecar predates `draftMaxAge`. Cheap enough
|
||||
/// to call synchronously — UserDefaults is in-memory at runtime.
|
||||
static func pruneStaleDrafts(now: Date = Date()) {
|
||||
let defaults = UserDefaults.standard
|
||||
let cutoff = now.timeIntervalSince1970 - draftMaxAge
|
||||
for key in defaults.dictionaryRepresentation().keys
|
||||
where key.hasPrefix(draftKeyPrefix) && key.hasSuffix(".ts")
|
||||
{
|
||||
guard let ts = defaults.object(forKey: key) as? TimeInterval, ts < cutoff else { continue }
|
||||
let baseKey = String(key.dropLast(3)) // strip ".ts"
|
||||
defaults.removeObject(forKey: baseKey)
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.vm = RichChatViewModel(context: context)
|
||||
}
|
||||
|
||||
/// Pre-flight: returns true when `config.yaml` has both
|
||||
/// `model.default` and `model.provider`. Returns false and stashes
|
||||
/// the start intent so the preflight sheet can replay it after the
|
||||
/// user picks a model. Reads via `context.readText` (transport-
|
||||
/// aware) and parses with the ScarfCore YAML parser — same path
|
||||
/// `IOSSettingsViewModel.load` uses, just synchronous because the
|
||||
/// preflight runs before any `state = .connecting` UI transition.
|
||||
private func passModelPreflight(intent: PendingStart) -> Bool {
|
||||
let raw = context.readText(context.paths.configYAML) ?? ""
|
||||
let config = HermesConfig(yaml: raw)
|
||||
let result = ModelPreflight.check(config)
|
||||
if result.isConfigured { return true }
|
||||
pendingStartIntent = intent
|
||||
modelPreflightReason = result.reason
|
||||
return false
|
||||
}
|
||||
|
||||
/// User confirmed model + provider in the preflight sheet. Persist
|
||||
/// to `config.yaml` via `hermes config set` (transport-aware — runs
|
||||
/// over SSH on the active server) and replay the original start
|
||||
/// intent. iOS picker is a free-form text input today (matches the
|
||||
/// Mac overlay-provider field for `nous`), so trust the user's
|
||||
/// input — Hermes will surface a runtime error if the model isn't
|
||||
/// valid for the provider.
|
||||
func confirmModelPreflight(model: String, provider: String) {
|
||||
let intent = pendingStartIntent
|
||||
modelPreflightReason = nil
|
||||
pendingStartIntent = nil
|
||||
|
||||
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedProvider.isEmpty else { return }
|
||||
|
||||
let ctx = context
|
||||
Task.detached { [weak self] in
|
||||
// Same PATH-prefix trick `IOSSettingsViewModel.saveValue`
|
||||
// uses so non-interactive shells find `hermes` even when
|
||||
// it's in ~/.local/bin / /opt/homebrew/bin.
|
||||
let hermes = ctx.paths.hermesBinary
|
||||
let providerScript = """
|
||||
PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH" \
|
||||
\(hermes) config set 'model.provider' '\(Self.escapeShellArg(trimmedProvider))'
|
||||
"""
|
||||
let providerOK = (try? ctx.makeTransport().runProcess(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", providerScript],
|
||||
stdin: nil,
|
||||
timeout: 15
|
||||
))?.exitCode == 0
|
||||
var modelOK = true
|
||||
if providerOK, !trimmedModel.isEmpty {
|
||||
let modelScript = """
|
||||
PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH" \
|
||||
\(hermes) config set 'model.default' '\(Self.escapeShellArg(trimmedModel))'
|
||||
"""
|
||||
modelOK = (try? ctx.makeTransport().runProcess(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", modelScript],
|
||||
stdin: nil,
|
||||
timeout: 15
|
||||
))?.exitCode == 0
|
||||
}
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if providerOK, modelOK, let intent {
|
||||
Task { @MainActor in
|
||||
switch intent {
|
||||
case .fresh:
|
||||
await self.start()
|
||||
case .project(let path, let name):
|
||||
await self.start(projectPath: path, projectName: name)
|
||||
case .resume(let id):
|
||||
await self.startResuming(sessionID: id)
|
||||
}
|
||||
}
|
||||
} else if !(providerOK && modelOK) {
|
||||
self.state = .failed("Couldn't save model+provider to config.yaml.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-quote escape a shell argument. Handles embedded single
|
||||
/// quotes via the standard `'"'"'` trick. Mirrors the helper on
|
||||
/// `IOSSettingsViewModel`. `nonisolated static` so the
|
||||
/// `Task.detached` body can call it without a `self` capture and
|
||||
/// without hopping back to the MainActor.
|
||||
nonisolated private static func escapeShellArg(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "'", with: "'\"'\"'")
|
||||
}
|
||||
|
||||
func cancelModelPreflight() {
|
||||
modelPreflightReason = nil
|
||||
pendingStartIntent = nil
|
||||
}
|
||||
|
||||
/// Open the SSH exec channel, send ACP `initialize`, then
|
||||
/// `session/new` — so that by the time `state == .ready` the user
|
||||
/// can type and hit send immediately.
|
||||
func start() async {
|
||||
if state == .connecting || state == .ready { return }
|
||||
guard passModelPreflight(intent: .fresh) else { return }
|
||||
state = .connecting
|
||||
vm.reset()
|
||||
let client = ACPClient.forIOSApp(
|
||||
@@ -596,16 +975,10 @@ final class ChatController {
|
||||
// Start streaming ACP events into the view-model BEFORE we
|
||||
// send session/new, so the `available_commands_update`
|
||||
// notification that the server sends on session init is
|
||||
// captured.
|
||||
let stream = await client.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in stream {
|
||||
guard let self else { break }
|
||||
await MainActor.run {
|
||||
self.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
// captured. Health monitor catches socket-level death the
|
||||
// event-stream EOF wouldn't see (e.g., a hung remote read).
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
// Create a fresh ACP session. `cwd` is the remote user's home
|
||||
// directory — Hermes defaults to that for tool scoping.
|
||||
@@ -613,7 +986,10 @@ final class ChatController {
|
||||
let home = await context.resolvedUserHome()
|
||||
let sessionId = try await client.newSession(cwd: home)
|
||||
vm.setSessionId(sessionId)
|
||||
loadDraft()
|
||||
state = .ready
|
||||
lastActiveSessionID = sessionId
|
||||
lastProjectPath = nil
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
await vm.recordACPFailure(error, client: client)
|
||||
@@ -631,6 +1007,7 @@ final class ChatController {
|
||||
let sessionId = vm.sessionId ?? ""
|
||||
guard !sessionId.isEmpty else { return }
|
||||
draft = ""
|
||||
clearStoredDraft()
|
||||
vm.addUserMessage(text: text)
|
||||
// /steer is non-interruptive — the agent is still on its
|
||||
// current turn; the guidance applies after the next tool call.
|
||||
@@ -691,13 +1068,283 @@ final class ChatController {
|
||||
/// Stop the current session + tear down the SSH exec channel.
|
||||
/// Idempotent.
|
||||
func stop() async {
|
||||
eventTask?.cancel()
|
||||
eventTask = nil
|
||||
eventTask?.cancel(); eventTask = nil
|
||||
healthMonitorTask?.cancel(); healthMonitorTask = nil
|
||||
reconnectTask?.cancel(); reconnectTask = nil
|
||||
if let client {
|
||||
await client.stop()
|
||||
}
|
||||
client = nil
|
||||
state = .idle
|
||||
// Explicit user-initiated disconnect — clear the session
|
||||
// memory so reachability/scenePhase events don't try to
|
||||
// resurrect the dead chat.
|
||||
lastActiveSessionID = nil
|
||||
lastProjectPath = nil
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
|
||||
// MARK: - Reconnect machinery (Section 1)
|
||||
|
||||
/// Stream ACP events into the view-model. When the stream ends
|
||||
/// without us cancelling it, the channel died; route into the
|
||||
/// reconnect path. Direct port of Mac's `startACPEventLoop`
|
||||
/// (scarf/Features/Chat/ViewModels/ChatViewModel.swift:563).
|
||||
private func startACPEventLoop(client: ACPClient) {
|
||||
eventTask = Task { @MainActor [weak self] in
|
||||
let stream = await client.events
|
||||
for await event in stream {
|
||||
guard !Task.isCancelled else { break }
|
||||
self?.vm.handleACPEvent(event)
|
||||
}
|
||||
// Stream ended — if we weren't explicitly cancelled the
|
||||
// channel died (EOF on stdin/out, write to dead pipe,
|
||||
// SSH socket gone). The Mac caller calls
|
||||
// `handleConnectionDied`; we mirror that.
|
||||
if !Task.isCancelled {
|
||||
self?.handleConnectionDied()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 5-second heartbeat that catches dead channels which don't
|
||||
/// explicitly EOF the stream (e.g., a hung SSH socket waiting
|
||||
/// for the next chunk that never arrives). When `isHealthy`
|
||||
/// returns false, route into the reconnect path. Mirrors Mac's
|
||||
/// `startHealthMonitor`.
|
||||
private func startHealthMonitor(client: ACPClient) {
|
||||
healthMonitorTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
guard !Task.isCancelled else { break }
|
||||
let healthy = await client.isHealthy
|
||||
if !healthy {
|
||||
self?.handleConnectionDied()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-stop cleanup + reconnect dispatch. Idempotent — guarded by
|
||||
/// `isHandlingDisconnect` so concurrent triggers (event-stream
|
||||
/// EOF + health monitor + write failure) don't tear down the same
|
||||
/// client twice.
|
||||
private func handleConnectionDied() {
|
||||
guard client != nil, !isHandlingDisconnect else { return }
|
||||
isHandlingDisconnect = true
|
||||
Self.logger.warning("ACP connection died")
|
||||
|
||||
// Capture any in-progress streaming text into a finalized
|
||||
// message before we attempt to merge against the DB. The VM
|
||||
// doesn't add a system "Connection lost" bubble — that would
|
||||
// create a phantom message during reconnect.
|
||||
vm.finalizeOnDisconnect()
|
||||
|
||||
let savedSessionId = vm.sessionId
|
||||
|
||||
// Tear down the dead client. The eventTask will be cancelled
|
||||
// immediately; awaiting `stop()` on the dead client is the
|
||||
// detached fire-and-forget pattern Mac uses (its `Task` block).
|
||||
eventTask?.cancel(); eventTask = nil
|
||||
healthMonitorTask?.cancel(); healthMonitorTask = nil
|
||||
if let dead = client { Task { await dead.stop() } }
|
||||
client = nil
|
||||
|
||||
guard let savedSessionId else {
|
||||
// No session id to resume — surface the failure.
|
||||
state = .failed("Connection lost")
|
||||
isHandlingDisconnect = false
|
||||
return
|
||||
}
|
||||
attemptReconnect(sessionId: savedSessionId)
|
||||
}
|
||||
|
||||
/// React to an iOS scene-phase transition.
|
||||
///
|
||||
/// `.background`: cancel the keepalive — iOS will suspend the
|
||||
/// socket within ~30s anyway, and fighting it via background
|
||||
/// tasks costs battery for marginal benefit (the agent's work is
|
||||
/// persisted to state.db on the remote, so we recover on resume).
|
||||
///
|
||||
/// `.active`: if we had a session running before suspension and
|
||||
/// the channel is now unhealthy, route into the reconnect path
|
||||
/// so the user sees fresh state without having to tap anything.
|
||||
func handleScenePhase(_ phase: ScenePhase) async {
|
||||
switch phase {
|
||||
case .background:
|
||||
healthMonitorTask?.cancel(); healthMonitorTask = nil
|
||||
case .active:
|
||||
// No session worth verifying.
|
||||
guard let id = lastActiveSessionID else { return }
|
||||
// Already mid-recovery — let it finish.
|
||||
if case .reconnecting = state { return }
|
||||
await verifyAndResume(sessionId: id)
|
||||
case .inactive:
|
||||
break // brief: control center, banners, split-screen
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the existing client's health on resume. If alive,
|
||||
/// just re-arm the heartbeat; if dead, route into the reconnect
|
||||
/// path (which preserves the session id and reconciles against
|
||||
/// the DB).
|
||||
private func verifyAndResume(sessionId: String) async {
|
||||
if let client {
|
||||
if await client.isHealthy {
|
||||
startHealthMonitor(client: client)
|
||||
return
|
||||
}
|
||||
}
|
||||
handleConnectionDied()
|
||||
}
|
||||
|
||||
/// React to a transition in `NetworkReachabilityService`. While
|
||||
/// the device has no network, suppress reconnect attempts (they'd
|
||||
/// just burn the 5-attempt budget against guaranteed failures);
|
||||
/// when the network comes back, kick a fresh cycle if we're
|
||||
/// stuck in `.failed` / `.offline` with a saved session id.
|
||||
func handleReachabilityChange() async {
|
||||
let satisfied = NetworkReachabilityService.shared.isSatisfied
|
||||
if !satisfied {
|
||||
// Stop the in-flight reconnect cycle — every attempt
|
||||
// will fail until the link is back. We'll restart on
|
||||
// the next `.satisfied` edge.
|
||||
reconnectTask?.cancel(); reconnectTask = nil
|
||||
if case .reconnecting = state {
|
||||
state = .offline(reason: "No network")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Network back. If we have a session worth restoring AND
|
||||
// we're currently in a non-recoverable state, kick a fresh
|
||||
// reconnect cycle.
|
||||
guard let id = lastActiveSessionID else { return }
|
||||
switch state {
|
||||
case .offline, .failed:
|
||||
attemptReconnect(sessionId: id)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 5-attempt exponential-backoff reconnect targeting the same
|
||||
/// session id. Tries `session/resume` first (correct semantics
|
||||
/// for live recovery), falls back to `session/load` for older
|
||||
/// remotes. NEVER `session/new` — that would lose the agent's
|
||||
/// in-context conversation. After a successful reattach, calls
|
||||
/// `vm.reconcileWithDB` so messages the agent wrote during the
|
||||
/// outage become visible.
|
||||
private func attemptReconnect(sessionId: String) {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for attempt in 1...Self.maxReconnectAttempts {
|
||||
guard !Task.isCancelled else { return }
|
||||
state = .reconnecting(attempt: attempt, of: Self.maxReconnectAttempts)
|
||||
|
||||
// Skip backoff on the first attempt so a quick
|
||||
// recovery (e.g., a momentary SSH socket flap) feels
|
||||
// instant. Subsequent attempts back off 1→2→4→8→16s.
|
||||
if attempt > 1 {
|
||||
let delay = min(
|
||||
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
|
||||
Self.maxReconnectDelay
|
||||
)
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
|
||||
let client = ACPClient.forIOSApp(
|
||||
context: context,
|
||||
keyProvider: {
|
||||
let store = KeychainSSHKeyStore()
|
||||
guard let key = try await store.load() else {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "No SSH key in Keychain — re-run onboarding.",
|
||||
osStatus: nil
|
||||
)
|
||||
}
|
||||
return key
|
||||
}
|
||||
)
|
||||
|
||||
do {
|
||||
try await client.start()
|
||||
|
||||
// Project-scoped sessions reconnect with their
|
||||
// project path as cwd; everything else uses the
|
||||
// remote user's home directory.
|
||||
let cwd: String
|
||||
if let path = lastProjectPath {
|
||||
cwd = path
|
||||
} else {
|
||||
cwd = await context.resolvedUserHome()
|
||||
}
|
||||
|
||||
let resolvedSessionId: String
|
||||
do {
|
||||
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
|
||||
} catch {
|
||||
Self.logger.info(
|
||||
"session/resume failed, trying session/load: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
|
||||
}
|
||||
|
||||
// Wire up the new client BEFORE merging messages
|
||||
// so any streaming chunks that arrive during the
|
||||
// reconcile land in the right place.
|
||||
self.client = client
|
||||
vm.acpStderrProvider = { [weak client] in
|
||||
await client?.recentStderr ?? ""
|
||||
}
|
||||
vm.setSessionId(resolvedSessionId)
|
||||
|
||||
// Merge in-memory state (any local-only user
|
||||
// messages typed before the disconnect) with
|
||||
// whatever Hermes has persisted to state.db
|
||||
// since we last looked. This is what makes the
|
||||
// "agent kept working while you were locked"
|
||||
// case visible to the user.
|
||||
let countBefore = vm.messages.count
|
||||
await vm.reconcileWithDB(sessionId: resolvedSessionId)
|
||||
let added = vm.messages.count - countBefore
|
||||
if added > 0 {
|
||||
vm.transientHint = "Resynced \(added) new message\(added == 1 ? "" : "s")."
|
||||
Task { @MainActor [weak vm] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if vm?.transientHint?.hasPrefix("Resynced") == true {
|
||||
vm?.transientHint = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
state = .ready
|
||||
lastActiveSessionID = resolvedSessionId
|
||||
|
||||
isHandlingDisconnect = false
|
||||
Self.logger.info("Reconnected on attempt \(attempt)")
|
||||
return
|
||||
} catch {
|
||||
Self.logger.warning(
|
||||
"Reconnect attempt \(attempt) failed: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
await client.stop()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted all attempts. Surface a manual-recovery prompt.
|
||||
guard !Task.isCancelled else { return }
|
||||
state = .failed("Connection lost")
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
}
|
||||
|
||||
/// User tapped "New chat". Stop, reset the VM, start again.
|
||||
@@ -788,6 +1435,13 @@ final class ChatController {
|
||||
projectName: String?
|
||||
) async {
|
||||
if state == .connecting || state == .ready { return }
|
||||
let intent: PendingStart
|
||||
if let projectPath, let projectName {
|
||||
intent = .project(path: projectPath, name: projectName)
|
||||
} else {
|
||||
intent = .fresh
|
||||
}
|
||||
guard passModelPreflight(intent: intent) else { return }
|
||||
state = .connecting
|
||||
let client = ACPClient.forIOSApp(
|
||||
context: context,
|
||||
@@ -815,15 +1469,8 @@ final class ChatController {
|
||||
return
|
||||
}
|
||||
|
||||
let stream = await client.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in stream {
|
||||
guard let self else { break }
|
||||
await MainActor.run {
|
||||
self.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
do {
|
||||
// Use the project's path as cwd when provided; else the
|
||||
@@ -836,7 +1483,10 @@ final class ChatController {
|
||||
}
|
||||
let sessionId = try await client.newSession(cwd: cwd)
|
||||
vm.setSessionId(sessionId)
|
||||
loadDraft()
|
||||
state = .ready
|
||||
lastActiveSessionID = sessionId
|
||||
lastProjectPath = projectPath
|
||||
|
||||
// If this was a project-scoped session, record the
|
||||
// attribution so Dashboard's Sessions tab can render the
|
||||
@@ -875,6 +1525,7 @@ final class ChatController {
|
||||
/// to `session/load` if the remote doesn't support `session/resume`
|
||||
/// (Hermes < 0.9.x).
|
||||
func startResuming(sessionID: String) async {
|
||||
guard passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
|
||||
await stop()
|
||||
vm.reset()
|
||||
// Clear eagerly so a lingering project name from a prior
|
||||
@@ -946,15 +1597,8 @@ final class ChatController {
|
||||
return
|
||||
}
|
||||
|
||||
let stream = await client.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in stream {
|
||||
guard let self else { break }
|
||||
await MainActor.run {
|
||||
self.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
do {
|
||||
let home = await context.resolvedUserHome()
|
||||
@@ -968,6 +1612,7 @@ final class ChatController {
|
||||
resolvedID = try await client.loadSession(cwd: home, sessionId: sessionID)
|
||||
}
|
||||
vm.setSessionId(resolvedID)
|
||||
loadDraft()
|
||||
// Pull the transcript out of state.db so the user sees
|
||||
// everything said up to now. Mirrors the Mac resume flow
|
||||
// (scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift:376).
|
||||
@@ -979,6 +1624,8 @@ final class ChatController {
|
||||
acpSessionId: resolvedID == sessionID ? nil : resolvedID
|
||||
)
|
||||
state = .ready
|
||||
lastActiveSessionID = resolvedID
|
||||
lastProjectPath = resolved?.path
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
await vm.recordACPFailure(error, client: client)
|
||||
@@ -1006,7 +1653,7 @@ private struct PermissionWrapper: Identifiable {
|
||||
|
||||
// MARK: - Message bubble
|
||||
|
||||
private struct MessageBubble: View {
|
||||
private struct MessageBubble: View, Equatable {
|
||||
let message: HermesMessage
|
||||
/// Wall-clock duration of the agent turn this assistant message
|
||||
/// belongs to (v2.5). Renders as a small `4.2s` pill below the
|
||||
@@ -1014,6 +1661,33 @@ private struct MessageBubble: View {
|
||||
/// resumed messages.
|
||||
var turnDuration: TimeInterval? = nil
|
||||
|
||||
/// SwiftUI body short-circuit (issue #46 — iOS path). On iOS the
|
||||
/// chat list is `LazyVStack` over `controller.vm.messages` directly
|
||||
/// (no message-group layer), so every visible bubble re-evaluates
|
||||
/// its body on each streamed chunk because `messages` mutates and
|
||||
/// the `@Observable` VM invalidates anyone reading it. Without
|
||||
/// equatable short-circuiting, every visible bubble re-runs
|
||||
/// `ChatContentFormatter.segments` + `AttributedString(markdown:)`
|
||||
/// per chunk — CPU-expensive on phones, especially with long
|
||||
/// content already on screen.
|
||||
///
|
||||
/// Streaming message has `id == 0` (shared with Mac via
|
||||
/// `RichChatViewModel.streamingId`); it correctly redraws on
|
||||
/// every chunk via the content/reasoning/toolCalls.count compare.
|
||||
static func == (lhs: MessageBubble, rhs: MessageBubble) -> Bool {
|
||||
guard lhs.message.id == rhs.message.id else { return false }
|
||||
if lhs.message.id == 0 {
|
||||
return lhs.message.content == rhs.message.content
|
||||
&& lhs.message.reasoning == rhs.message.reasoning
|
||||
&& lhs.message.reasoningContent == rhs.message.reasoningContent
|
||||
&& lhs.message.toolCalls.count == rhs.message.toolCalls.count
|
||||
&& lhs.turnDuration == rhs.turnDuration
|
||||
}
|
||||
return lhs.turnDuration == rhs.turnDuration
|
||||
&& lhs.message.tokenCount == rhs.message.tokenCount
|
||||
&& lhs.message.finishReason == rhs.message.finishReason
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if message.isToolResult {
|
||||
ToolResultRow(message: message)
|
||||
@@ -1453,6 +2127,76 @@ private struct PermissionSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS preflight sheet for the model + provider on a server whose
|
||||
/// `config.yaml` is missing them. The Mac picker (`ModelPickerSheet`)
|
||||
/// doesn't ship in the iOS target — the catalog UI is Mac-only today —
|
||||
/// so this is a pair of `TextField`s plus a hint pointing at common
|
||||
/// formats. Confirms via the same `setModelAndProvider` path the Mac
|
||||
/// preflight uses, so persistence + replay logic stays single-sourced
|
||||
/// in `ChatController.confirmModelPreflight`.
|
||||
private struct IOSModelPreflightSheet: View {
|
||||
let reason: String
|
||||
let serverDisplayName: String
|
||||
let onSelect: (_ model: String, _ provider: String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var model: String = ""
|
||||
@State private var provider: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text(reasonLine)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Section("Provider") {
|
||||
TextField("e.g. anthropic, nous, openai", text: $provider)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
Section("Model") {
|
||||
TextField("e.g. claude-sonnet-4.6, hermes-3", text: $model)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Text("Hermes will pass these through verbatim. Leave model blank if you're using Nous Portal — Hermes picks its default.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pick a model")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
onCancel()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save & Start") {
|
||||
let p = provider.trimmingCharacters(in: .whitespaces)
|
||||
let m = model.trimmingCharacters(in: .whitespaces)
|
||||
guard !p.isEmpty else { return }
|
||||
onSelect(m, p)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(provider.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reasonLine: String {
|
||||
let suffix = "Scarf will save these to `config.yaml` on \(serverDisplayName) and start the chat."
|
||||
guard !reason.isEmpty else { return suffix }
|
||||
return "\(reason) \(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
|
||||
// Empty shim so the file compiles on platforms without SQLite3 — the
|
||||
|
||||
@@ -18,15 +18,24 @@ struct OnboardingRootView: View {
|
||||
/// step 1 with nowhere to go. Optional for callers that don't
|
||||
/// need cancel (shouldn't be any, but keeps the API forgiving).
|
||||
let onCancel: @MainActor () -> Void
|
||||
/// Whether the Cancel button should appear in the nav bar
|
||||
/// (issue #55). False on the first-run onboarding where there
|
||||
/// is no `.serverList` to fall back to — showing Cancel there
|
||||
/// fired the action but the state machine routed straight back
|
||||
/// into onboarding, so the button looked broken to TestFlight
|
||||
/// users.
|
||||
let canCancel: Bool
|
||||
|
||||
@State private var vm: OnboardingViewModel
|
||||
|
||||
init(
|
||||
targetServerID: ServerID,
|
||||
canCancel: Bool = true,
|
||||
onFinished: @escaping @MainActor () async -> Void,
|
||||
onCancel: @escaping @MainActor () -> Void = {}
|
||||
) {
|
||||
self.targetServerID = targetServerID
|
||||
self.canCancel = canCancel
|
||||
self.onFinished = onFinished
|
||||
self.onCancel = onCancel
|
||||
let service = CitadelSSHService()
|
||||
@@ -63,9 +72,16 @@ struct OnboardingRootView: View {
|
||||
// to cancel. Hiding the button then also keeps
|
||||
// users from accidentally wiping a just-saved
|
||||
// server mid-race.
|
||||
//
|
||||
// Also hidden on first-run onboarding (issue #55):
|
||||
// there is no server list to return to, so Cancel
|
||||
// would either be inert (state machine looping
|
||||
// back into onboarding) or confusing (an empty
|
||||
// server list with no path forward). Better to
|
||||
// not show the affordance at all.
|
||||
if case .connected = vm.step {
|
||||
EmptyView()
|
||||
} else {
|
||||
} else if canCancel {
|
||||
Button("Cancel") {
|
||||
onCancel()
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ If you join the ScarfGo beta via TestFlight, Apple shares anonymized crash repor
|
||||
|
||||
- iOS Keychain storage uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` so credentials are unreachable while the device is locked and never synced to iCloud.
|
||||
- SSH connections use the same protocol stack as `ssh(1)` — strict host-key verification on first connect, key-based auth (no passwords are sent over the wire), and Citadel's pure-Swift implementation on iOS.
|
||||
- The macOS app is sandboxed where possible and notarized via Apple's standard Developer ID flow.
|
||||
- The macOS app is notarized via Apple's standard Developer ID flow (signed + stapled by `xcrun notarytool` on every release). It is not App-Sandboxed — Scarf needs direct read access to `~/.hermes/` and the ability to spawn the `hermes` CLI, both of which the App Sandbox forbids. That's why Scarf is distributed via GitHub Releases + Sparkle rather than the Mac App Store.
|
||||
- ScarfGo on iOS runs inside the standard iOS app sandbox — no special entitlements beyond Keychain access for the SSH key.
|
||||
|
||||
## Children's privacy
|
||||
|
||||
@@ -65,7 +66,7 @@ Neither app is directed at children under 13 and we do not knowingly collect any
|
||||
Because we don't collect any data on developer-controlled servers, there is nothing for you to opt out of, request deletion of, or export. To remove all app-stored data from your device:
|
||||
|
||||
- **ScarfGo**: delete the app. iOS purges the Keychain group + app container.
|
||||
- **Scarf**: delete the app and the `~/Library/Containers/com.scarf` directory (the app is sandboxed; this is the only on-disk data).
|
||||
- **Scarf**: delete `Scarf.app` from `/Applications`, then optionally remove `~/Library/Caches/scarf/` (remote SQLite snapshots), `~/Library/Preferences/com.scarf.app.plist` (server registry + preferences), and `~/Library/Application Support/com.scarf/` (skill snapshots).
|
||||
|
||||
Your Hermes host's data (`~/.hermes/`) stays untouched — that's yours to manage.
|
||||
|
||||
|
||||
@@ -529,7 +529,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -546,7 +546,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -571,7 +571,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -588,7 +588,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -612,7 +612,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -635,7 +635,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -658,7 +658,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -680,7 +680,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -834,7 +834,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -848,7 +848,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -870,7 +870,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -884,7 +884,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -902,12 +902,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -924,12 +924,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -945,11 +945,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -965,11 +965,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -1442,17 +1442,44 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan auth.json (Credential Pools file written by the Configure →
|
||||
// Credential Pools UI). Schema:
|
||||
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
|
||||
// Defensive parse: any malformed input falls through to the next check.
|
||||
// Scan auth.json. Two shapes need to count as "credential present":
|
||||
//
|
||||
// 1. credential_pool.<provider>[].access_token
|
||||
// — written by Configure → Credential Pools (manual key entry,
|
||||
// round-robin / least-used routing).
|
||||
//
|
||||
// 2. providers.<name>.access_token
|
||||
// — written by `hermes auth add <name>` for OAuth-authed
|
||||
// providers (Nous Portal, Spotify, GitHub Copilot ACP, etc.).
|
||||
// Pre-fix this was ignored, so a user with only Nous OAuth
|
||||
// kept seeing the "No AI provider credentials" banner even
|
||||
// after a successful Nous sign-in.
|
||||
//
|
||||
// Defensive parse: malformed input falls through to the next check.
|
||||
if let data = readFileData(context.paths.authJSON),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let pool = root["credential_pool"] as? [String: Any] {
|
||||
for (_, entries) in pool {
|
||||
guard let list = entries as? [[String: Any]] else { continue }
|
||||
for cred in list {
|
||||
if let token = cred["access_token"] as? String, !token.isEmpty {
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
{
|
||||
if let pool = root["credential_pool"] as? [String: Any] {
|
||||
for (_, entries) in pool {
|
||||
guard let list = entries as? [[String: Any]] else { continue }
|
||||
for cred in list {
|
||||
if let token = cred["access_token"] as? String, !token.isEmpty {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let providers = root["providers"] as? [String: Any] {
|
||||
for (_, value) in providers {
|
||||
guard let entry = value as? [String: Any] else { continue }
|
||||
if let token = entry["access_token"] as? String, !token.isEmpty {
|
||||
return true
|
||||
}
|
||||
// Some auth records (Spotify) carry only a refresh
|
||||
// token until the first access-token mint — count
|
||||
// that too so we don't false-negative seconds-old
|
||||
// OAuth flows.
|
||||
if let refresh = entry["refresh_token"] as? String, !refresh.isEmpty {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1473,6 +1500,42 @@ struct HermesFileService: Sendable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Persist the primary model + provider to `config.yaml` in one call.
|
||||
/// Used by the chat-start preflight when the user picks a model from
|
||||
/// the picker sheet — we need to write both keys before re-attempting
|
||||
/// `client.start()`. Wraps two `hermes config set` invocations because
|
||||
/// Hermes doesn't expose a combined "set model" command.
|
||||
///
|
||||
/// Returns `true` only if both writes succeed. If the second write
|
||||
/// fails the first is left in place — `model.default` without a
|
||||
/// matching `model.provider` is no worse than the all-empty state we
|
||||
/// started in, and the next preflight pass will re-prompt anyway.
|
||||
@discardableResult
|
||||
nonisolated func setModelAndProvider(model: String, provider: String) -> Bool {
|
||||
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedProvider.isEmpty else { return false }
|
||||
|
||||
let providerResult = runHermesCLI(args: ["config", "set", "model.provider", trimmedProvider], timeout: 30)
|
||||
guard providerResult.exitCode == 0 else {
|
||||
Self.logger.warning("hermes config set model.provider failed: \(providerResult.output, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
// Subscription-gated overlay providers (Nous Portal) accept an
|
||||
// empty model — Hermes picks its own default. Skip the model
|
||||
// write in that case rather than persisting the empty string,
|
||||
// which Hermes would treat as "unset" and the preflight would
|
||||
// catch again on the next start.
|
||||
guard !trimmedModel.isEmpty else { return true }
|
||||
|
||||
let modelResult = runHermesCLI(args: ["config", "set", "model.default", trimmedModel], timeout: 30)
|
||||
guard modelResult.exitCode == 0 else {
|
||||
Self.logger.warning("hermes config set model.default failed: \(modelResult.output, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||
// Resolve the executable path — for remote, prefer the cached
|
||||
|
||||
@@ -21,6 +21,7 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
|
||||
@discardableResult
|
||||
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||
try bootstrapProjectsRoot(plan: plan)
|
||||
try preflight(plan: plan)
|
||||
try createProjectFiles(plan: plan)
|
||||
try createSkillsFiles(plan: plan)
|
||||
@@ -32,6 +33,24 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
return entry
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
||||
/// Idempotently `mkdir -p` the parent directory so a fresh remote
|
||||
/// host (or a local user with no `~/Projects`) can complete the
|
||||
/// first install. Runs *before* preflight — preflight then checks
|
||||
/// the project dir itself, which we deliberately don't create
|
||||
/// here so the "already exists" collision check still fires for
|
||||
/// repeat installs at the same path.
|
||||
///
|
||||
/// Safe on both transports: `LocalTransport.createDirectory` uses
|
||||
/// `withIntermediateDirectories: true`; `SSHTransport.createDirectory`
|
||||
/// runs `mkdir -p`. Idempotent for existing dirs in both cases.
|
||||
nonisolated private func bootstrapProjectsRoot(plan: TemplateInstallPlan) throws {
|
||||
let parentDir = (plan.projectDir as NSString).deletingLastPathComponent
|
||||
guard !parentDir.isEmpty, parentDir != "/" else { return }
|
||||
try context.makeTransport().createDirectory(parentDir)
|
||||
}
|
||||
|
||||
// MARK: - Preflight
|
||||
|
||||
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Scarf-local chat rendering preferences (issues #47 / #48).
|
||||
///
|
||||
/// **Scope vs. Hermes config.** These three keys control how Scarf
|
||||
/// *renders* the chat transcript on screen — they do not affect what
|
||||
/// Hermes emits over ACP. The companion Hermes flags (`display.compact`,
|
||||
/// `showReasoning`, `showCost`) live on the Settings → Display tab's
|
||||
/// "Output" section and gate emission. Two separate concerns; both can
|
||||
/// be on at once.
|
||||
///
|
||||
/// **Defaults match today's UI exactly.** Existing users see no change
|
||||
/// until they opt in via Settings → Display → Chat density.
|
||||
enum ChatDensityKeys {
|
||||
static let toolCardStyle = "scarf.chat.toolCardStyle"
|
||||
static let reasoningStyle = "scarf.chat.reasoningStyle"
|
||||
static let fontScale = "scarf.chat.fontScale"
|
||||
/// Whether the left sessions list pane is visible in the Mac
|
||||
/// 3-pane chat layout. Defaults true (today's behavior). Issue #58.
|
||||
static let showSessionsList = "scarf.chat.showSessionsList"
|
||||
/// Whether the right tool inspector pane is visible. Defaults true.
|
||||
/// When hidden, clicking a tool card auto-flips it back on so the
|
||||
/// click does what the user expects (`ToolCallCard.onFocus`). Issue #58.
|
||||
static let showInspector = "scarf.chat.showInspector"
|
||||
}
|
||||
|
||||
/// How `RichMessageBubble` renders the per-call tool widgets.
|
||||
enum ToolCardStyle: String, CaseIterable, Identifiable {
|
||||
/// Today's behavior: full expandable card per call with arguments
|
||||
/// preview and inline result.
|
||||
case full
|
||||
/// Single-line chip per call (icon + name + status dot). Tap opens
|
||||
/// the right-pane inspector with the same details the inline expand
|
||||
/// shows. Saves significant vertical space when the assistant
|
||||
/// chains many tool calls.
|
||||
case compact
|
||||
/// No per-call rows. The `MessageGroupView.toolSummary` pill stays
|
||||
/// visible (showing aggregate counts) and is tappable — clicking it
|
||||
/// opens the inspector on the first call so per-call telemetry
|
||||
/// (duration, exit code) remains reachable.
|
||||
case hidden
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .full: return "Full card"
|
||||
case .compact: return "Compact chip"
|
||||
case .hidden: return "Hidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How `RichMessageBubble` renders the assistant's reasoning channel.
|
||||
enum ReasoningStyle: String, CaseIterable, Identifiable {
|
||||
/// Today's behavior: yellow tinted DisclosureGroup with a brain
|
||||
/// icon, "REASONING" label, and reasoning-token chip in the label.
|
||||
case disclosure
|
||||
/// Italic foregroundFaint caption inline above the reply, with a
|
||||
/// 9pt brain prefix. No box, no border, no toggle — just the text.
|
||||
/// Reasoning token count moves into the bubble's metadataFooter
|
||||
/// (`· N reasoning tok`) so it isn't lost.
|
||||
case inline
|
||||
/// Reasoning is not rendered. Token count still appears in the
|
||||
/// metadataFooter so user retains visibility into reasoning cost.
|
||||
case hidden
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .disclosure: return "Disclosure box"
|
||||
case .inline: return "Inline (italic)"
|
||||
case .hidden: return "Hidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helpers for translating the user's chat font scale into
|
||||
/// SwiftUI's `DynamicTypeSize`. Applied once at the `RichChatView` root
|
||||
/// so all of message list / input bar / session info bar scale together.
|
||||
enum ChatFontScale {
|
||||
static let min: Double = 0.85
|
||||
static let max: Double = 1.30
|
||||
static let step: Double = 0.05
|
||||
static let `default`: Double = 1.0
|
||||
|
||||
/// Map the slider value to the closest `DynamicTypeSize`. We avoid
|
||||
/// the accessibility sizes deliberately — the Mac chat layout has
|
||||
/// fixed-width side panes and accessibility-XXL would push tool
|
||||
/// chips into truncation. Users who need larger text should also
|
||||
/// resize the window.
|
||||
static func dynamicTypeSize(for scale: Double) -> DynamicTypeSize {
|
||||
switch scale {
|
||||
case ..<0.92: return .xSmall
|
||||
case ..<1.00: return .small
|
||||
case ..<1.08: return .medium
|
||||
case ..<1.18: return .large
|
||||
case ..<1.25: return .xLarge
|
||||
default: return .xxLarge
|
||||
}
|
||||
}
|
||||
|
||||
/// Display percentage for the slider's value chip.
|
||||
static func percentLabel(for scale: Double) -> String {
|
||||
let pct = Int((scale * 100).rounded())
|
||||
return "\(pct)%"
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,20 @@ final class ChatViewModel {
|
||||
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||
var missingCredentials: Bool = false
|
||||
|
||||
/// Set when chat-start is blocked because the active server's
|
||||
/// `config.yaml` has no `model.default` / `model.provider`. The chat
|
||||
/// view observes this and presents `ChatModelPreflightSheet`; on
|
||||
/// successful pick we persist via `setModelAndProvider` and re-attempt
|
||||
/// the original `startACPSession` call from `pendingStartArgs`.
|
||||
/// Nil when no preflight is pending.
|
||||
var modelPreflightReason: String?
|
||||
|
||||
/// Stash of the original `startACPSession` arguments while we wait
|
||||
/// for the user to pick a model. Replayed verbatim once
|
||||
/// `confirmModelPreflight` writes the chosen model+provider to
|
||||
/// config.yaml. Cleared on cancel or after replay.
|
||||
private var pendingStartArgs: (sessionId: String?, projectPath: String?)?
|
||||
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
|
||||
@@ -404,6 +418,23 @@ final class ChatViewModel {
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
|
||||
// Pre-flight: bail before opening any ACP plumbing if the
|
||||
// active server's `config.yaml` has no primary model or
|
||||
// provider. Hermes would otherwise let `session/new` succeed
|
||||
// and only fail at first prompt with an opaque
|
||||
// "Model parameter is required" 400. Stashing the start
|
||||
// arguments here lets `confirmModelPreflight` replay them
|
||||
// unchanged after the user picks a model.
|
||||
let preflight = ModelPreflight.check(fileService.loadConfig())
|
||||
if !preflight.isConfigured {
|
||||
pendingStartArgs = (sessionId, projectPath)
|
||||
modelPreflightReason = preflight.reason
|
||||
acpStatus = ""
|
||||
hasActiveProcess = false
|
||||
return
|
||||
}
|
||||
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient.forMacApp(context: context)
|
||||
@@ -716,6 +747,44 @@ final class ChatViewModel {
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
|
||||
// MARK: - Model preflight
|
||||
|
||||
/// Called by `ChatModelPreflightSheet` once the user has picked a
|
||||
/// model in the embedded `ModelPickerSheet`. Persists the choice via
|
||||
/// `hermes config set` (transport-aware — works on remote droplets
|
||||
/// too) and replays the pending `startACPSession` call so the chat
|
||||
/// the user originally tried to open finally lands.
|
||||
@MainActor
|
||||
func confirmModelPreflight(model: String, provider: String) {
|
||||
let pending = pendingStartArgs
|
||||
modelPreflightReason = nil
|
||||
pendingStartArgs = nil
|
||||
|
||||
let svc = fileService
|
||||
Task.detached { [weak self] in
|
||||
let ok = svc.setModelAndProvider(model: model, provider: provider)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if ok {
|
||||
if let pending {
|
||||
self.startACPSession(resume: pending.sessionId, projectPath: pending.projectPath)
|
||||
}
|
||||
} else {
|
||||
self.acpError = "Couldn't save model+provider to config.yaml. Open Settings to retry."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User dismissed the preflight sheet without picking a model. Drop
|
||||
/// the stashed start arguments and leave the chat in its idle state
|
||||
/// — no error banner, since this isn't a failure, just a deferral.
|
||||
@MainActor
|
||||
func cancelModelPreflight() {
|
||||
modelPreflightReason = nil
|
||||
pendingStartArgs = nil
|
||||
}
|
||||
|
||||
/// Respond to a permission request from the ACP agent.
|
||||
func respondToPermission(optionId: String) {
|
||||
guard let client = acpClient,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Pre-flight sheet shown when a chat-start hits a server whose
|
||||
/// `config.yaml` has no `model.default` / `model.provider`. Wraps the
|
||||
/// existing `ModelPickerSheet` so the picker surface, validation, and
|
||||
/// Nous-catalog branch all remain in one place.
|
||||
///
|
||||
/// The host (`ChatView`) owns persistence + retry: this sheet only
|
||||
/// captures the user's selection and calls `onSelect`. The
|
||||
/// `ChatViewModel` writes via `hermes config set` and replays the
|
||||
/// original `startACPSession` arguments, so the chat the user
|
||||
/// originally opened lands without them having to click the project
|
||||
/// row again.
|
||||
struct ChatModelPreflightSheet: View {
|
||||
let reason: String
|
||||
let serverDisplayName: String
|
||||
let onSelect: (_ model: String, _ provider: String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
ModelPickerSheet(
|
||||
initialProvider: "",
|
||||
initialModel: "",
|
||||
onSelect: { modelID, providerID in
|
||||
onSelect(modelID, providerID)
|
||||
dismiss()
|
||||
},
|
||||
onCancel: {
|
||||
onCancel()
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "cpu")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pick a model to start chatting")
|
||||
.scarfStyle(.headline)
|
||||
Text(detailMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var detailMessage: String {
|
||||
let suffix = "Hermes uses `model.default` + `model.provider` from `config.yaml`. Pick one and Scarf will save it on \(serverDisplayName) before starting the chat."
|
||||
guard !reason.isEmpty else { return suffix }
|
||||
return "\(reason) \(suffix)"
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,10 @@ struct ChatTranscriptPane: View {
|
||||
isWorking: richChat.isGenerating,
|
||||
isLoadingSession: chatViewModel.isPreparingSession,
|
||||
scrollTrigger: richChat.scrollTrigger,
|
||||
turnDurations: richChat.turnDurations
|
||||
turnDurations: richChat.turnDurations,
|
||||
hasMoreHistory: richChat.hasMoreHistory,
|
||||
isLoadingEarlier: richChat.isLoadingEarlier,
|
||||
onLoadEarlier: { Task { await richChat.loadEarlier() } }
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -7,6 +7,15 @@ struct ChatView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
/// Side-pane visibility toggles (issue #58). Drive the new
|
||||
/// sidebar.left / sidebar.right toolbar buttons; `RichChatView.body`
|
||||
/// reads the same `@AppStorage` keys and conditionally renders the
|
||||
/// panes with a slide animation.
|
||||
@AppStorage(ChatDensityKeys.showSessionsList)
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
@Bindable var coord = coordinator
|
||||
@@ -225,6 +234,30 @@ struct ChatView: View {
|
||||
voiceControls
|
||||
}
|
||||
|
||||
// Side-pane toggles (issue #58). Only meaningful in rich-chat
|
||||
// mode where the 3-pane layout exists; terminal mode is a
|
||||
// single SwiftTerm view and these would do nothing. Hide
|
||||
// them on the terminal side so the toolbar stays uncluttered.
|
||||
if viewModel.displayMode == .richChat {
|
||||
Button {
|
||||
showSessionsList.toggle()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.left")
|
||||
.foregroundStyle(showSessionsList ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(showSessionsList ? "Hide sessions list" : "Show sessions list")
|
||||
|
||||
Button {
|
||||
showInspector.toggle()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.right")
|
||||
.foregroundStyle(showInspector ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(showInspector ? "Hide tool inspector" : "Show tool inspector")
|
||||
}
|
||||
|
||||
Picker("View", selection: Bindable(viewModel).displayMode) {
|
||||
Image(systemName: "terminal")
|
||||
.help("Terminal")
|
||||
@@ -386,6 +419,23 @@ struct ChatView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
// Model preflight — open before any ACP plumbing when the active
|
||||
// server has no `model.default` / `model.provider` set. Keeps the
|
||||
// user from typing a prompt only to find out the upstream
|
||||
// provider rejected it.
|
||||
.sheet(isPresented: modelPreflightBinding) {
|
||||
ChatModelPreflightSheet(
|
||||
reason: viewModel.modelPreflightReason ?? "",
|
||||
serverDisplayName: viewModel.context.displayName,
|
||||
onSelect: { model, provider in
|
||||
viewModel.confirmModelPreflight(model: model, provider: provider)
|
||||
},
|
||||
onCancel: {
|
||||
viewModel.cancelModelPreflight()
|
||||
}
|
||||
)
|
||||
.environment(\.serverContext, viewModel.context)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionBinding: Binding<RichChatViewModel.PendingPermission?> {
|
||||
@@ -394,6 +444,15 @@ struct ChatView: View {
|
||||
set: { viewModel.richChatViewModel.pendingPermission = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private var modelPreflightBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.modelPreflightReason != nil },
|
||||
set: { newValue in
|
||||
if !newValue { viewModel.cancelModelPreflight() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission Approval View
|
||||
|
||||
@@ -15,6 +15,13 @@ struct RichChatMessageList: View {
|
||||
/// bubble's metadata footer can render the v2.5 stopwatch pill.
|
||||
/// Defaults empty so callers that don't care can omit it.
|
||||
var turnDurations: [Int: TimeInterval] = [:]
|
||||
/// Show the "Load earlier messages" button at the top of the
|
||||
/// transcript when the underlying session has more on-disk
|
||||
/// history that hasn't been paged in yet. Hidden by default so
|
||||
/// existing callers who haven't opted in see no UI change.
|
||||
var hasMoreHistory: Bool = false
|
||||
var isLoadingEarlier: Bool = false
|
||||
var onLoadEarlier: (() -> Void)? = nil
|
||||
|
||||
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
|
||||
/// `.defaultScrollAnchor(.bottom)`.
|
||||
@@ -57,8 +64,33 @@ struct RichChatMessageList: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if hasMoreHistory, let onLoadEarlier {
|
||||
Button {
|
||||
onLoadEarlier()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if isLoadingEarlier {
|
||||
ProgressView().scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.font(.caption)
|
||||
}
|
||||
Text(isLoadingEarlier ? "Loading earlier…" : "Load earlier messages")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoadingEarlier)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
ForEach(groups) { group in
|
||||
MessageGroupView(group: group, turnDurations: turnDurations)
|
||||
.equatable()
|
||||
.id("group-\(group.id)")
|
||||
}
|
||||
|
||||
@@ -136,7 +168,7 @@ struct RichChatMessageList: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageGroupView: View {
|
||||
struct MessageGroupView: View, Equatable {
|
||||
let group: MessageGroup
|
||||
/// Wall-clock turn durations keyed by assistant-message id (v2.5).
|
||||
/// Forwarded into `RichMessageBubble` so the metadata footer can
|
||||
@@ -144,10 +176,57 @@ struct MessageGroupView: View {
|
||||
/// that haven't been updated yet still compile.
|
||||
var turnDurations: [Int: TimeInterval] = [:]
|
||||
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
/// Read here so the toolSummary pill knows whether to render as
|
||||
/// always-visible (today's behavior) or as a tappable inspector
|
||||
/// shortcut when per-call tool cards are hidden (issue #47).
|
||||
@AppStorage(ChatDensityKeys.toolCardStyle)
|
||||
private var toolCardStyleRaw: String = ToolCardStyle.full.rawValue
|
||||
private var toolCardStyle: ToolCardStyle {
|
||||
ToolCardStyle(rawValue: toolCardStyleRaw) ?? .full
|
||||
}
|
||||
|
||||
/// Equatable short-circuit for SwiftUI: when the trailing group's
|
||||
/// streaming bubble grows, only that group's `==` returns false.
|
||||
/// All earlier groups skip body re-evaluation, dropping per-chunk
|
||||
/// render work from O(n) to O(1) for settled groups (issue #46).
|
||||
///
|
||||
/// What participates:
|
||||
/// - `group.id` (primary key — stable sequential index).
|
||||
/// - assistant-message id list (additions / finalize-id-flip).
|
||||
/// - For the streaming message (id == 0): content, reasoning,
|
||||
/// reasoningContent, toolCalls.count — the only fields that
|
||||
/// mutate while streaming.
|
||||
/// - `turnDurations[msg.id]` for assistants in this group only —
|
||||
/// the dict is large and shared across groups, but each group
|
||||
/// only renders its own entries.
|
||||
/// - `group.toolResults.count` — append-only within a group.
|
||||
static func == (lhs: MessageGroupView, rhs: MessageGroupView) -> Bool {
|
||||
guard lhs.group.id == rhs.group.id else { return false }
|
||||
guard lhs.group.userMessage?.id == rhs.group.userMessage?.id else { return false }
|
||||
guard lhs.group.userMessage?.content == rhs.group.userMessage?.content else { return false }
|
||||
guard lhs.group.assistantMessages.count == rhs.group.assistantMessages.count else { return false }
|
||||
for (l, r) in zip(lhs.group.assistantMessages, rhs.group.assistantMessages) {
|
||||
if l.id != r.id { return false }
|
||||
if l.id == 0 {
|
||||
if l.content != r.content { return false }
|
||||
if l.reasoning != r.reasoning { return false }
|
||||
if l.reasoningContent != r.reasoningContent { return false }
|
||||
if l.toolCalls.count != r.toolCalls.count { return false }
|
||||
}
|
||||
}
|
||||
if lhs.group.toolResults.count != rhs.group.toolResults.count { return false }
|
||||
for msg in lhs.group.assistantMessages where msg.isAssistant && msg.id != 0 {
|
||||
if lhs.turnDurations[msg.id] != rhs.turnDurations[msg.id] { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let user = group.userMessage {
|
||||
RichMessageBubble(message: user, toolResults: [:])
|
||||
.equatable()
|
||||
}
|
||||
|
||||
// Identify by array offset rather than `message.id`. The
|
||||
@@ -166,9 +245,19 @@ struct MessageGroupView: View {
|
||||
toolResults: group.toolResults,
|
||||
turnDuration: turnDurations[message.id]
|
||||
)
|
||||
.equatable()
|
||||
}
|
||||
|
||||
if group.toolCallCount > 1 {
|
||||
// When per-call tool cards are visible, the summary pill
|
||||
// is informational only. When tool cards are hidden
|
||||
// (issue #47), this pill becomes the only chrome surfacing
|
||||
// tool activity AND the only path back into the inspector
|
||||
// pane — render it on every group with calls (not just >1)
|
||||
// and make it tappable to focus the first call.
|
||||
let showSummary = (toolCardStyle == .hidden)
|
||||
? group.toolCallCount > 0
|
||||
: group.toolCallCount > 1
|
||||
if showSummary {
|
||||
toolSummary
|
||||
}
|
||||
}
|
||||
@@ -176,28 +265,44 @@ struct MessageGroupView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var toolSummary: some View {
|
||||
let kinds = toolKindCounts
|
||||
let kinds = group.toolKindCounts
|
||||
if !kinds.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench")
|
||||
.font(.caption2)
|
||||
Text(summaryText(kinds))
|
||||
.font(.caption2)
|
||||
let firstCallId = group.assistantMessages
|
||||
.flatMap(\.toolCalls)
|
||||
.first?.callId
|
||||
let isInteractive = (toolCardStyle == .hidden) && firstCallId != nil
|
||||
Group {
|
||||
if isInteractive, let firstCallId {
|
||||
Button {
|
||||
chatViewModel.focusedToolCallId = firstCallId
|
||||
} label: {
|
||||
toolSummaryPill(kinds, interactive: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Click to inspect tool calls")
|
||||
} else {
|
||||
toolSummaryPill(kinds, interactive: false)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolKindCounts: [ToolKind: Int] {
|
||||
var counts: [ToolKind: Int] = [:]
|
||||
for msg in group.assistantMessages where msg.isAssistant {
|
||||
for call in msg.toolCalls {
|
||||
counts[call.toolKind, default: 0] += 1
|
||||
@ViewBuilder
|
||||
private func toolSummaryPill(_ kinds: [ToolKind: Int], interactive: Bool) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench")
|
||||
.font(.caption2)
|
||||
Text(summaryText(kinds))
|
||||
.font(.caption2)
|
||||
if interactive {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
|
||||
|
||||
@@ -22,14 +22,32 @@ struct RichChatView: View {
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
|
||||
/// User-controlled font scale for the chat surface (issue #48).
|
||||
/// Applied via `.environment(\.dynamicTypeSize, ...)` so message
|
||||
/// list, input bar, session info bar, and the inspector pane all
|
||||
/// scale together. Default 1.0 = today's UI.
|
||||
@AppStorage(ChatDensityKeys.fontScale)
|
||||
private var fontScale: Double = ChatFontScale.default
|
||||
|
||||
/// Sessions-list / inspector pane visibility (issue #58). Defaults
|
||||
/// `true` so existing users see no change until they opt out via
|
||||
/// the toolbar buttons or Settings → Display → Chat density.
|
||||
@AppStorage(ChatDensityKeys.showSessionsList)
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
|
||||
/// In ACP mode, events drive updates directly — no DB polling needed.
|
||||
private var isACPMode: Bool { chatViewModel.isACPConnected }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
|
||||
.frame(width: 264)
|
||||
Divider().background(ScarfColor.border)
|
||||
if showSessionsList {
|
||||
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
|
||||
.frame(width: 264)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
Divider().background(ScarfColor.border)
|
||||
}
|
||||
ChatTranscriptPane(
|
||||
richChat: richChat,
|
||||
chatViewModel: chatViewModel,
|
||||
@@ -37,11 +55,30 @@ struct RichChatView: View {
|
||||
isEnabled: isEnabled
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
Divider().background(ScarfColor.border)
|
||||
ChatInspectorPane(chatViewModel: chatViewModel)
|
||||
.frame(width: 320)
|
||||
if showInspector {
|
||||
Divider().background(ScarfColor.border)
|
||||
ChatInspectorPane(chatViewModel: chatViewModel)
|
||||
.frame(width: 320)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
|
||||
// Animate side-pane shows/hides so the transcript reflows
|
||||
// smoothly rather than snapping. ~180ms feels responsive
|
||||
// without being jarring.
|
||||
.animation(.easeInOut(duration: 0.18), value: showSessionsList)
|
||||
.animation(.easeInOut(duration: 0.18), value: showInspector)
|
||||
// Auto-show inspector when a tool call is focused so a click
|
||||
// on a tool card is never silently lost (issue #58 follow-up).
|
||||
// Tool clicks set `chatViewModel.focusedToolCallId`; if that
|
||||
// becomes non-nil while the inspector is hidden, flip it back
|
||||
// on. The animation modifiers above cover the slide-in.
|
||||
.onChange(of: chatViewModel.focusedToolCallId) { _, new in
|
||||
if new != nil, !showInspector {
|
||||
showInspector = true
|
||||
}
|
||||
}
|
||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
struct RichMessageBubble: View {
|
||||
struct RichMessageBubble: View, Equatable {
|
||||
let message: HermesMessage
|
||||
let toolResults: [String: HermesMessage]
|
||||
/// Wall-clock duration of the agent turn this assistant message
|
||||
@@ -14,6 +14,44 @@ struct RichMessageBubble: View {
|
||||
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
|
||||
/// Scarf-local chat density preferences (issues #47 / #48). All
|
||||
/// three default to today's UI. Read here so the reasoning + tool-
|
||||
/// call switches don't have to thread the values through every
|
||||
/// layer; the AppStorage seam is one line per dependency.
|
||||
@AppStorage(ChatDensityKeys.toolCardStyle)
|
||||
private var toolCardStyleRaw: String = ToolCardStyle.full.rawValue
|
||||
@AppStorage(ChatDensityKeys.reasoningStyle)
|
||||
private var reasoningStyleRaw: String = ReasoningStyle.disclosure.rawValue
|
||||
private var toolCardStyle: ToolCardStyle {
|
||||
ToolCardStyle(rawValue: toolCardStyleRaw) ?? .full
|
||||
}
|
||||
private var reasoningStyle: ReasoningStyle {
|
||||
ReasoningStyle(rawValue: reasoningStyleRaw) ?? .disclosure
|
||||
}
|
||||
|
||||
/// SwiftUI body short-circuit (issue #46). Settled bubbles
|
||||
/// (`message.id != 0`) are immutable — id equality plus a couple
|
||||
/// of cheap stored-field comparisons is sufficient. The streaming
|
||||
/// bubble (id == 0) gets a content + reasoning + toolCalls.count
|
||||
/// comparison so it correctly redraws on every chunk.
|
||||
/// `toolResults` is compared by count: results are append-only
|
||||
/// within a group, so a count change implies a new tool result.
|
||||
static func == (lhs: RichMessageBubble, rhs: RichMessageBubble) -> Bool {
|
||||
guard lhs.message.id == rhs.message.id else { return false }
|
||||
if lhs.message.id == 0 {
|
||||
return lhs.message.content == rhs.message.content
|
||||
&& lhs.message.reasoning == rhs.message.reasoning
|
||||
&& lhs.message.reasoningContent == rhs.message.reasoningContent
|
||||
&& lhs.message.toolCalls.count == rhs.message.toolCalls.count
|
||||
&& lhs.turnDuration == rhs.turnDuration
|
||||
&& lhs.toolResults.count == rhs.toolResults.count
|
||||
}
|
||||
return lhs.turnDuration == rhs.turnDuration
|
||||
&& lhs.toolResults.count == rhs.toolResults.count
|
||||
&& lhs.message.tokenCount == rhs.message.tokenCount
|
||||
&& lhs.message.finishReason == rhs.message.finishReason
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if message.isUser {
|
||||
userBubble
|
||||
@@ -79,13 +117,13 @@ struct RichMessageBubble: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
if message.hasReasoning {
|
||||
if message.hasReasoning, reasoningStyle != .hidden {
|
||||
reasoningSection
|
||||
}
|
||||
if !message.content.isEmpty {
|
||||
contentView
|
||||
}
|
||||
if !message.toolCalls.isEmpty {
|
||||
if !message.toolCalls.isEmpty, toolCardStyle != .hidden {
|
||||
toolCallsSection
|
||||
}
|
||||
}
|
||||
@@ -125,7 +163,24 @@ struct RichMessageBubble: View {
|
||||
|
||||
// MARK: - Reasoning
|
||||
|
||||
/// Reasoning is rendered in one of three styles, controlled by
|
||||
/// `Settings → Display → Chat density → Reasoning` (issue #48).
|
||||
/// Token count for the reasoning-bearing message is kept in the
|
||||
/// metadataFooter (always-visible), so collapsing or hiding the
|
||||
/// box doesn't drop telemetry.
|
||||
@ViewBuilder
|
||||
private var reasoningSection: some View {
|
||||
switch reasoningStyle {
|
||||
case .disclosure:
|
||||
reasoningDisclosure
|
||||
case .inline:
|
||||
reasoningInline
|
||||
case .hidden:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var reasoningDisclosure: some View {
|
||||
DisclosureGroup {
|
||||
Text(message.preferredReasoning ?? "")
|
||||
.font(ScarfFont.monoSmall)
|
||||
@@ -158,9 +213,44 @@ struct RichMessageBubble: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// Inline reasoning: italic foregroundFaint caption with a 9pt
|
||||
/// brain prefix, no box / border / disclosure. Same data, far less
|
||||
/// vertical space — addresses the #48 complaint.
|
||||
private var reasoningInline: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
Text(message.preferredReasoning ?? "")
|
||||
.font(ScarfFont.caption)
|
||||
.italic()
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Calls
|
||||
|
||||
/// Tool calls render in one of three styles, controlled by
|
||||
/// `Settings → Display → Chat density → Tool calls` (issue #47).
|
||||
/// `.hidden` is handled by the caller (skips this view entirely)
|
||||
/// AND by the parent `MessageGroupView`, which makes its
|
||||
/// always-visible toolSummary pill tappable so the inspector pane
|
||||
/// remains reachable in both compact and hidden modes.
|
||||
@ViewBuilder
|
||||
private var toolCallsSection: some View {
|
||||
switch toolCardStyle {
|
||||
case .full:
|
||||
toolCallsFull
|
||||
case .compact:
|
||||
toolCallsCompact
|
||||
case .hidden:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var toolCallsFull: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(message.toolCalls) { call in
|
||||
ToolCallCard(
|
||||
@@ -173,6 +263,78 @@ struct RichMessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line tappable chip per call. Click sets focus so the right-
|
||||
/// pane inspector opens with the same data the inline expand
|
||||
/// shows. Status dot mirrors the full-card status icon: in-flight
|
||||
/// progress / success check / non-zero exit code → danger.
|
||||
private var toolCallsCompact: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(message.toolCalls) { call in
|
||||
let result = toolResults[call.callId]
|
||||
let isFocused = chatViewModel.focusedToolCallId == call.callId
|
||||
let color = compactToolColor(for: call.toolKind)
|
||||
Button {
|
||||
chatViewModel.focusedToolCallId = call.callId
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: call.toolKind.icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(color)
|
||||
Text(call.functionName)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 6)
|
||||
compactStatusIcon(call: call, result: result)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(color.opacity(isFocused ? 0.16 : 0.08))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.strokeBorder(
|
||||
color.opacity(isFocused ? 0.45 : 0.20),
|
||||
lineWidth: isFocused ? 1.2 : 1
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Click to inspect this tool call")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func compactStatusIcon(call: HermesToolCall, result: HermesMessage?) -> some View {
|
||||
if let exit = call.exitCode {
|
||||
Image(systemName: exit == 0 ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(exit == 0 ? ScarfColor.success : ScarfColor.danger)
|
||||
} else if result != nil {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
} else {
|
||||
ProgressView().controlSize(.mini)
|
||||
}
|
||||
}
|
||||
|
||||
private func compactToolColor(for kind: ToolKind) -> Color {
|
||||
switch kind {
|
||||
case .read: return ScarfColor.success
|
||||
case .edit: return ScarfColor.info
|
||||
case .execute: return ScarfColor.warning
|
||||
case .fetch: return ScarfColor.Tool.web
|
||||
case .browser: return ScarfColor.Tool.search
|
||||
case .other: return ScarfColor.foregroundMuted
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata Footer
|
||||
|
||||
private var metadataFooter: some View {
|
||||
|
||||
@@ -21,9 +21,28 @@ struct SessionInfoBar: View {
|
||||
/// git repos.
|
||||
var gitBranch: String? = nil
|
||||
|
||||
/// Active Hermes profile name (issue #50). Resolved on each body
|
||||
/// re-evaluation; the resolver caches for 5s so this is cheap.
|
||||
/// Chip renders only when not "default" so existing (non-profile)
|
||||
/// installations see no change in the bar.
|
||||
private var activeProfile: String {
|
||||
HermesProfileResolver.activeProfileName()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if let session {
|
||||
// Profile chip leftmost — surfaces which Hermes profile
|
||||
// Scarf is reading (issue #50). Without this users couldn't
|
||||
// tell whether the visible session list came from the
|
||||
// profile they thought they switched to.
|
||||
if activeProfile != "default" {
|
||||
Label(activeProfile, systemImage: "person.crop.square")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
.lineLimit(1)
|
||||
.help("Scarf is reading from Hermes profile \"\(activeProfile)\". Switch profiles with `hermes profile use <name>` and relaunch Scarf.")
|
||||
}
|
||||
// Project indicator first — visually anchors the session
|
||||
// as "scoped to project X" before the working dot and
|
||||
// title. Hidden for non-project chats so the bar looks
|
||||
|
||||
@@ -16,6 +16,12 @@ struct ToolCallCard: View {
|
||||
var onFocus: (() -> Void)? = nil
|
||||
|
||||
@State private var expanded = false
|
||||
/// Pretty-printed `call.arguments`. Computed once per `call.callId`
|
||||
/// via `.task(id:)` instead of on every card re-render (issue #46).
|
||||
/// Seeded with the raw arguments so the first frame after expand
|
||||
/// shows readable text instead of a flicker of empty space while
|
||||
/// the task runs.
|
||||
@State private var formattedArgs: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -77,7 +83,7 @@ struct ToolCallCard: View {
|
||||
Text("ARGUMENTS")
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Text(formatJSON(call.arguments))
|
||||
Text(formattedArgs.isEmpty ? call.arguments : formattedArgs)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.textSelection(.enabled)
|
||||
@@ -102,6 +108,9 @@ struct ToolCallCard: View {
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
.task(id: call.callId) {
|
||||
formattedArgs = formatJSON(call.arguments)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolLabel: String {
|
||||
@@ -141,13 +150,18 @@ struct ToolResultContent: View {
|
||||
let content: String
|
||||
|
||||
@State private var showAll = false
|
||||
|
||||
private var lines: [String] { content.components(separatedBy: "\n") }
|
||||
private var isLong: Bool { lines.count > 8 }
|
||||
/// Cached line split. The previous computed-property pair
|
||||
/// (`lines` + `isLong`) split `content` twice on every render —
|
||||
/// once for the count check, once for the prefix join. With long
|
||||
/// tool outputs (file contents, command output) this was O(n)
|
||||
/// per render, repeated for every settled card on every chunk
|
||||
/// (issue #46). Now split once per content change via `.task(id:)`.
|
||||
@State private var lines: [String] = []
|
||||
@State private var preview: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(showAll ? content : lines.prefix(8).joined(separator: "\n"))
|
||||
Text(showAll ? content : preview)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.textSelection(.enabled)
|
||||
@@ -162,7 +176,7 @@ struct ToolResultContent: View {
|
||||
)
|
||||
)
|
||||
|
||||
if isLong {
|
||||
if lines.count > 8 {
|
||||
Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
|
||||
withAnimation { showAll.toggle() }
|
||||
}
|
||||
@@ -171,5 +185,10 @@ struct ToolResultContent: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.task(id: content) {
|
||||
let split = content.components(separatedBy: "\n")
|
||||
lines = split
|
||||
preview = split.prefix(8).joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,21 @@ struct HermesCredentialPool: Identifiable, Sendable {
|
||||
let credentials: [HermesCredential]
|
||||
}
|
||||
|
||||
/// OAuth-authed provider parsed from `auth.json.providers.<name>`. Distinct
|
||||
/// from `HermesCredentialPool` because OAuth providers don't pool — one
|
||||
/// active token per provider, refresh handled by Hermes. Nous, Spotify,
|
||||
/// GitHub Copilot ACP, Qwen, Gemini all land here.
|
||||
struct HermesOAuthProvider: Identifiable, Sendable, Equatable {
|
||||
var id: String { provider }
|
||||
let provider: String // "nous" | "spotify" | ...
|
||||
let tokenTail: String // last 4 of access_token, never the full token
|
||||
let hasAccessToken: Bool
|
||||
let hasRefreshToken: Bool
|
||||
let expiresAt: Date?
|
||||
let portalURL: String? // "portal_base_url" — Nous-specific but generic-shaped
|
||||
let updatedAt: Date?
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CredentialPoolsViewModel {
|
||||
@@ -64,6 +79,13 @@ final class CredentialPoolsViewModel {
|
||||
}
|
||||
|
||||
var pools: [HermesCredentialPool] = []
|
||||
/// OAuth-authed providers from `auth.json.providers.<name>` (Nous,
|
||||
/// Spotify, etc.). These have a different shape from `credential_pool`
|
||||
/// entries — one access token per provider, no rotation strategy —
|
||||
/// so they render in a parallel section rather than as a single-entry
|
||||
/// pool. Without this, OAuth providers were invisible in the UI even
|
||||
/// after a successful sign-in.
|
||||
var oauthProviders: [HermesOAuthProvider] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
@@ -101,13 +123,70 @@ final class CredentialPoolsViewModel {
|
||||
decodedPools = []
|
||||
}
|
||||
|
||||
// OAuth providers are a parallel surface — different shape, so
|
||||
// we parse via `JSONSerialization` instead of folding into the
|
||||
// strict `AuthFile` decoder. A malformed `providers` block is
|
||||
// a non-fatal shrug: empty list, no banner.
|
||||
let oauth = Self.parseOAuthProviders(from: authData)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
self?.pools = decodedPools
|
||||
self?.oauthProviders = oauth
|
||||
self?.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `providers.<name>` entries out of `auth.json` and shape them
|
||||
/// for the UI. Returns an empty array when the file is missing,
|
||||
/// unparseable, or has no `providers` key.
|
||||
nonisolated private static func parseOAuthProviders(from data: Data?) -> [HermesOAuthProvider] {
|
||||
guard let data,
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let providers = root["providers"] as? [String: Any]
|
||||
else { return [] }
|
||||
|
||||
return providers.keys.sorted().compactMap { name in
|
||||
guard let entry = providers[name] as? [String: Any] else { return nil }
|
||||
let access = entry["access_token"] as? String ?? ""
|
||||
let refresh = entry["refresh_token"] as? String ?? ""
|
||||
// Worth surfacing if there's ANY token shape — pre-mint
|
||||
// refresh-only entries shouldn't be hidden.
|
||||
guard !access.isEmpty || !refresh.isEmpty else { return nil }
|
||||
|
||||
let expiresAt: Date? = {
|
||||
if let ms = entry["expires_at_ms"] as? Double, ms > 0 {
|
||||
return Date(timeIntervalSince1970: ms / 1000.0)
|
||||
}
|
||||
if let secs = entry["expires_at"] as? Double, secs > 0 {
|
||||
// Hermes' Nous flow writes epoch seconds as a Double here.
|
||||
return Date(timeIntervalSince1970: secs)
|
||||
}
|
||||
if let iso = entry["expires_at"] as? String {
|
||||
return Self.parseISO8601(iso)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
let updatedAt: Date? = {
|
||||
if let iso = entry["obtained_at"] as? String {
|
||||
return Self.parseISO8601(iso)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
return HermesOAuthProvider(
|
||||
provider: name,
|
||||
tokenTail: Self.tail(of: access.isEmpty ? refresh : access),
|
||||
hasAccessToken: !access.isEmpty,
|
||||
hasRefreshToken: !refresh.isEmpty,
|
||||
expiresAt: expiresAt,
|
||||
portalURL: entry["portal_base_url"] as? String,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
||||
/// Pure-function form so it's safe to call from the detached load task.
|
||||
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
|
||||
|
||||
@@ -20,9 +20,12 @@ struct CredentialPoolsView: View {
|
||||
safetyNotice
|
||||
if viewModel.isLoading {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.pools.isEmpty {
|
||||
} else if viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
if !viewModel.oauthProviders.isEmpty {
|
||||
oauthProvidersSection
|
||||
}
|
||||
ForEach(viewModel.pools) { pool in
|
||||
poolSection(pool)
|
||||
}
|
||||
@@ -37,7 +40,7 @@ struct CredentialPoolsView: View {
|
||||
.loadingOverlay(
|
||||
viewModel.isLoading,
|
||||
label: "Loading credentials…",
|
||||
isEmpty: viewModel.pools.isEmpty
|
||||
isEmpty: viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty
|
||||
)
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
@@ -114,6 +117,97 @@ struct CredentialPoolsView: View {
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
/// Render OAuth-authed providers (`auth.json.providers.<name>`) as a
|
||||
/// single section above the rotation pools. Read-only — Hermes owns
|
||||
/// the write path via `hermes auth add <name>`. Rendered only when
|
||||
/// `viewModel.oauthProviders` is non-empty so users without any
|
||||
/// OAuth-authed providers don't see an empty header.
|
||||
@ViewBuilder
|
||||
private var oauthProvidersSection: some View {
|
||||
SettingsSection(title: LocalizedStringKey("OAuth providers"), icon: "person.badge.key") {
|
||||
ForEach(viewModel.oauthProviders) { provider in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "person.badge.key")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(provider.provider.capitalized)
|
||||
.font(.system(.body, weight: .medium))
|
||||
Text("oauth")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
if !provider.hasAccessToken && provider.hasRefreshToken {
|
||||
Text("refresh-only")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
oauthExpiryBadge(provider)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(provider.tokenTail.isEmpty ? "—" : provider.tokenTail)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if let updated = provider.updatedAt {
|
||||
Text("authed · \(Self.relativeAge(updated))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if let url = provider.portalURL, !url.isEmpty {
|
||||
Text(url)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Text("Managed by `hermes auth add <provider>` — Scarf is read-only here.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func oauthExpiryBadge(_ provider: HermesOAuthProvider) -> some View {
|
||||
if let expiresAt = provider.expiresAt {
|
||||
let secondsRemaining = expiresAt.timeIntervalSinceNow
|
||||
if secondsRemaining <= 0 {
|
||||
Text("expired")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.red)
|
||||
.clipShape(Capsule())
|
||||
} else if secondsRemaining < 7 * 86_400 {
|
||||
let days = max(1, Int(secondsRemaining / 86_400))
|
||||
Text("expires in \(days)d")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||
SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") {
|
||||
@@ -263,6 +357,11 @@ private struct AddCredentialSheet: View {
|
||||
@State private var apiKey: String = ""
|
||||
@State private var label: String = ""
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
/// True while the initial models.dev catalog read is in flight.
|
||||
/// Drives the loading-overlay placeholder. Pre-fix this work ran
|
||||
/// synchronously inside `.onAppear` and froze the sheet for 1–2
|
||||
/// minutes on remote contexts (issue #59).
|
||||
@State private var isLoadingProviders: Bool = true
|
||||
@State private var oauthStarted: Bool = false
|
||||
@State private var authCode: String = ""
|
||||
/// Drives presentation of the dedicated Nous sign-in sheet from inside
|
||||
@@ -291,8 +390,23 @@ private struct AddCredentialSheet: View {
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 600, minHeight: 460)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
.overlay {
|
||||
if isLoadingProviders {
|
||||
ProgressView("Loading providers…")
|
||||
.progressViewStyle(.circular)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Off-MainActor read of the multi-megabyte models.dev cache
|
||||
// (via SSHTransport on remote contexts). Pre-fix this ran
|
||||
// sync inside `.onAppear` and froze the Add Credential sheet
|
||||
// for 1–2 minutes on remote contexts (issue #59).
|
||||
isLoadingProviders = true
|
||||
providers = await catalog.loadProvidersAsync()
|
||||
isLoadingProviders = false
|
||||
}
|
||||
// Auto-close the sheet once a credential is actually saved. We key
|
||||
// off `succeeded` which the controller sets only when hermes exited
|
||||
|
||||
@@ -33,6 +33,14 @@ final class DashboardViewModel {
|
||||
/// surfaceable error.
|
||||
var lastReadError: String?
|
||||
|
||||
/// Projects with their own `<project>/.hermes/` directory shadowing
|
||||
/// the global Hermes home. Hermes' CLI uses the closest `.hermes/`
|
||||
/// when invoked from inside such a project, which silently routes
|
||||
/// `hermes auth add` / setup writes into the project-local copy
|
||||
/// instead of `~/.hermes/`. Surfaced as a yellow banner so users
|
||||
/// can consolidate before more state drifts.
|
||||
var hermesShadows: [ProjectHermesShadowDetector.Shadow] = []
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
// refresh() = close + reopen, forces a fresh remote snapshot. Cheap
|
||||
@@ -110,6 +118,17 @@ final class DashboardViewModel {
|
||||
} else {
|
||||
lastReadError = nil
|
||||
}
|
||||
|
||||
// Probe for projects with shadow `.hermes/` directories. Read-only
|
||||
// — we just stat each registered project's path. Detached so the
|
||||
// SSH round-trips don't block the load completion.
|
||||
let ctx = context
|
||||
let detector = ProjectHermesShadowDetector(context: ctx)
|
||||
let projects = await Task.detached {
|
||||
ProjectDashboardService(context: ctx).loadRegistry().projects
|
||||
}.value
|
||||
hermesShadows = await detector.detect(in: projects)
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ struct DashboardView: View {
|
||||
if let err = viewModel.lastReadError {
|
||||
readErrorBanner(err)
|
||||
}
|
||||
if !viewModel.hermesShadows.isEmpty {
|
||||
hermesShadowBanner(viewModel.hermesShadows)
|
||||
}
|
||||
statusRow
|
||||
statsSection
|
||||
recentTwoColumn
|
||||
@@ -126,6 +129,99 @@ struct DashboardView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Hermes shadow banner
|
||||
|
||||
/// One row per project that carries its own `<project>/.hermes/`
|
||||
/// directory. Hermes' CLI binds to that as `$HERMES_HOME` when run
|
||||
/// from inside, which silently shadows the user's global setup —
|
||||
/// `hermes auth add nous` lands in the project, not in `~/.hermes/`,
|
||||
/// and Scarf's global probes show "missing provider" until consolidated.
|
||||
private func hermesShadowBanner(_ shadows: [ProjectHermesShadowDetector.Shadow]) -> some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Project-local Hermes home shadowing global setup")
|
||||
.scarfStyle(.bodyEmph)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Text("These projects carry their own `.hermes/` directory. Hermes' CLI uses the closest one as `$HERMES_HOME` when run from inside the project, so credentials and config written there don't show up in your global Hermes setup. Consolidate to clear this warning.")
|
||||
.scarfStyle(.footnote)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
ForEach(shadows) { shadow in
|
||||
shadowRow(shadow)
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(ScarfColor.warning.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func shadowRow(_ shadow: ProjectHermesShadowDetector.Shadow) -> some View {
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(shadow.projectName)
|
||||
.scarfStyle(.bodyEmph)
|
||||
Text(shadow.shadowPath)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.textSelection(.enabled)
|
||||
HStack(spacing: 6) {
|
||||
if shadow.hasAuthJSON {
|
||||
Text("auth.json present")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(ScarfColor.warning.opacity(0.20))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if shadow.hasStateDB {
|
||||
Text("state.db present")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(ScarfColor.warning.opacity(0.20))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if shadow.hasAuthJSON {
|
||||
Button("Copy fix command") {
|
||||
Task { @MainActor in
|
||||
let home = await viewModel.context.resolvedUserHome() + "/.hermes"
|
||||
if let cmd = ProjectHermesShadowDetector.consolidationCommand(
|
||||
for: shadow,
|
||||
hermesHome: home
|
||||
) {
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(cmd, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(ScarfSecondaryButton())
|
||||
.controlSize(.small)
|
||||
.help("Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard.")
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
|
||||
.fill(ScarfColor.warning.opacity(0.06))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Status row
|
||||
|
||||
private var statusRow: some View {
|
||||
|
||||
@@ -20,6 +20,16 @@ struct ProfilesView: View {
|
||||
@State private var renameTarget: HermesProfile?
|
||||
@State private var renameNewName = ""
|
||||
@State private var pendingDelete: HermesProfile?
|
||||
/// Remote-import sheet visibility. Local imports use `NSOpenPanel`
|
||||
/// inline; remote imports route through `RemoteProfilePathSheet`
|
||||
/// because the zip the user wants to import lives on the remote
|
||||
/// host (that's where `hermes profile export` produced it), and
|
||||
/// `NSOpenPanel` can only browse the local Mac.
|
||||
@State private var showRemoteImportSheet = false
|
||||
/// When non-nil, the export button on the named profile presents
|
||||
/// `RemoteProfilePathSheet` to ask for an output path on the
|
||||
/// remote host. Local exports continue to use `NSSavePanel`.
|
||||
@State private var pendingRemoteExport: HermesProfile?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -53,6 +63,36 @@ struct ProfilesView: View {
|
||||
} message: {
|
||||
Text("This removes the profile directory and all data within it. This cannot be undone.")
|
||||
}
|
||||
.sheet(isPresented: $showRemoteImportSheet) {
|
||||
RemoteProfilePathSheet(
|
||||
context: viewModel.context,
|
||||
title: "Import profile",
|
||||
prompt: "Enter the path to a profile `.zip` on \(viewModel.context.displayName).",
|
||||
placeholder: "e.g. ~/profiles/my-profile.zip",
|
||||
confirmLabel: "Import",
|
||||
mode: .existingFile,
|
||||
onCancel: { showRemoteImportSheet = false },
|
||||
onConfirm: { path in
|
||||
showRemoteImportSheet = false
|
||||
viewModel.import(from: path)
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(item: $pendingRemoteExport) { profile in
|
||||
RemoteProfilePathSheet(
|
||||
context: viewModel.context,
|
||||
title: "Export profile '\(profile.name)'",
|
||||
prompt: "Enter the destination path on \(viewModel.context.displayName) where the `.zip` should be written.",
|
||||
placeholder: "e.g. ~/\(profile.name)-profile.zip",
|
||||
confirmLabel: "Export",
|
||||
mode: .writableFile(initialName: "\(profile.name)-profile.zip"),
|
||||
onCancel: { pendingRemoteExport = nil },
|
||||
onConfirm: { path in
|
||||
pendingRemoteExport = nil
|
||||
viewModel.export(profile, to: path)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var listSection: some View {
|
||||
@@ -72,13 +112,21 @@ struct ProfilesView: View {
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.import(from: url.path)
|
||||
if viewModel.context.isRemote {
|
||||
// The zip lives on the remote (where `hermes profile
|
||||
// export` produced it). NSOpenPanel can only browse
|
||||
// the user's Mac, so route through a remote-path
|
||||
// input sheet instead.
|
||||
showRemoteImportSheet = true
|
||||
} else {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.import(from: url.path)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
@@ -119,11 +167,20 @@ struct ProfilesView: View {
|
||||
renameNewName = profile.name
|
||||
}
|
||||
Button("Export…") {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.export(profile, to: url.path)
|
||||
if viewModel.context.isRemote {
|
||||
// Exporting a remote profile must write to a
|
||||
// remote path — NSSavePanel would write to
|
||||
// the user's Mac, leaving the remote
|
||||
// profile zip nowhere on the host where
|
||||
// anyone can use it.
|
||||
pendingRemoteExport = profile
|
||||
} else {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.export(profile, to: url.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
@@ -264,3 +321,147 @@ struct ProfilesView: View {
|
||||
.frame(minWidth: 440, minHeight: 180)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote-path picker for profile import + export. Used when the active
|
||||
/// `ServerContext` is `.ssh` — `NSOpenPanel` / `NSSavePanel` would
|
||||
/// browse the user's Mac, which is the wrong host. The sheet takes a
|
||||
/// remote path string and verifies it via the active transport before
|
||||
/// handing it back. The `mode` distinguishes "must already exist" from
|
||||
/// "we're about to write here," each with appropriate validation.
|
||||
private struct RemoteProfilePathSheet: View {
|
||||
enum Mode {
|
||||
/// Import flow: zip must already exist on the remote.
|
||||
case existingFile
|
||||
/// Export flow: we'll be writing to the path. Permissive on
|
||||
/// non-existence (that's expected); warn on existing dir or
|
||||
/// non-zip extension.
|
||||
case writableFile(initialName: String)
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
let title: String
|
||||
let prompt: String
|
||||
let placeholder: String
|
||||
let confirmLabel: String
|
||||
let mode: Mode
|
||||
let onCancel: () -> Void
|
||||
let onConfirm: (String) -> Void
|
||||
|
||||
@State private var path: String = ""
|
||||
@State private var verification: Verification = .idle
|
||||
|
||||
private enum Verification: Equatable {
|
||||
case idle
|
||||
case verifying
|
||||
case ok(String)
|
||||
case warn(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title).font(.headline)
|
||||
Text(prompt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack {
|
||||
TextField(placeholder, text: $path)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: path) { _, _ in
|
||||
if verification != .idle { verification = .idle }
|
||||
}
|
||||
Button("Verify") { Task { await verify() } }
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| verification == .verifying)
|
||||
}
|
||||
verificationBadge
|
||||
HStack {
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button(confirmLabel) {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onConfirm(trimmed)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
.onAppear {
|
||||
if case .writableFile(let initialName) = mode, path.isEmpty {
|
||||
path = "~/" + initialName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var verificationBadge: some View {
|
||||
switch verification {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .verifying:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking on \(context.displayName)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .ok(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(detail).font(.caption)
|
||||
}
|
||||
case .warn(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(detail).font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verify() async {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
verification = .verifying
|
||||
let snapshot = context
|
||||
let snapshotMode = mode
|
||||
let result: Verification = await Task.detached {
|
||||
let transport = snapshot.makeTransport()
|
||||
let exists = transport.fileExists(trimmed)
|
||||
switch snapshotMode {
|
||||
case .existingFile:
|
||||
guard exists else {
|
||||
return .warn("Path doesn't exist on \(snapshot.displayName).")
|
||||
}
|
||||
guard let stat = transport.stat(trimmed) else {
|
||||
return .warn("Found, but couldn't stat — check permissions.")
|
||||
}
|
||||
if stat.isDirectory {
|
||||
return .warn("Path is a directory, not a file.")
|
||||
}
|
||||
if !trimmed.lowercased().hasSuffix(".zip") {
|
||||
return .warn("File found, but extension isn't `.zip`. Profile import expects a zip archive.")
|
||||
}
|
||||
return .ok("File found on \(snapshot.displayName).")
|
||||
case .writableFile:
|
||||
if exists {
|
||||
if let stat = transport.stat(trimmed), stat.isDirectory {
|
||||
return .warn("Path is a directory. Choose a file path that doesn't yet exist.")
|
||||
}
|
||||
return .warn("File already exists on \(snapshot.displayName) — export will overwrite it.")
|
||||
}
|
||||
if !trimmed.lowercased().hasSuffix(".zip") {
|
||||
return .warn("Extension isn't `.zip`. The export command writes a zip archive.")
|
||||
}
|
||||
return .ok("Path is available on \(snapshot.displayName).")
|
||||
}
|
||||
}.value
|
||||
verification = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ struct ProjectsView: View {
|
||||
onAddProject: { showingAddSheet = true }
|
||||
)
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
AddProjectSheet { name, path in
|
||||
AddProjectSheet(context: serverContext) { name, path in
|
||||
viewModel.addProject(name: name, path: path)
|
||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||
}
|
||||
@@ -593,28 +593,38 @@ struct AddProjectSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var projectName = ""
|
||||
@State private var projectPath = ""
|
||||
/// Inline verification result for remote contexts (issue #54).
|
||||
/// Renders alongside the path field as a green check / red x so
|
||||
/// users learn whether a remote path is valid BEFORE they hit Add
|
||||
/// and the agent's tool calls fail at runtime.
|
||||
@State private var remoteVerification: RemoteVerification = .idle
|
||||
/// Active server context. On remote contexts the local Browse
|
||||
/// button is hidden (NSOpenPanel browses the Mac filesystem,
|
||||
/// useless when the project lives on a remote host) and replaced
|
||||
/// with a Verify button driven by the SSH transport's `stat`.
|
||||
let context: ServerContext
|
||||
let onAdd: (String, String) -> Void
|
||||
|
||||
private enum RemoteVerification: Equatable {
|
||||
case idle
|
||||
case verifying
|
||||
case ok(String) // green: "Directory exists (1.2k items)" etc.
|
||||
case warn(String) // red: missing / not a dir / unreadable
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Add Project")
|
||||
.font(.headline)
|
||||
TextField("Project Name", text: $projectName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
TextField("Project Path", text: $projectPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Browse...") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
projectPath = url.path
|
||||
if projectName.isEmpty {
|
||||
projectName = url.lastPathComponent
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
pathInputRow
|
||||
if context.isRemote {
|
||||
Text("Path on \(context.displayName) — must already exist on the server. Tool calls run with this directory as their working directory.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
verificationBadge
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
@@ -631,6 +641,102 @@ struct AddProjectSheet: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 400)
|
||||
.frame(width: 440)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pathInputRow: some View {
|
||||
HStack {
|
||||
TextField("Project Path", text: $projectPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: projectPath) { _, _ in
|
||||
// Stale verification once the path edits — reset to
|
||||
// idle so users don't see a green check for a path
|
||||
// they've since changed.
|
||||
if remoteVerification != .idle {
|
||||
remoteVerification = .idle
|
||||
}
|
||||
}
|
||||
if context.isRemote {
|
||||
Button("Verify") {
|
||||
Task { await verifyRemotePath() }
|
||||
}
|
||||
.disabled(projectPath.isEmpty || remoteVerification == .verifying)
|
||||
} else {
|
||||
Button("Browse...") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
projectPath = url.path
|
||||
if projectName.isEmpty {
|
||||
projectName = url.lastPathComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var verificationBadge: some View {
|
||||
switch remoteVerification {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .verifying:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking on \(context.displayName)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .ok(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
case .warn(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the entered path on the remote via the existing SSH
|
||||
/// transport. Uses `stat` (not just `fileExists`) so we can reject
|
||||
/// files-that-aren't-dirs without a separate round trip.
|
||||
private func verifyRemotePath() async {
|
||||
let path = projectPath.trimmingCharacters(in: .whitespaces)
|
||||
guard !path.isEmpty, context.isRemote else { return }
|
||||
remoteVerification = .verifying
|
||||
|
||||
let snapshot = context
|
||||
let result: RemoteVerification = await Task.detached {
|
||||
let transport = snapshot.makeTransport()
|
||||
guard transport.fileExists(path) else {
|
||||
return .warn("Path doesn't exist on \(snapshot.displayName).")
|
||||
}
|
||||
guard let stat = transport.stat(path) else {
|
||||
// Stat failed even though `test -e` passed — typically
|
||||
// a permission issue on the parent dir. Surface as a
|
||||
// warning so the user knows the path is reachable but
|
||||
// not introspectable.
|
||||
return .warn("Found, but couldn't stat — check parent directory permissions.")
|
||||
}
|
||||
if stat.isDirectory {
|
||||
return .ok("Directory exists on \(snapshot.displayName).")
|
||||
} else {
|
||||
return .warn("Path is a file, not a directory. Project paths must be directories.")
|
||||
}
|
||||
}.value
|
||||
remoteVerification = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ final class AddServerViewModel {
|
||||
var identityFile: String = ""
|
||||
/// Override for `~/.hermes` on the remote. Empty = default.
|
||||
var remoteHome: String = ""
|
||||
/// Override for the parent dir under which template installs land on
|
||||
/// this host. Empty = default (`~/projects`). Created on first install
|
||||
/// if missing.
|
||||
var projectsRoot: String = ""
|
||||
|
||||
var isTesting: Bool = false
|
||||
/// Outcome of the most recent Test Connection run. `nil` = not yet run.
|
||||
@@ -44,6 +48,7 @@ final class AddServerViewModel {
|
||||
port: Int(port),
|
||||
identityFile: nonEmpty(identityFile),
|
||||
remoteHome: nonEmpty(remoteHome),
|
||||
projectsRoot: nonEmpty(projectsRoot),
|
||||
hermesBinaryHint: nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ final class RemoteDiagnosticsViewModel {
|
||||
case .hermesHomeConfigured: return "Hermes home directory"
|
||||
case .hermesDirExists: return "Hermes directory exists"
|
||||
case .hermesDirReadable: return "Hermes directory readable"
|
||||
case .configYAMLReadable: return "config.yaml readable"
|
||||
case .configYAMLContents: return "config.yaml actually readable (content)"
|
||||
case .configYAMLReadable: return "config.yaml readable (optional)"
|
||||
case .configYAMLContents: return "config.yaml content (optional)"
|
||||
case .stateDBReadable: return "state.db readable"
|
||||
case .sqlite3Installed: return "sqlite3 binary installed on remote"
|
||||
case .sqlite3CanOpenStateDB: return "sqlite3 can open state.db"
|
||||
@@ -75,11 +75,15 @@ final class RemoteDiagnosticsViewModel {
|
||||
case .hermesDirReadable:
|
||||
return "The SSH user can see `~/.hermes` but can't list it. Check permissions: `ls -ld ~/.hermes` on the remote — the SSH user needs at least `r-x`."
|
||||
case .configYAMLReadable, .configYAMLContents:
|
||||
return "Scarf can't read `config.yaml`. This usually means the SSH user is different from the user Hermes runs as. Either (a) run Hermes as the SSH user, (b) `chmod a+r ~/.hermes/config.yaml`, or (c) configure Scarf to SSH as the Hermes user."
|
||||
// Reached only when the file EXISTS but is unreadable —
|
||||
// a real permission issue. The "file absent" case emits
|
||||
// SKIP (Hermes v0.11+ creates config.yaml lazily, only
|
||||
// when the user changes a setting from defaults).
|
||||
return "`config.yaml` exists on the remote but the SSH user can't read it. Either (a) run Hermes as the SSH user, (b) `chmod a+r ~/.hermes/config.yaml`, or (c) configure Scarf to SSH as the Hermes user. If `config.yaml` is missing entirely, that's fine — Hermes only creates it when you change a setting from the defaults."
|
||||
case .stateDBReadable:
|
||||
return "Scarf can't read `state.db` — Sessions, Activity, Dashboard stats all depend on this. Same fix pattern as config.yaml."
|
||||
return "Scarf can't read `state.db` — Sessions, Activity, Dashboard stats all depend on this. Either (a) run Hermes as the SSH user, (b) `chmod a+r ~/.hermes/state.db`, or (c) configure Scarf to SSH as the Hermes user."
|
||||
case .sqlite3Installed:
|
||||
return "Scarf pulls a snapshot of state.db via `sqlite3 .backup`, so sqlite3 must be installed on the remote. Install: `sudo apt install sqlite3` (Ubuntu/Debian), `sudo yum install sqlite` (RHEL/Fedora), `apk add sqlite` (Alpine)."
|
||||
return "Scarf pulls a snapshot of state.db via `sqlite3 .backup`, so sqlite3 must be installed on the remote AND visible to non-interactive SSH sessions. The probe sources `~/.zshenv` / `.zprofile` / `.bash_profile` / `.profile` and falls back to `/usr/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, and `/opt/local/bin` — if it's still not found, either install via your package manager (`sudo apt install sqlite3` / `sudo yum install sqlite` / `apk add sqlite`) or symlink the existing binary into a location the probe checks (e.g. `sudo ln -s /your/path/sqlite3 /usr/local/bin/sqlite3`)."
|
||||
case .sqlite3CanOpenStateDB:
|
||||
return "sqlite3 exists but can't open state.db. Could be a permission issue, a corrupt DB, or a version skew."
|
||||
case .hermesBinaryNonLogin:
|
||||
@@ -92,10 +96,26 @@ final class RemoteDiagnosticsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tri-state probe outcome. `.skipped` covers checks that didn't
|
||||
/// run because they aren't applicable (e.g. config.yaml absence on
|
||||
/// a fresh Hermes v0.11+ install — the file is created lazily, so
|
||||
/// missing is normal). UI renders skipped probes with a grey info
|
||||
/// icon and excludes them from "X/Y failing" tallies.
|
||||
enum ProbeStatus: Sendable, Equatable {
|
||||
case pass
|
||||
case fail
|
||||
case skipped
|
||||
}
|
||||
|
||||
struct Probe: Identifiable, Sendable {
|
||||
let id: ProbeID
|
||||
let passed: Bool
|
||||
let status: ProbeStatus
|
||||
let detail: String
|
||||
|
||||
/// Back-compat for callers (Copy Full Report, view counters)
|
||||
/// that still think in pass/fail. Skipped probes report `true`
|
||||
/// so they don't count as failures.
|
||||
var passed: Bool { status != .fail }
|
||||
}
|
||||
|
||||
private(set) var probes: [Probe] = []
|
||||
@@ -123,7 +143,11 @@ final class RemoteDiagnosticsViewModel {
|
||||
finishedAt = nil
|
||||
|
||||
let script = Self.buildScript(hermesHome: context.paths.home)
|
||||
let captured = await Self.execute(script: script, context: context)
|
||||
// Use the shared SSHScriptRunner so this view model and the
|
||||
// ConnectionStatusViewModel pill always agree on what the
|
||||
// remote sees (issue #44 — the prior local copies of the
|
||||
// workaround drifted from each other).
|
||||
let captured = await SSHScriptRunner.run(script: script, context: context, timeout: 30)
|
||||
|
||||
switch captured {
|
||||
case .connectFailure(let msg):
|
||||
@@ -131,10 +155,10 @@ final class RemoteDiagnosticsViewModel {
|
||||
rawStderr = msg
|
||||
rawExitCode = -1
|
||||
probes = [
|
||||
Probe(id: .connectivity, passed: false, detail: msg)
|
||||
Probe(id: .connectivity, status: .fail, detail: msg)
|
||||
] + ProbeID.allCases
|
||||
.filter { $0 != .connectivity }
|
||||
.map { Probe(id: $0, passed: false, detail: "(skipped — SSH didn't connect)") }
|
||||
.map { Probe(id: $0, status: .fail, detail: "(skipped — SSH didn't connect)") }
|
||||
case .completed(let stdout, let stderr, let exitCode):
|
||||
rawStdout = stdout
|
||||
rawStderr = stderr
|
||||
@@ -147,18 +171,37 @@ final class RemoteDiagnosticsViewModel {
|
||||
Self.logger.info("Diagnostics for \(self.context.displayName, privacy: .public) finished — \(self.passingCount)/\(self.probes.count) passing")
|
||||
}
|
||||
|
||||
/// Quick summary string, e.g. "9/14 passing". Used in the header.
|
||||
/// Quick summary string. Skipped probes (e.g. config.yaml absent
|
||||
/// on a fresh Hermes v0.11+ install) are excluded from the
|
||||
/// denominator so the user sees "12/12 passing" instead of a
|
||||
/// misleading "12/14 passing." When any probe is skipped we
|
||||
/// append a parenthetical so it's still visible at a glance.
|
||||
var summary: String {
|
||||
guard !probes.isEmpty else { return "Not yet run." }
|
||||
return "\(passingCount)/\(probes.count) checks passing"
|
||||
let total = probes.filter { $0.status != .skipped }.count
|
||||
var s = "\(passingCount)/\(total) checks passing"
|
||||
if skippedCount > 0 {
|
||||
s += " (\(skippedCount) optional skipped)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var passingCount: Int {
|
||||
probes.filter { $0.passed }.count
|
||||
probes.filter { $0.status == .pass }.count
|
||||
}
|
||||
|
||||
var skippedCount: Int {
|
||||
probes.filter { $0.status == .skipped }.count
|
||||
}
|
||||
|
||||
var failingCount: Int {
|
||||
probes.filter { $0.status == .fail }.count
|
||||
}
|
||||
|
||||
/// True iff every applicable probe passed — skipped probes don't
|
||||
/// block the green-banner state because they're informational.
|
||||
var allPassed: Bool {
|
||||
!probes.isEmpty && passingCount == probes.count
|
||||
!probes.isEmpty && failingCount == 0
|
||||
}
|
||||
|
||||
// MARK: - Script + parsing
|
||||
@@ -206,21 +249,32 @@ final class RemoteDiagnosticsViewModel {
|
||||
emit hermesDirReadable FAIL "cannot read/enter $H (check perms on the dir)"
|
||||
fi
|
||||
|
||||
# config.yaml is OPTIONAL on Hermes v0.11+ — the file is created
|
||||
# lazily when the user changes a setting from defaults. So a
|
||||
# working fresh install is expected to have no config.yaml.
|
||||
# The probe distinguishes:
|
||||
# PASS — file exists and is readable
|
||||
# SKIP — file is absent (informational, not a failure)
|
||||
# FAIL — file exists but the SSH user can't read it (real perm issue)
|
||||
if [ -r "$H/config.yaml" ]; then
|
||||
emit configYAMLReadable PASS ""
|
||||
else
|
||||
if [ -e "$H/config.yaml" ]; then
|
||||
emit configYAMLReadable FAIL "exists but not readable by $user"
|
||||
else
|
||||
emit configYAMLReadable FAIL "file does not exist"
|
||||
emit configYAMLReadable SKIP "not present (Hermes creates it on first config change)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if head -c 1 "$H/config.yaml" > /dev/null 2>&1; then
|
||||
size=$(wc -c < "$H/config.yaml" 2>/dev/null | tr -d ' ')
|
||||
emit configYAMLContents PASS "${size} bytes"
|
||||
if [ -e "$H/config.yaml" ]; then
|
||||
if head -c 1 "$H/config.yaml" > /dev/null 2>&1; then
|
||||
size=$(wc -c < "$H/config.yaml" 2>/dev/null | tr -d ' ')
|
||||
emit configYAMLContents PASS "${size} bytes"
|
||||
else
|
||||
emit configYAMLContents FAIL "cannot read file contents"
|
||||
fi
|
||||
else
|
||||
emit configYAMLContents FAIL "cannot read file contents"
|
||||
emit configYAMLContents SKIP "not present (no content to read)"
|
||||
fi
|
||||
|
||||
if [ -r "$H/state.db" ]; then
|
||||
@@ -234,21 +288,10 @@ final class RemoteDiagnosticsViewModel {
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v sqlite3 > /dev/null 2>&1; then
|
||||
sq=$(command -v sqlite3)
|
||||
emit sqlite3Installed PASS "$sq"
|
||||
else
|
||||
emit sqlite3Installed FAIL "sqlite3 not on PATH"
|
||||
fi
|
||||
|
||||
if sqlite3 "$H/state.db" 'SELECT 1' > /dev/null 2>&1; then
|
||||
emit sqlite3CanOpenStateDB PASS ""
|
||||
else
|
||||
err=$(sqlite3 "$H/state.db" 'SELECT 1' 2>&1 | head -1)
|
||||
emit sqlite3CanOpenStateDB FAIL "$err"
|
||||
fi
|
||||
|
||||
# Non-login PATH: just ask the current shell.
|
||||
# Non-login PATH probe for `hermes` runs in the bare shell BEFORE
|
||||
# sourcing rc files — that semantic ("is hermes on the un-enriched
|
||||
# PATH the SSH session inherits?") is meaningful and we don't
|
||||
# want to muddle it.
|
||||
hpath=$(command -v hermes 2>/dev/null)
|
||||
if [ -n "$hpath" ]; then
|
||||
emit hermesBinaryNonLogin PASS "$hpath"
|
||||
@@ -256,10 +299,18 @@ final class RemoteDiagnosticsViewModel {
|
||||
emit hermesBinaryNonLogin FAIL "not on non-login PATH ($PATH)"
|
||||
fi
|
||||
|
||||
# Login PATH: source rc files (mirroring TestConnectionProbe) and re-probe.
|
||||
# Source rc files (mirroring TestConnectionProbe) so subsequent
|
||||
# probes see the user's full login PATH. sqlite3 / hermes-login
|
||||
# detection happens AFTER this so installs in Homebrew /
|
||||
# `/usr/local/bin` / pipx / etc. are findable on hosts where the
|
||||
# non-login SSH session inherits a stripped PATH (issue #19,
|
||||
# @cmalpass's case where sqlite3 was installed but probed as
|
||||
# missing — the non-login shell didn't have Homebrew on PATH).
|
||||
for rc in "$HOME/.zshenv" "$HOME/.zprofile" "$HOME/.bash_profile" "$HOME/.profile"; do
|
||||
[ -f "$rc" ] && . "$rc" 2>/dev/null
|
||||
done
|
||||
|
||||
# Login-PATH `hermes` probe with hardcoded candidate fallback.
|
||||
hpath2=$(command -v hermes 2>/dev/null)
|
||||
if [ -z "$hpath2" ]; then
|
||||
for cand in "$HOME/.local/bin/hermes" "/opt/homebrew/bin/hermes" "/usr/local/bin/hermes" "$HOME/.hermes/bin/hermes"; do
|
||||
@@ -272,6 +323,36 @@ final class RemoteDiagnosticsViewModel {
|
||||
emit hermesBinaryLogin FAIL "not found after sourcing rc files"
|
||||
fi
|
||||
|
||||
# sqlite3 detection — also after sourcing rc files, with a
|
||||
# standard-location fallback that mirrors the hermes probe
|
||||
# above. Pre-fix this was a bare `command -v sqlite3` in the
|
||||
# non-login shell, which produced false negatives on Homebrew
|
||||
# / `/usr/local/bin` installs (issue #19 layer 3).
|
||||
sqbin=$(command -v sqlite3 2>/dev/null)
|
||||
if [ -z "$sqbin" ]; then
|
||||
for cand in "/usr/bin/sqlite3" "/usr/local/bin/sqlite3" "/opt/homebrew/bin/sqlite3" "/opt/local/bin/sqlite3"; do
|
||||
if [ -x "$cand" ]; then sqbin="$cand"; break; fi
|
||||
done
|
||||
fi
|
||||
if [ -n "$sqbin" ]; then
|
||||
emit sqlite3Installed PASS "$sqbin"
|
||||
else
|
||||
emit sqlite3Installed FAIL "not found on PATH or in standard locations"
|
||||
fi
|
||||
|
||||
# Use the resolved sqlite3 path explicitly so the open-state.db
|
||||
# probe doesn't re-fail-by-PATH when the binary is at e.g.
|
||||
# /opt/homebrew/bin. Falls back to bare `sqlite3` so the FAIL
|
||||
# detail line (with the underlying error) is still informative
|
||||
# if no candidate was found.
|
||||
sqcmd="${sqbin:-sqlite3}"
|
||||
if "$sqcmd" "$H/state.db" 'SELECT 1' > /dev/null 2>&1; then
|
||||
emit sqlite3CanOpenStateDB PASS ""
|
||||
else
|
||||
err=$("$sqcmd" "$H/state.db" 'SELECT 1' 2>&1 | head -1)
|
||||
emit sqlite3CanOpenStateDB FAIL "$err"
|
||||
fi
|
||||
|
||||
if command -v pgrep > /dev/null 2>&1; then
|
||||
emit pgrepAvailable PASS "$(command -v pgrep)"
|
||||
else
|
||||
@@ -282,176 +363,24 @@ final class RemoteDiagnosticsViewModel {
|
||||
"""#
|
||||
}
|
||||
|
||||
enum Captured {
|
||||
case connectFailure(String)
|
||||
case completed(stdout: String, stderr: String, exitCode: Int32)
|
||||
}
|
||||
|
||||
private static func execute(script: String, context: ServerContext) async -> Captured {
|
||||
// Can't use `transport.runProcess(executable: "/bin/sh", args: ["-c", script])`
|
||||
// here: SSHTransport.runProcess pipes every argument through
|
||||
// `remotePathArg` (which double-quotes to rewrite `~/` → `$HOME/`),
|
||||
// which mangles a multi-line shell script containing `"$1"`,
|
||||
// nested quotes, and `printf` escape sequences. The result on the
|
||||
// remote is a scrambled string and every probe fails to emit.
|
||||
//
|
||||
// Mirror TestConnectionProbe's approach: build the ssh argv
|
||||
// directly so the script travels as a single opaque argv entry
|
||||
// that ssh forwards to the remote shell unchanged.
|
||||
switch context.kind {
|
||||
case .local:
|
||||
return await runLocally(script: script)
|
||||
case .ssh(let config):
|
||||
return await runOverSSH(script: script, config: config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct ssh invocation. Pipes the script into `sh` on stdin rather
|
||||
/// than passing it as `sh -c <script>` argv — because ssh concatenates
|
||||
/// argv with spaces and sends that as a single command string to the
|
||||
/// remote's LOGIN shell, which then parses newlines as command
|
||||
/// separators. A multi-line `sh -c <script>` would run only the first
|
||||
/// line inside the `sh` subprocess (any variables set there die when
|
||||
/// `sh` exits), and the rest would run in the login shell with no
|
||||
/// access to those variables. Symptom: `$H=""` everywhere downstream.
|
||||
///
|
||||
/// Feeding the script via stdin avoids the split entirely — `sh -s`
|
||||
/// consumes the whole stream in one process, so variable scope is
|
||||
/// preserved and the script runs exactly the same way it would from
|
||||
/// a local `cat script.sh | sh`.
|
||||
private static func runOverSSH(script: String, config: SSHConfig) async -> Captured {
|
||||
var sshArgv: [String] = [
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=\(controlDirPath())/%C",
|
||||
"-o", "ControlPersist=600",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "LogLevel=QUIET",
|
||||
"-o", "BatchMode=yes",
|
||||
"-T" // no pty — keep stdin/stdout a clean byte stream
|
||||
]
|
||||
if let port = config.port { sshArgv += ["-p", String(port)] }
|
||||
if let id = config.identityFile, !id.isEmpty {
|
||||
sshArgv += ["-i", id]
|
||||
}
|
||||
let hostSpec: String
|
||||
if let user = config.user, !user.isEmpty { hostSpec = "\(user)@\(config.host)" }
|
||||
else { hostSpec = config.host }
|
||||
sshArgv.append(hostSpec)
|
||||
sshArgv.append("--")
|
||||
sshArgv.append("/bin/sh")
|
||||
sshArgv.append("-s") // read script from stdin
|
||||
|
||||
return await Task.detached { () -> Captured in
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
proc.arguments = sshArgv
|
||||
|
||||
// Inherit the shell's SSH_AUTH_SOCK so ssh can reach the
|
||||
// agent — same pattern as SSHTransport + TestConnectionProbe.
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
let shellEnv = HermesFileService.enrichedEnvironment()
|
||||
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
|
||||
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
|
||||
env[key] = v
|
||||
}
|
||||
}
|
||||
proc.environment = env
|
||||
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
proc.standardInput = stdinPipe
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
return .connectFailure("Failed to launch ssh: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Write the script to ssh's stdin, then close the write end so
|
||||
// remote sh sees EOF and exits after executing the whole script.
|
||||
if let data = script.data(using: .utf8) {
|
||||
try? stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
||||
}
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
|
||||
let deadline = Date().addingTimeInterval(30)
|
||||
while proc.isRunning && Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
return .connectFailure("Diagnostics timed out after 30s")
|
||||
}
|
||||
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
return .completed(
|
||||
stdout: String(data: out, encoding: .utf8) ?? "",
|
||||
stderr: String(data: err, encoding: .utf8) ?? "",
|
||||
exitCode: proc.terminationStatus
|
||||
)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Local Shell invocation — runs the diagnostic script against the
|
||||
/// user's own Mac. Less useful than the remote form (most checks will
|
||||
/// trivially pass), but lets the same UI work for both contexts.
|
||||
private static func runLocally(script: String) async -> Captured {
|
||||
return await Task.detached { () -> Captured in
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
proc.arguments = ["-c", script]
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
proc.standardOutput = stdoutPipe
|
||||
proc.standardError = stderrPipe
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
return .connectFailure("Failed to launch /bin/sh: \(error.localizedDescription)")
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while proc.isRunning && Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
if proc.isRunning {
|
||||
proc.terminate()
|
||||
return .connectFailure("Local diagnostics timed out (should be <1s)")
|
||||
}
|
||||
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
return .completed(
|
||||
stdout: String(data: out, encoding: .utf8) ?? "",
|
||||
stderr: String(data: err, encoding: .utf8) ?? "",
|
||||
exitCode: proc.terminationStatus
|
||||
)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Same cache directory used by SSHTransport — shared so the diagnostic
|
||||
/// probe reuses the connection's ControlMaster socket when it already
|
||||
/// exists (no second TCP handshake, no second auth).
|
||||
private static func controlDirPath() -> String {
|
||||
SSHTransport.controlDirPath()
|
||||
}
|
||||
|
||||
private static func parse(stdout: String, stderr: String, exitCode: Int32) -> [Probe] {
|
||||
var results: [ProbeID: Probe] = [:]
|
||||
for line in stdout.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
|
||||
let parts = line.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard parts.count == 3 else { continue }
|
||||
let key = String(parts[0]).trimmingCharacters(in: .whitespaces)
|
||||
let status = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
let statusRaw = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
let detail = String(parts[2]).trimmingCharacters(in: .whitespaces)
|
||||
guard let probe = ProbeID(rawValue: key) else { continue }
|
||||
let status: ProbeStatus
|
||||
switch statusRaw {
|
||||
case "PASS": status = .pass
|
||||
case "SKIP": status = .skipped
|
||||
default: status = .fail
|
||||
}
|
||||
results[probe] = Probe(
|
||||
id: probe,
|
||||
passed: status == "PASS",
|
||||
status: status,
|
||||
detail: detail
|
||||
)
|
||||
}
|
||||
@@ -469,7 +398,7 @@ final class RemoteDiagnosticsViewModel {
|
||||
}
|
||||
|
||||
return ProbeID.allCases.map { id in
|
||||
results[id] ?? Probe(id: id, passed: false, detail: fallbackDetail)
|
||||
results[id] ?? Probe(id: id, status: .fail, detail: fallbackDetail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,16 @@ struct AddServerSheet: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
LabeledField("Projects directory") {
|
||||
TextField("Default: ~/projects", text: $viewModel.projectsRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
Text("Where Scarf installs new project templates on this host. Created on first install if missing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text("Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -10,6 +10,7 @@ import ScarfDesign
|
||||
struct ConnectionStatusPill: View {
|
||||
let status: ConnectionStatusViewModel
|
||||
@State private var showDetails = false
|
||||
@State private var showDegraded = false
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
var body: some View {
|
||||
@@ -18,9 +19,10 @@ struct ConnectionStatusPill: View {
|
||||
case .error:
|
||||
showDetails = true
|
||||
case .degraded:
|
||||
// Yellow "can't read" state — open the diagnostics sheet
|
||||
// so the user can see exactly which files fail and why.
|
||||
showDiagnostics = true
|
||||
// Show the granular reason + hint inline first (issue
|
||||
// #53). The user can drill into the full diagnostics
|
||||
// sheet from the popover if the hint isn't enough.
|
||||
showDegraded = true
|
||||
case .connected, .idle:
|
||||
status.retry()
|
||||
}
|
||||
@@ -45,6 +47,9 @@ struct ConnectionStatusPill: View {
|
||||
.popover(isPresented: $showDetails, arrowEdge: .bottom) {
|
||||
errorDetails.frame(width: 400)
|
||||
}
|
||||
.popover(isPresented: $showDegraded, arrowEdge: .bottom) {
|
||||
degradedDetails.frame(width: 440)
|
||||
}
|
||||
.sheet(isPresented: $showDiagnostics) {
|
||||
RemoteDiagnosticsView(context: status.context)
|
||||
}
|
||||
@@ -75,7 +80,7 @@ struct ConnectionStatusPill: View {
|
||||
private var labelText: Text {
|
||||
switch status.status {
|
||||
case .connected: return Text("Connected")
|
||||
case .degraded: return Text("Connected — can't read Hermes state")
|
||||
case .degraded(let reason, _, _): return Text("Connected — \(reason)")
|
||||
case .idle: return Text("Checking…")
|
||||
case .error(let message, _): return Text(verbatim: message)
|
||||
}
|
||||
@@ -89,13 +94,75 @@ struct ConnectionStatusPill: View {
|
||||
return Text("Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))")
|
||||
}
|
||||
return Text("Connected")
|
||||
case .degraded(let reason):
|
||||
return Text("SSH works but \(reason). Click for diagnostics.")
|
||||
case .degraded(let reason, _, _):
|
||||
return Text("SSH works but \(reason). Click for details.")
|
||||
case .idle: return Text("Waiting for first probe")
|
||||
case .error: return Text("Click for details")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var degradedDetails: some View {
|
||||
if case .degraded(let reason, let hint, let cause) = status.status {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top) {
|
||||
Label(reason, systemImage: "stethoscope")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
.scarfStyle(.headline)
|
||||
Spacer()
|
||||
}
|
||||
Divider()
|
||||
Text(hint)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if case .profileActive(let name) = cause {
|
||||
// Specific copy-paste affordance for the profile case
|
||||
// — the most actionable hint, surfaced inline.
|
||||
profileFixCommand(name: name)
|
||||
}
|
||||
HStack {
|
||||
Button("Run diagnostics") {
|
||||
showDegraded = false
|
||||
showDiagnostics = true
|
||||
}
|
||||
.buttonStyle(ScarfSecondaryButton())
|
||||
Spacer()
|
||||
Button("Retry") {
|
||||
status.retry()
|
||||
showDegraded = false
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 440)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func profileFixCommand(name _: String) -> some View {
|
||||
let command = "hermes profile use default"
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Or run this on the remote to switch back to the default profile:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Text(command)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(6)
|
||||
.background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
|
||||
Spacer()
|
||||
Button("Copy") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(command, forType: .string)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var errorDetails: some View {
|
||||
if case .error(let message, let stderr) = status.status {
|
||||
|
||||
@@ -93,8 +93,10 @@ struct RemoteDiagnosticsView: View {
|
||||
|
||||
private func probeRow(_ probe: RemoteDiagnosticsViewModel.Probe) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: probe.passed ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(probe.passed ? .green : .red)
|
||||
// Tri-state icon: green check on pass, red x on fail, grey
|
||||
// info-circle on skipped (the optional-and-absent state).
|
||||
Image(systemName: iconName(for: probe.status))
|
||||
.foregroundStyle(iconColor(for: probe.status))
|
||||
.font(.title3)
|
||||
.padding(.top, 2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -106,7 +108,7 @@ struct RemoteDiagnosticsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if !probe.passed, let hint = probe.id.failureHint {
|
||||
if probe.status == .fail, let hint = probe.id.failureHint {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "lightbulb")
|
||||
.foregroundStyle(.yellow)
|
||||
@@ -128,6 +130,22 @@ struct RemoteDiagnosticsView: View {
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func iconName(for status: RemoteDiagnosticsViewModel.ProbeStatus) -> String {
|
||||
switch status {
|
||||
case .pass: return "checkmark.circle.fill"
|
||||
case .fail: return "xmark.circle.fill"
|
||||
case .skipped: return "info.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func iconColor(for status: RemoteDiagnosticsViewModel.ProbeStatus) -> Color {
|
||||
switch status {
|
||||
case .pass: return .green
|
||||
case .fail: return .red
|
||||
case .skipped: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Raw-output disclosure. Shown whenever anything fails — we need
|
||||
@@ -189,10 +207,15 @@ struct RemoteDiagnosticsView: View {
|
||||
lines.append("Result: \(viewModel.summary)")
|
||||
lines.append("")
|
||||
for probe in viewModel.probes {
|
||||
let mark = probe.passed ? "PASS" : "FAIL"
|
||||
let mark: String
|
||||
switch probe.status {
|
||||
case .pass: mark = "PASS"
|
||||
case .fail: mark = "FAIL"
|
||||
case .skipped: mark = "SKIP"
|
||||
}
|
||||
lines.append("[\(mark)] \(probe.id.title)")
|
||||
if !probe.detail.isEmpty { lines.append(" \(probe.detail)") }
|
||||
if !probe.passed, let hint = probe.id.failureHint {
|
||||
if probe.status == .fail, let hint = probe.id.failureHint {
|
||||
lines.append(" hint: \(hint)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ final class SessionsViewModel {
|
||||
|
||||
func selectSession(_ session: HermesSession) async {
|
||||
selectedSession = session
|
||||
messages = await dataService.fetchMessages(sessionId: session.id)
|
||||
messages = await dataService.fetchMessages(sessionId: session.id, limit: HistoryPageSize.macSessionDetail)
|
||||
subagentSessions = await dataService.fetchSubagentSessions(parentId: session.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -271,10 +271,16 @@ final class SettingsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func runRestore(from url: URL) {
|
||||
/// Restore from a backup `.zip`. The path may be local (the user picked
|
||||
/// it via `NSOpenPanel` on a local context) or remote (the user typed it
|
||||
/// in the remote-path sheet). Either way, the call goes through
|
||||
/// `fileService.runHermesCLI`, which is transport-aware — for an SSH
|
||||
/// context the `hermes import <path>` command runs on the remote shell
|
||||
/// where `<path>` is a remote filesystem path.
|
||||
func runRestore(fromPath path: String) {
|
||||
backupInProgress = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300)
|
||||
let result = fileService.runHermesCLI(args: ["import", path], timeout: 300)
|
||||
await MainActor.run {
|
||||
self.backupInProgress = false
|
||||
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
|
||||
@@ -299,17 +305,6 @@ final class SettingsViewModel {
|
||||
return String(output[r])
|
||||
}
|
||||
|
||||
func presentRestorePicker() -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.message = "Choose a Hermes backup archive to restore"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
// No-op for remote contexts — the file is on the remote host, not
|
||||
// this Mac. The Settings tab's in-app editor is the supported way
|
||||
|
||||
@@ -22,6 +22,12 @@ struct ModelPickerSheet: View {
|
||||
@State private var models: [HermesModelInfo] = []
|
||||
@State private var selectedModelID: String = ""
|
||||
@State private var searchText: String = ""
|
||||
/// True while the initial catalog load (or a per-provider model
|
||||
/// reload) is in flight. Drives the loading-overlay placeholder.
|
||||
/// Pre-fix this work ran synchronously inside `.onAppear` — issue
|
||||
/// #59. The catalog file is multi-MB on remote contexts; sync I/O
|
||||
/// on the MainActor froze the picker for 1–2 minutes.
|
||||
@State private var isLoadingCatalog: Bool = true
|
||||
|
||||
// Custom model entry — used when the catalog doesn't have the exact model
|
||||
// the user needs (e.g., provider-prefixed IDs like "openrouter/some/model").
|
||||
@@ -41,6 +47,20 @@ struct ModelPickerSheet: View {
|
||||
/// "Sign in to Nous Portal" button in the subscription summary.
|
||||
@State private var showNousSignIn: Bool = false
|
||||
|
||||
/// Cached + freshly-fetched Nous model list for the picker's
|
||||
/// nous-overlay branch. Populated on appear (cache-first) and
|
||||
/// refreshed when the user signs in or hits the Refresh button.
|
||||
@State private var nousModels: [NousModel] = []
|
||||
@State private var nousFetchedAt: Date?
|
||||
@State private var nousRefreshError: String?
|
||||
@State private var nousIsRefreshing: Bool = false
|
||||
/// When true, render the Nous detail with the original free-form
|
||||
/// TextField + manual hint instead of the model list. Used when
|
||||
/// the user explicitly wants to type a model not in the catalog —
|
||||
/// the API list is comprehensive but not infallible, so always
|
||||
/// keep the escape hatch reachable.
|
||||
@State private var nousManualEntry: Bool = false
|
||||
|
||||
/// Validation failure surfaced on Select when the typed / selected
|
||||
/// model isn't in the chosen provider's catalog. Pass-1 M7 #5
|
||||
/// cross-platform fix — previously Scarf let you save any string
|
||||
@@ -67,13 +87,33 @@ struct ModelPickerSheet: View {
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
.overlay {
|
||||
if isLoadingCatalog {
|
||||
ProgressView("Loading providers…")
|
||||
.progressViewStyle(.circular)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Off-MainActor read of the multi-megabyte models.dev cache
|
||||
// (via SSHTransport on remote contexts). Pre-fix this ran
|
||||
// sync inside `.onAppear` and froze the picker for 1–2
|
||||
// minutes on remote contexts (issue #59).
|
||||
isLoadingCatalog = true
|
||||
providers = await catalog.loadProvidersAsync()
|
||||
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
|
||||
selectedModelID = initialModel
|
||||
overlayModelID = initialModel
|
||||
subscription = subscriptionService.loadState()
|
||||
loadModelsForSelection()
|
||||
// subscriptionService.loadState() reads auth.json — tiny
|
||||
// on local but still SSH-backed on remote, so route it
|
||||
// through a detached task too. The result is a small
|
||||
// value type; safe to assign back onto MainActor.
|
||||
let svc = subscriptionService
|
||||
subscription = await Task.detached { svc.loadState() }.value
|
||||
await loadModelsForSelectionAsync()
|
||||
isLoadingCatalog = false
|
||||
}
|
||||
.sheet(isPresented: $showNousSignIn) {
|
||||
NousSignInSheet {
|
||||
@@ -81,6 +121,10 @@ struct ModelPickerSheet: View {
|
||||
// status row flips to "active" without waiting for the
|
||||
// picker to be re-opened.
|
||||
subscription = subscriptionService.loadState()
|
||||
// Sign-in unlocked the bearer token — kick a fresh
|
||||
// model-list fetch so the picker populates without the
|
||||
// user needing to hit Refresh manually.
|
||||
Task { await refreshNousModels(forceRefresh: true) }
|
||||
}
|
||||
}
|
||||
.alert(item: $validationIssue) { issue in
|
||||
@@ -134,7 +178,7 @@ struct ModelPickerSheet: View {
|
||||
get: { selectedProviderID },
|
||||
set: { newValue in
|
||||
selectedProviderID = newValue
|
||||
loadModelsForSelection()
|
||||
Task { await loadModelsForSelectionAsync() }
|
||||
}
|
||||
)) {
|
||||
ForEach(filteredProviders) { provider in
|
||||
@@ -163,8 +207,14 @@ struct ModelPickerSheet: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var modelColumn: some View {
|
||||
if let selected = providers.first(where: { $0.providerID == selectedProviderID }), selected.isOverlay {
|
||||
overlayProviderDetail(selected)
|
||||
if let selected = providers.first(where: { $0.providerID == selectedProviderID }) {
|
||||
if selected.providerID == "nous" {
|
||||
nousOverlayDetail(selected)
|
||||
} else if selected.isOverlay {
|
||||
overlayProviderDetail(selected)
|
||||
} else {
|
||||
cachedModelList
|
||||
}
|
||||
} else {
|
||||
cachedModelList
|
||||
}
|
||||
@@ -215,6 +265,147 @@ struct ModelPickerSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-column detail for Nous Portal — same overlay shape as
|
||||
/// `overlayProviderDetail` but with a live model list fetched from
|
||||
/// Nous's OpenAI-compatible `/v1/models` endpoint. The list is
|
||||
/// cache-first so opening the sheet feels instant; refresh runs
|
||||
/// in the background. Falls back to a hard-coded short list when
|
||||
/// the user has no token AND no cache (offline first-run on a
|
||||
/// fresh remote install). The "Custom…" button below the list
|
||||
/// flips to the original free-form TextField — Nous occasionally
|
||||
/// adds a model before our cache hits 24h, and we don't want
|
||||
/// users locked out of the latest releases.
|
||||
@ViewBuilder
|
||||
private func nousOverlayDetail(_ provider: HermesProviderInfo) -> some View {
|
||||
let overlay = catalog.overlayMetadata(for: provider.providerID)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(provider.providerName).font(.title3.bold())
|
||||
if provider.subscriptionGated {
|
||||
capsuleTag("Subscription", tint: .accentColor)
|
||||
}
|
||||
}
|
||||
if provider.subscriptionGated {
|
||||
subscriptionSummary(provider: provider, overlay: overlay)
|
||||
}
|
||||
Divider()
|
||||
if nousManualEntry {
|
||||
nousManualEntryBlock(provider: provider)
|
||||
} else {
|
||||
nousModelListBlock
|
||||
}
|
||||
if let docURL = overlay?.docURL, let url = URL(string: docURL) {
|
||||
Link(destination: url) {
|
||||
Label("Setup documentation", systemImage: "book")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var nousModelListBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Available models")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if nousIsRefreshing {
|
||||
HStack(spacing: 4) {
|
||||
ProgressView().controlSize(.mini)
|
||||
Text("Refreshing…").font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task { await refreshNousModels(forceRefresh: true) }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(nousFetchedAtTooltip)
|
||||
}
|
||||
Button("Custom…") { nousManualEntry = true }
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let err = nousRefreshError, !nousIsRefreshing {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
List(selection: $overlayModelID) {
|
||||
ForEach(nousModels) { model in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.id)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
if let owner = model.owned_by, !owner.isEmpty {
|
||||
Text(owner)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.tag(model.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.frame(minHeight: 220)
|
||||
.overlay {
|
||||
if nousModels.isEmpty && !nousIsRefreshing {
|
||||
ContentUnavailableView(
|
||||
"No models loaded",
|
||||
systemImage: "cpu",
|
||||
description: Text("Sign in to Nous Portal to load the catalog, or enter a model ID manually.")
|
||||
)
|
||||
}
|
||||
}
|
||||
if nousFetchedAt == nil && !nousModels.isEmpty {
|
||||
Text("Showing built-in fallback list — couldn't reach Nous to refresh.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text("Leave blank in config to let Hermes pick the default Nous model. Picking one above writes it explicitly.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func nousManualEntryBlock(provider: HermesProviderInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Model ID").font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Use list") { nousManualEntry = false }
|
||||
.controlSize(.small)
|
||||
}
|
||||
TextField(modelIDPlaceholder(for: provider), text: $overlayModelID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Text("Type a model ID exactly as Nous expects it. Leave blank to use Hermes's default.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private var nousFetchedAtTooltip: String {
|
||||
guard let date = nousFetchedAt else {
|
||||
return "Fetch the latest model list from Nous."
|
||||
}
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return "Last refreshed \(formatter.localizedString(for: date, relativeTo: Date()))"
|
||||
}
|
||||
|
||||
/// Right-column detail for overlay-only providers (Nous Portal, OpenAI
|
||||
/// Codex, Qwen OAuth, …). models.dev has no catalog for them, so the user
|
||||
/// either trusts Hermes's default (subscription providers) or types a
|
||||
@@ -424,17 +615,70 @@ struct ModelPickerSheet: View {
|
||||
return resolved.isEmpty ? "Provider will not be changed" : "Provider → \(resolved)"
|
||||
}
|
||||
|
||||
private func loadModelsForSelection() {
|
||||
/// Async variant of the per-provider catalog read. Pre-fix this
|
||||
/// was synchronous on the MainActor and froze the picker every
|
||||
/// time the user clicked a different provider — same root cause
|
||||
/// as the open-sheet freeze (issue #59). Routes through
|
||||
/// `loadModelsAsync(for:)` which dispatches the SSHTransport
|
||||
/// file read off the main thread.
|
||||
private func loadModelsForSelectionAsync() async {
|
||||
guard !selectedProviderID.isEmpty else {
|
||||
models = []
|
||||
return
|
||||
}
|
||||
models = catalog.loadModels(for: selectedProviderID)
|
||||
models = await catalog.loadModelsAsync(for: selectedProviderID)
|
||||
// If the current selection is not in the new list, don't try to keep
|
||||
// stale highlight state — clear unless the user originally had this model.
|
||||
if !models.contains(where: { $0.modelID == selectedModelID }) {
|
||||
selectedModelID = models.first?.modelID ?? ""
|
||||
}
|
||||
// Cache-first kick for the Nous catalog. Renders from cache
|
||||
// immediately, fires a background refresh if stale or empty.
|
||||
if selectedProviderID == "nous" {
|
||||
Task { await refreshNousModels(forceRefresh: false) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache-first load of the Nous model list. Updates the four
|
||||
/// `@State` vars the detail view reads. Force-refresh skips the
|
||||
/// TTL check so the user-tapped Refresh button always hits the
|
||||
/// network — the cache write keeps the next sheet-open instant.
|
||||
private func refreshNousModels(forceRefresh: Bool) async {
|
||||
let service = NousModelCatalogService(context: serverContext)
|
||||
// Render from cache immediately on the first pass so the user
|
||||
// doesn't see an empty list while the network call is in
|
||||
// flight. The async load below overwrites with fresh data
|
||||
// when it returns.
|
||||
if !forceRefresh, let cache = service.readCache(), !cache.models.isEmpty, nousModels.isEmpty {
|
||||
nousModels = cache.models
|
||||
nousFetchedAt = cache.fetchedAt
|
||||
nousRefreshError = nil
|
||||
}
|
||||
nousIsRefreshing = true
|
||||
let result = await service.loadModels(forceRefresh: forceRefresh)
|
||||
nousIsRefreshing = false
|
||||
switch result {
|
||||
case .fresh(let models, let fetchedAt):
|
||||
nousModels = models
|
||||
nousFetchedAt = fetchedAt
|
||||
nousRefreshError = nil
|
||||
case .cache(let models, let fetchedAt, let refreshError):
|
||||
nousModels = models
|
||||
nousFetchedAt = fetchedAt
|
||||
nousRefreshError = refreshError
|
||||
case .fallback(let models, let reason):
|
||||
nousModels = models
|
||||
nousFetchedAt = nil
|
||||
nousRefreshError = reason
|
||||
}
|
||||
// Pre-fill `overlayModelID` with the user's previously chosen
|
||||
// model when it's in the freshly-loaded list — otherwise the
|
||||
// selection state highlights nothing on first paint.
|
||||
if !overlayModelID.isEmpty,
|
||||
!nousModels.contains(where: { $0.id == overlayModelID }) {
|
||||
// Leave overlayModelID alone — it's a user-typed value
|
||||
// that may legitimately not be in the catalog.
|
||||
}
|
||||
}
|
||||
|
||||
/// When the user enters a custom model ID without explicitly naming a
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Advanced tab — network, compression, checkpoints, logging, delegation, file read cap,
|
||||
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
|
||||
@@ -7,7 +9,8 @@ struct AdvancedTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@State private var showRawConfig = false
|
||||
@State private var showRestoreConfirm = false
|
||||
@State private var pendingRestoreURL: URL?
|
||||
@State private var pendingRestorePath: String?
|
||||
@State private var showRemoteRestoreSheet = false
|
||||
@State private var diagnosticsOutput: String = ""
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
@@ -111,9 +114,17 @@ struct AdvancedTab: View {
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
Button {
|
||||
if let url = viewModel.presentRestorePicker() {
|
||||
pendingRestoreURL = url
|
||||
showRestoreConfirm = true
|
||||
if viewModel.context.isRemote {
|
||||
// The backup zip lives on the remote (that's where
|
||||
// `hermes backup` ran). NSOpenPanel can only browse
|
||||
// the user's Mac, so present a remote-path input
|
||||
// sheet instead.
|
||||
showRemoteRestoreSheet = true
|
||||
} else {
|
||||
if let path = pickLocalBackupZip() {
|
||||
pendingRestorePath = path
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Restore…", systemImage: "arrow.up.doc")
|
||||
@@ -131,15 +142,40 @@ struct AdvancedTab: View {
|
||||
}
|
||||
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
|
||||
Button("Restore", role: .destructive) {
|
||||
if let url = pendingRestoreURL {
|
||||
viewModel.runRestore(from: url)
|
||||
if let path = pendingRestorePath {
|
||||
viewModel.runRestore(fromPath: path)
|
||||
}
|
||||
pendingRestoreURL = nil
|
||||
pendingRestorePath = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
|
||||
Button("Cancel", role: .cancel) { pendingRestorePath = nil }
|
||||
} message: {
|
||||
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
|
||||
Text("This will overwrite files under \(viewModel.context.paths.home) with the archive contents.")
|
||||
}
|
||||
.sheet(isPresented: $showRemoteRestoreSheet) {
|
||||
RemoteBackupPathSheet(
|
||||
context: viewModel.context,
|
||||
onCancel: { showRemoteRestoreSheet = false },
|
||||
onConfirm: { path in
|
||||
showRemoteRestoreSheet = false
|
||||
pendingRestorePath = path
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// NSOpenPanel for local backup zip. Lifted from
|
||||
/// `SettingsViewModel.presentRestorePicker` — kept in the view layer
|
||||
/// because it's a UI concern that has no business on the VM.
|
||||
private func pickLocalBackupZip() -> String? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.message = "Choose a Hermes backup archive to restore"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
||||
return url.path
|
||||
}
|
||||
|
||||
private var pathsSection: some View {
|
||||
@@ -178,3 +214,115 @@ struct AdvancedTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote-backup-path picker. NSOpenPanel can only browse the user's
|
||||
/// Mac, which is the wrong host for a remote restore — `hermes backup`
|
||||
/// produced the zip on the remote, so the path the user wants is on
|
||||
/// the remote too. This sheet takes a remote path string + verifies
|
||||
/// it via `transport.fileExists` before handing it back to the
|
||||
/// caller. Future iteration: add an "Upload local zip first" path so
|
||||
/// users can restore from a backup that lives on this Mac.
|
||||
private struct RemoteBackupPathSheet: View {
|
||||
let context: ServerContext
|
||||
let onCancel: () -> Void
|
||||
let onConfirm: (String) -> Void
|
||||
|
||||
@State private var path: String = ""
|
||||
@State private var verification: Verification = .idle
|
||||
|
||||
private enum Verification: Equatable {
|
||||
case idle
|
||||
case verifying
|
||||
case ok
|
||||
case warn(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Restore from remote backup")
|
||||
.font(.headline)
|
||||
Text("Enter the path to a Hermes backup `.zip` on \(context.displayName). Hermes ran the backup there, so the file lives on the remote — Scarf can't browse the remote from a local file picker.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack {
|
||||
TextField("e.g. ~/.hermes-backups/hermes-2026-04-28.zip", text: $path)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: path) { _, _ in
|
||||
if verification != .idle { verification = .idle }
|
||||
}
|
||||
Button("Verify") { Task { await verify() } }
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| verification == .verifying)
|
||||
}
|
||||
verificationBadge
|
||||
HStack {
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Restore…") {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onConfirm(trimmed)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var verificationBadge: some View {
|
||||
switch verification {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .verifying:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking on \(context.displayName)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .ok:
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("File found on \(context.displayName).")
|
||||
.font(.caption)
|
||||
}
|
||||
case .warn(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(detail).font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verify() async {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
verification = .verifying
|
||||
let snapshot = context
|
||||
let result: Verification = await Task.detached {
|
||||
let transport = snapshot.makeTransport()
|
||||
guard transport.fileExists(trimmed) else {
|
||||
return .warn("Path doesn't exist on \(snapshot.displayName).")
|
||||
}
|
||||
guard let stat = transport.stat(trimmed) else {
|
||||
return .warn("Found, but couldn't stat — check permissions.")
|
||||
}
|
||||
if stat.isDirectory {
|
||||
return .warn("Path is a directory, not a file. Restore expects a `.zip` archive.")
|
||||
}
|
||||
if !trimmed.lowercased().hasSuffix(".zip") {
|
||||
return .warn("File found, but extension isn't `.zip`. Restore expects a Hermes backup archive.")
|
||||
}
|
||||
return .ok
|
||||
}.value
|
||||
verification = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,46 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Display tab — streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
|
||||
struct DisplayTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
/// Scarf-local chat density preferences (issues #47 / #48).
|
||||
/// Independent of the Hermes config flags rendered in the
|
||||
/// "Output" section below — those control what Hermes EMITS,
|
||||
/// these control how Scarf RENDERS what was emitted.
|
||||
@AppStorage(ChatDensityKeys.toolCardStyle)
|
||||
private var toolCardStyle: String = ToolCardStyle.full.rawValue
|
||||
@AppStorage(ChatDensityKeys.reasoningStyle)
|
||||
private var reasoningStyle: String = ReasoningStyle.disclosure.rawValue
|
||||
@AppStorage(ChatDensityKeys.fontScale)
|
||||
private var fontScale: Double = ChatFontScale.default
|
||||
/// Side-pane visibility (issue #58). Mirrors the toolbar buttons in
|
||||
/// ChatView; this is the canonical preferences home.
|
||||
@AppStorage(ChatDensityKeys.showSessionsList)
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") {
|
||||
DensityPickerRow(
|
||||
label: "Tool calls",
|
||||
selection: $toolCardStyle,
|
||||
options: ToolCardStyle.allCases.map { ($0.rawValue, $0.displayName) }
|
||||
)
|
||||
DensityPickerRow(
|
||||
label: "Reasoning",
|
||||
selection: $reasoningStyle,
|
||||
options: ReasoningStyle.allCases.map { ($0.rawValue, $0.displayName) }
|
||||
)
|
||||
FontScaleRow(scale: $fontScale)
|
||||
ToggleRow(label: "Sessions list", isOn: showSessionsList) { showSessionsList = $0 }
|
||||
ToggleRow(label: "Tool inspector", isOn: showInspector) { showInspector = $0 }
|
||||
DensityFootnote()
|
||||
}
|
||||
|
||||
SettingsSection(title: "Output", icon: "doc.plaintext") {
|
||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||
@@ -32,3 +67,82 @@ struct DisplayTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Density-section primitives
|
||||
|
||||
/// Segmented picker over (rawValue, displayName) tuples — keeps the
|
||||
/// existing `PickerRow` simple-string contract while still letting us
|
||||
/// render distinct user-facing labels for each density enum case.
|
||||
/// Cannot reuse the generic `PickerRow` in `SettingsComponents.swift`:
|
||||
/// that one is `.menu` style and doesn't accept a separate display
|
||||
/// name per option.
|
||||
private struct DensityPickerRow: View {
|
||||
let label: String
|
||||
@Binding var selection: String
|
||||
let options: [(rawValue: String, displayName: String)]
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Picker("", selection: $selection) {
|
||||
ForEach(options, id: \.rawValue) { option in
|
||||
Text(option.displayName).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 320)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.vertical, 6)
|
||||
.background(ScarfColor.backgroundTertiary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
private struct FontScaleRow: View {
|
||||
@Binding var scale: Double
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("Chat font size")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Slider(
|
||||
value: $scale,
|
||||
in: ChatFontScale.min...ChatFontScale.max,
|
||||
step: ChatFontScale.step
|
||||
)
|
||||
.frame(maxWidth: 240)
|
||||
Text(ChatFontScale.percentLabel(for: scale))
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(width: 48, alignment: .leading)
|
||||
Button("Reset") {
|
||||
scale = ChatFontScale.default
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.small)
|
||||
.disabled(abs(scale - ChatFontScale.default) < 0.001)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.vertical, 6)
|
||||
.background(ScarfColor.backgroundTertiary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
private struct DensityFootnote: View {
|
||||
var body: some View {
|
||||
Text("Controls how Scarf renders the chat. Use Output → Show Reasoning to control what Hermes sends.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,28 +65,36 @@ struct TemplateInstallSheet: View {
|
||||
}
|
||||
|
||||
private var pickParentView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let manifest = viewModel.inspection?.manifest {
|
||||
ParentDirectoryStep(
|
||||
context: viewModel.context,
|
||||
templateID: viewModel.inspection?.manifest.id,
|
||||
header: parentStepHeader(),
|
||||
onCancel: {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
},
|
||||
onContinue: { parentDir in
|
||||
viewModel.pickParentDirectory(parentDir)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the manifest banner that sits above the parent-directory
|
||||
/// picker. Returned as `AnyView` so `ParentDirectoryStep` can stay
|
||||
/// non-generic and `pickParentView` doesn't have to bubble its
|
||||
/// generics back up the stack. Empty when inspection is still in
|
||||
/// flight.
|
||||
private func parentStepHeader() -> AnyView {
|
||||
guard let manifest = viewModel.inspection?.manifest else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
manifestHeader(manifest)
|
||||
Divider()
|
||||
.padding(.top, 8)
|
||||
}
|
||||
Text("Where should this project live?")
|
||||
.scarfStyle(.headline)
|
||||
Text("Scarf will create a new folder inside the directory you pick, named after the template id.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Choose Folder…") { chooseParentDirectory() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Configure step for schemaful templates. Inlines
|
||||
@@ -417,17 +425,191 @@ struct TemplateInstallSheet: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
}
|
||||
|
||||
private func chooseParentDirectory() {
|
||||
/// Parent-directory picker step. Uses the active `ServerContext` so a
|
||||
/// remote install never opens an `NSOpenPanel` against the local Mac
|
||||
/// filesystem — the panel's choices are useless when the project lives
|
||||
/// on the remote host. Mirrors the `AddProjectSheet` pattern in
|
||||
/// `ProjectsView`: text input + Verify (remote) or Browse… (local), an
|
||||
/// idle/verifying/ok/warn badge for remote feedback, and a Continue
|
||||
/// button that hands the chosen path back via `onContinue`.
|
||||
///
|
||||
/// **Bootstrap.** The path is allowed to not yet exist — the installer
|
||||
/// runs `transport.createDirectory(_:)` on the parent dir at install
|
||||
/// time (`mkdir -p` / `withIntermediateDirectories: true`). The Verify
|
||||
/// badge surfaces "doesn't exist" as a warn rather than blocking
|
||||
/// Continue, so a fresh remote host with no `~/projects` still
|
||||
/// completes the install.
|
||||
private struct ParentDirectoryStep: View {
|
||||
let context: ServerContext
|
||||
let templateID: String?
|
||||
let header: AnyView
|
||||
let onCancel: () -> Void
|
||||
let onContinue: (String) -> Void
|
||||
|
||||
@State private var parentPath: String
|
||||
@State private var remoteVerification: RemoteVerification = .idle
|
||||
|
||||
init(
|
||||
context: ServerContext,
|
||||
templateID: String?,
|
||||
header: AnyView,
|
||||
onCancel: @escaping () -> Void,
|
||||
onContinue: @escaping (String) -> Void
|
||||
) {
|
||||
self.context = context
|
||||
self.templateID = templateID
|
||||
self.header = header
|
||||
self.onCancel = onCancel
|
||||
self.onContinue = onContinue
|
||||
self._parentPath = State(initialValue: context.defaultProjectsRoot)
|
||||
}
|
||||
|
||||
private enum RemoteVerification: Equatable {
|
||||
case idle
|
||||
case verifying
|
||||
case ok(String)
|
||||
case warn(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
Text("Where should this project live?")
|
||||
.scarfStyle(.headline)
|
||||
Text(installPreviewCaption)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
pathInputRow
|
||||
if context.isRemote {
|
||||
Text("Path on \(context.displayName) — Scarf creates it on first install if missing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
verificationBadge
|
||||
}
|
||||
Spacer()
|
||||
footer
|
||||
}
|
||||
}
|
||||
|
||||
private var installPreviewCaption: String {
|
||||
let trimmedPath = parentPath.trimmingCharacters(in: .whitespaces)
|
||||
let parentDisplay = trimmedPath.isEmpty ? "<parent>" : trimmedPath
|
||||
let slug = templateID ?? "<template-id>"
|
||||
return "Project will be installed at \(parentDisplay)/\(slug) on \(context.displayName)."
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pathInputRow: some View {
|
||||
HStack {
|
||||
TextField("Parent directory", text: $parentPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: parentPath) { _, _ in
|
||||
if remoteVerification != .idle {
|
||||
remoteVerification = .idle
|
||||
}
|
||||
}
|
||||
if context.isRemote {
|
||||
Button("Verify") { Task { await verifyRemotePath() } }
|
||||
.disabled(parentPath.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| remoteVerification == .verifying)
|
||||
} else {
|
||||
Button("Browse…") { browseLocalDirectory() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var verificationBadge: some View {
|
||||
switch remoteVerification {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .verifying:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking on \(context.displayName)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .ok(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
case .warn(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Continue") {
|
||||
let trimmed = parentPath.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onContinue(trimmed)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(parentPath.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func browseLocalDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = String(localized: "Choose Parent Folder")
|
||||
let trimmed = parentPath.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty {
|
||||
let expanded = (trimmed as NSString).expandingTildeInPath
|
||||
if FileManager.default.fileExists(atPath: expanded) {
|
||||
panel.directoryURL = URL(fileURLWithPath: expanded)
|
||||
}
|
||||
}
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.pickParentDirectory(url.path)
|
||||
parentPath = url.path
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the entered path on the remote via the SSH transport's
|
||||
/// `stat`. Mirrors `AddProjectSheet.verifyRemotePath`. A missing
|
||||
/// directory is reported as a *warn*, not an error — Continue is
|
||||
/// still enabled because the installer's `mkdir -p` creates the
|
||||
/// parent on first install.
|
||||
private func verifyRemotePath() async {
|
||||
let path = parentPath.trimmingCharacters(in: .whitespaces)
|
||||
guard !path.isEmpty, context.isRemote else { return }
|
||||
remoteVerification = .verifying
|
||||
let snapshot = context
|
||||
let result: RemoteVerification = await Task.detached {
|
||||
let transport = snapshot.makeTransport()
|
||||
guard transport.fileExists(path) else {
|
||||
return .warn("Path doesn't exist on \(snapshot.displayName) — Scarf will create it on install.")
|
||||
}
|
||||
guard let stat = transport.stat(path) else {
|
||||
return .warn("Found, but couldn't stat — check parent directory permissions.")
|
||||
}
|
||||
if stat.isDirectory {
|
||||
return .ok("Directory exists on \(snapshot.displayName).")
|
||||
} else {
|
||||
return .warn("Path is a file, not a directory. Project paths must be directories.")
|
||||
}
|
||||
}.value
|
||||
remoteVerification = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2841,6 +2841,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.json present" : {
|
||||
|
||||
},
|
||||
"authed · %@" : {
|
||||
"comment" : "A label that shows when a provider's access token is still valid. The argument is a relative date string.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Authentication" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3097,6 +3104,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Available models" : {
|
||||
"comment" : "A label for the list of available models.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Back" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3543,6 +3554,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Browse…" : {
|
||||
"comment" : "A button that opens a file browser to select a directory.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Browser" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3917,6 +3932,14 @@
|
||||
"comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat density" : {
|
||||
"comment" : "Title of a settings section that lets the user configure the chat density (tool calls, reasoning, font size).",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat font size" : {
|
||||
"comment" : "A label displayed above a slider that adjusts the font size of chat messages.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat is scoped to Scarf project \"%@\"" : {
|
||||
"comment" : "Tooltip for the folder-chip indicator.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -4129,6 +4152,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Checking on %@…" : {
|
||||
"comment" : "A label indicating that a project is being verified. The argument is the name of the project being verified.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Checking…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -4452,10 +4479,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Choose Folder…" : {
|
||||
"comment" : "A button that opens a dialog for choosing a folder.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Choose Parent Folder" : {
|
||||
|
||||
},
|
||||
@@ -4670,6 +4693,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Click to inspect this tool call" : {
|
||||
"comment" : "A tooltip for a tool call button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Click to inspect tool calls" : {
|
||||
"comment" : "A tooltip for the button that opens a list of tool calls.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5367,7 +5398,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Connected — %@" : {
|
||||
"comment" : "A connected state with a reason for",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Connected — can't read Hermes state" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -5574,6 +5610,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Controls how Scarf renders the chat. Use Output → Show Reasoning to control what Hermes sends." : {
|
||||
"comment" : "A footnote that describes how the chat is rendered.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Copied" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5614,6 +5654,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard." : {
|
||||
"comment" : "A tooltip for the \"Copy fix command\" button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Copy" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5777,6 +5821,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Copy fix command" : {
|
||||
"comment" : "A button that copies a one-liner that consolidates a project's auth.json into your global ~/.hermes/.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Copy Full Report" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -6726,6 +6774,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Default: ~/projects" : {
|
||||
"comment" : "A description of the default location of the user's projects directory.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Defaults to ~/.ssh/config or current user" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -7444,6 +7496,10 @@
|
||||
},
|
||||
"Duplicate" : {
|
||||
|
||||
},
|
||||
"e.g. ~/.hermes-backups/hermes-2026-04-28.zip" : {
|
||||
"comment" : "A placeholder for a remote backup path.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"e.g. anthropic" : {
|
||||
"localizations" : {
|
||||
@@ -8225,6 +8281,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter the path to a Hermes backup `.zip` on %@. Hermes ran the backup there, so the file lives on the remote — Scarf can't browse the remote from a local file picker." : {
|
||||
"comment" : "A label at the top of the remote backup path picker.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Entity Filters (config.yaml only)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -8992,6 +9052,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"File found on %@." : {
|
||||
"comment" : "A label indicating that a file was found at the path provided by the user.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Files" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11251,6 +11315,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Leave blank in config to let Hermes pick the default Nous model. Picking one above writes it explicitly." : {
|
||||
"comment" : "A description of the default model selection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai)." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11499,6 +11567,10 @@
|
||||
"comment" : "A description of the logs feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Load earlier messages" : {
|
||||
"comment" : "A button to load older messages.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loaded" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11547,6 +11619,10 @@
|
||||
"comment" : "A message displayed while loading the configuration.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loading earlier…" : {
|
||||
"comment" : "A label displayed while loading older messages.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loading session…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11952,6 +12028,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Managed by `hermes auth add <provider>` — Scarf is read-only here." : {
|
||||
"comment" : "A footer describing how OAuth providers are managed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Mark as seen" : {
|
||||
"comment" : "A button that marks the current skill set as seen and dismisses the \"What's New\" pill.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -13612,6 +13692,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No models loaded" : {
|
||||
"comment" : "A message that appears when the user is not logged in to Nous Portal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No output yet." : {
|
||||
"comment" : "A message displayed when a tool call has not yet produced output.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -14265,6 +14349,10 @@
|
||||
},
|
||||
"npx" : {
|
||||
|
||||
},
|
||||
"oauth" : {
|
||||
"comment" : "A label for OAuth tokens.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"OAuth" : {
|
||||
|
||||
@@ -14312,6 +14400,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OAuth providers" : {
|
||||
"comment" : "Title of a section in the credential pools view that lists OAuth-authed providers.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"OK" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -14936,6 +15028,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Or run this on the remote to switch back to the default profile:" : {
|
||||
"comment" : "A hint to the user on how to switch back to the default",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Other" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -15149,6 +15245,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Parent directory" : {
|
||||
|
||||
},
|
||||
"Paste an https URL pointing at a .scarftemplate file." : {
|
||||
"comment" : "A description of the URL field in the template installation prompt.",
|
||||
@@ -15194,6 +15293,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Path on %@ — must already exist on the server. Tool calls run with this directory as their working directory." : {
|
||||
"comment" : "A label that describes the path of a project on a remote server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Path on %@ — Scarf creates it on first install if missing." : {
|
||||
"comment" : "A label that describes a warning about a project's path on a remote host. The argument is the name of the host.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Paths" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -15478,6 +15585,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pick a model to start chatting" : {
|
||||
"comment" : "A heading for the chat model picker sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pick an MCP server to add." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16070,6 +16181,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Project-local Hermes home shadowing global setup" : {
|
||||
|
||||
},
|
||||
"Project's current git branch" : {
|
||||
"comment" : "A tooltip for the git branch of the project.",
|
||||
@@ -16914,6 +17028,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"refresh-only" : {
|
||||
"comment" : "A label for a refresh-only OAuth provider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Refreshing…" : {
|
||||
"comment" : "A message that appears when the app is refreshing",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Reload" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -17942,6 +18064,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Restore from remote backup" : {
|
||||
"comment" : "A heading for a sheet that lets the user restore from a remote backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -18357,6 +18483,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Run diagnostics" : {
|
||||
"comment" : "A button that runs diagnostics.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Run Diagnostics…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -18739,6 +18869,10 @@
|
||||
"comment" : "A description of the warning about not switching models.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Scarf is reading from Hermes profile \"%@\". Switch profiles with `hermes profile use <name>` and relaunch Scarf." : {
|
||||
"comment" : "A warning that Scarf is reading from a Hermes profile that is not the default.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -18863,10 +18997,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scarf will create a new folder inside the directory you pick, named after the template id." : {
|
||||
"comment" : "A description of how a template will be installed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"scarf-default" : {
|
||||
"comment" : "A tool gateway policy applied at run time.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -20456,6 +20586,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Showing built-in fallback list — couldn't reach Nous to refresh." : {
|
||||
"comment" : "A message that appears when the user has selected a",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Shown as the subtitle in the chat slash menu." : {
|
||||
"comment" : "A description of a field that describes a slash command.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -20470,6 +20604,10 @@
|
||||
},
|
||||
"Sign in to Nous Portal" : {
|
||||
|
||||
},
|
||||
"Sign in to Nous Portal to load the catalog, or enter a model ID manually." : {
|
||||
"comment" : "A description of the error message shown when the",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sign in to Spotify" : {
|
||||
"comment" : "A label for a Spotify sign-in button.",
|
||||
@@ -20955,7 +21093,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SSH works but %@. Click for details." : {
|
||||
"comment" : "A tooltip for a degraded connection status pill. The argument is a reason for the degraded connection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"SSH works but %@. Click for diagnostics." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -21358,6 +21501,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"state.db present" : {
|
||||
"comment" : "A label indicating that a project has a state.db file.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"state.db readable" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22366,6 +22513,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"These projects carry their own `.hermes/` directory. Hermes' CLI uses the closest one as `$HERMES_HOME` when run from inside the project, so credentials and config written there don't show up in your global Hermes setup. Consolidate to clear this warning." : {
|
||||
"comment" : "A description of the warning that appears when a project's Hermes home shadows the user's global Hermes setup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This is the prompt Hermes will receive. The user sees the literal `/%@` they typed in their own bubble; the expanded body goes to the agent with a `<!-- scarf-slash:<name> -->` marker." : {
|
||||
"comment" : "A description of what the preview pane shows.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -22628,7 +22779,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"This will overwrite files under %@ with the archive contents." : {
|
||||
"comment" : "A message in the confirmation dialog for restoring from a backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This will overwrite files under ~/.hermes/ with the archive contents." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -23342,6 +23498,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Type a model ID exactly as Nous expects it. Leave blank to use Hermes's default." : {
|
||||
"comment" : "A description of how to enter a model ID for a",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Unarchive" : {
|
||||
"comment" : "A button that unarchives a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -23853,6 +24013,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Use list" : {
|
||||
"comment" : "A button that lets users cancel entering a custom model ID.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Use this" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -23942,6 +24106,10 @@
|
||||
},
|
||||
"value" : {
|
||||
|
||||
},
|
||||
"Verify" : {
|
||||
"comment" : "A button that verifies a project path on a remote server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Verifying token…" : {
|
||||
"comment" : "A label displayed in the Spotify sign-in sheet",
|
||||
@@ -24712,6 +24880,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Where Scarf installs new project templates on this host. Created on first install if missing." : {
|
||||
"comment" : "A description of the location of the projects directory.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Where should this project live?" : {
|
||||
|
||||
},
|
||||
|
||||
@@ -188,6 +188,21 @@ build_variant() {
|
||||
|
||||
log "[$label] Package $(basename "$out_zip")"
|
||||
ditto -c -k --keepParent "$app_path" "$out_zip"
|
||||
|
||||
# Post-package verification: extract the actual distribution zip and confirm
|
||||
# codesign + Gatekeeper still accept it. Catches any regression introduced by
|
||||
# ditto / staple / future pipeline tweaks before users see "damaged" errors.
|
||||
# See issue #49 — without this, a broken seal in Sparkle.framework or the
|
||||
# outer bundle would only surface in user reports.
|
||||
log "[$label] Post-package signature + Gatekeeper verification"
|
||||
local verify_dir
|
||||
verify_dir="$(mktemp -d)"
|
||||
ditto -xk "$out_zip" "$verify_dir"
|
||||
codesign --verify --strict --deep --verbose=4 "$verify_dir/Scarf.app" \
|
||||
|| die "[$label] codesign --verify failed on packaged zip"
|
||||
spctl --assess --type execute --verbose "$verify_dir/Scarf.app" \
|
||||
|| die "[$label] spctl --assess failed on packaged zip"
|
||||
rm -rf "$verify_dir"
|
||||
}
|
||||
|
||||
UNIVERSAL_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-Universal.zip"
|
||||
|
||||
Reference in New Issue
Block a user