Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50fbbc6af6 | |||
| 4776119e07 | |||
| f72bf6e30b | |||
| 0bfae1227a | |||
| c312a565b6 | |||
| afb1356b27 | |||
| f9a288ac6c | |||
| bb33a39b42 | |||
| e828538a2d | |||
| 051f3bf80c | |||
| 558970a09a | |||
| 8d9de4c576 | |||
| e0f0fad192 | |||
| 80a4d23974 | |||
| d95ef61e13 | |||
| 988ce5df5a | |||
| 3bca8a6e55 | |||
| b5f4f65ffe | |||
| b474286bfe | |||
| b1e2fc5dcd | |||
| 87fcbad1ac |
@@ -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>
|
||||
@@ -21,27 +21,57 @@
|
||||
|
||||
## What's New in 2.5
|
||||
|
||||
- **ScarfGo, the iPhone companion, ships in public TestFlight.** Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session. **TestFlight invite + onboarding walkthrough:** [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo).
|
||||
- **Portable project-scoped slash commands.** Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (with optional `default:` fallback) in the body and sends the expanded prompt to Hermes. Mac authoring tab + iOS read-only browser. Templates carry them via the new `slash-commands/` block in `.scarftemplate` bundles (schemaVersion 3).
|
||||
### ScarfGo — the iPhone companion ships in public TestFlight
|
||||
|
||||
Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
|
||||
|
||||
**[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
|
||||
|
||||
- **Portable project-scoped slash commands.** Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (with optional `default:` fallback) in the body and sends the expanded prompt to Hermes. Mac authoring tab + iOS read-only browser. Templates carry them via the new `slash-commands/` block in `.scarftemplate` bundles (schemaVersion 3). See [Slash Commands](https://github.com/awizemann/scarf/wiki/Slash-Commands) for the full schema.
|
||||
- **Hermes v2026.4.23 chat parity.** `/steer` non-interruptive guidance command, per-turn stopwatch on assistant bubbles, numbered keyboard shortcuts (1–9) on the permission sheet, git branch chip in the chat header. The new `messages.reasoning_content` and `sessions.api_call_count` columns surface as a richer reasoning disclosure + an "API" chip on session rows.
|
||||
- **Spotify + design-md skills.** Mac ships an in-app Spotify OAuth sheet (mirrors the v2.3 Nous Portal pattern); design-md gets a host-side `npx` prereq check on both platforms. SKILL.md frontmatter (`allowed_tools`, `related_skills`, `dependencies`) renders as chip rows. A "What's New" pill on the Skills tab tells you when remote skills changed since you last looked.
|
||||
- **Mac global Sessions: project filter + project badges** — parity with ScarfGo's Sessions tab. The list grows a filter Menu (All projects / Unattributed / each registered project) and each row carries a tinted folder chip with the project name when attributed.
|
||||
- **Human-readable cron schedules everywhere.** New `CronScheduleFormatter` in ScarfCore translates the common cron shapes into English phrases and falls back to the raw expression on anything custom. Mac and iOS render the same.
|
||||
- **Mac design-system overhaul.** Rust palette, typed token bundle (`ScarfColor`, `ScarfFont`, `ScarfSpace`, `ScarfRadius`), reusable components (`ScarfPageHeader`, `ScarfCard`, `ScarfBadge`, `ScarfTextField`, four button styles), redesigned 3-pane chat. iOS adopts the same tokens with a hybrid Dynamic Type policy so accessibility scaling on body text is preserved. See [Design System](https://github.com/awizemann/scarf/wiki/Design-System) for the full reference.
|
||||
- **Under the hood** — `SessionAttributionService`, `ProjectContextBlock`, `CronScheduleFormatter`, `GitBranchService`, `SkillPrereqService`, `SkillSnapshotService`, `ProjectSlashCommandService`, and the ACP error triplet (`acpError` / `acpErrorHint` / `acpErrorDetails`) consolidated into ScarfCore so Mac and iOS consume one source of truth. 179 tests across 13 suites, three consecutive green runs. Several `try?` swallows in iOS lifecycle code now surface real failures (Keychain unlock errors no longer drop people into onboarding; partial Forget operations report what failed).
|
||||
- **iOS push notifications skeleton** — `NotificationRouter` ships with foreground presentation + a lock-screen "Approve / Deny" action category gated by `apnsEnabled = false`. Lights up when Hermes ships a server-side push sender + an APNs cert.
|
||||
|
||||
See the full [v2.5.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.5.0), the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo), and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
|
||||
See the full [v2.5.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.5.0).
|
||||
|
||||
### Previously, in 2.3
|
||||
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.3, v2.2, v2.0, v1.6, and earlier.
|
||||
|
||||
- **Projects sidebar grows up** — group projects into folders, rename / archive / unarchive in place, filter the list with ⌘F, jump to the first nine with ⌘1–⌘9. Archived projects hide by default; a toggle in the bottom bar surfaces them. Non-destructive on the v2.2 registry file — downgrade stays clean.
|
||||
- **Per-project Sessions tab** — alongside Dashboard and Site. Shows chats attributed to the project, with a **New Chat** button that spawns `hermes acp` with the project's directory as the session cwd and attributes the result via a Scarf-owned sidecar (`~/.hermes/scarf/session_project_map.json`). Click any listed session to resume it with project context automatically restored.
|
||||
- **Agent actually knows what project it's in** — the architectural headline. Every project-scoped chat gets a Scarf-managed block auto-injected into the project's `AGENTS.md` before the session starts. Hermes reads AGENTS.md from the session's cwd at startup and picks up the block as part of its system prompt. Ask the agent *"what project am I in?"* and it answers with the project name, directory, template id + version, configuration field names, and registered cron jobs — pulled from the injected block. Secret-safe (field names only, never values), idempotent, bounded to `<!-- scarf-project:begin/end -->` markers so template-author content outside the block is preserved across refreshes.
|
||||
- **Project indicator in Chat** — folder chip in `SessionInfoBar` and `Chat · <ProjectName>` in the nav title when you're in a project-scoped chat. Resumed sessions keep the indicator by looking up the attribution sidecar at resume time.
|
||||
- **Tool Gateway — Nous Portal support** — Hermes v0.10.0 introduced subscription-routed tools (web search, image gen, TTS, browser automation). Scarf 2.3 merges Hermes's provider-overlay table into the model picker so **Nous Portal + 5 other previously-invisible providers** now appear, and ships a dedicated **Sign in to Nous Portal** sheet that runs the device-code flow end-to-end in-app — no terminal. Each of the 8 auxiliary sub-model tasks gets a per-task Nous toggle, a Tool Gateway card lands in Health, and Credential Pools' silent-fail dead-end for device-code providers is closed. Scarf's existing messaging-gateway section is renamed **Messaging Gateway** to disambiguate from the new Tool Gateway.
|
||||
- **Window-layout cleanup** — switching to Chat or a Sessions tab no longer grows the window past the screen. `.windowResizability(.contentMinSize)` + targeted `idealHeight` caps keep the window's floor at a sensible content minimum while letting users freely drag larger or smaller.
|
||||
## Connect ScarfGo to your Hermes server
|
||||
|
||||
See the full [v2.3.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.3.0). Earlier release summaries (1.6 / 2.0 / 2.1 / 2.2) live at [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki.
|
||||
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
|
||||
|
||||
1. **Install via TestFlight.** Open the [public TestFlight link](https://testflight.apple.com/join/qCrRpcTz) on your phone, accept the invite, install ScarfGo from TestFlight (just like any other beta).
|
||||
2. **Tap Add Server.** Enter the host (IP or DNS), SSH user, port (default 22), and an optional nickname. Same details you'd type into `ssh user@host`.
|
||||
3. **Generate Key.** ScarfGo creates a fresh Ed25519 keypair on the device. The private half lives in the iOS Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and is excluded from iCloud sync — it never leaves the phone.
|
||||
4. **Add the public key to your Hermes host.** Tap **Copy public key**, then on the host run:
|
||||
```bash
|
||||
cat >> ~/.ssh/authorized_keys <<'EOF'
|
||||
<paste the line ScarfGo showed you>
|
||||
EOF
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
This is its own line per device — the convention any second SSH client uses. Mac (Scarf) keeps using your existing ssh-agent / `~/.ssh/config` and is unaffected.
|
||||
5. **Tap Test connection.** ScarfGo opens an SSH session, probes for the `hermes` binary, and saves the server on success. If it can't find `hermes`, see the [troubleshooting section](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding#troubleshooting) — it's almost always a `PATH` quirk on non-interactive SSH.
|
||||
|
||||
Done. Open the Dashboard tab and tap any session to resume it; tap the **+** in Chat to start a new project-scoped session.
|
||||
|
||||
## Multi-server, one window per server
|
||||
|
||||
@@ -149,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
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 770 KiB |
@@ -0,0 +1,218 @@
|
||||
# ScarfGo — App Store Connect submission copy
|
||||
|
||||
Single source of truth for every field you paste into App Store Connect → My Apps → ScarfGo. TestFlight-specific fields (Beta App Description, "What to test") live in [TESTFLIGHT_CHECKLIST.md](TESTFLIGHT_CHECKLIST.md). This file covers the full App Store listing for when ScarfGo graduates from TestFlight to the public store.
|
||||
|
||||
All character counts are pre-counted against Apple's published limits. Counts include trailing punctuation but exclude the leading `> ` Markdown blockquote markers.
|
||||
|
||||
## App information (set once, persists across builds)
|
||||
|
||||
### App name (max 30 chars)
|
||||
|
||||
```
|
||||
ScarfGo
|
||||
```
|
||||
_7 / 30 chars._
|
||||
|
||||
### Subtitle (max 30 chars)
|
||||
|
||||
```
|
||||
On-the-go Hermes companion
|
||||
```
|
||||
_26 / 30 chars._
|
||||
|
||||
### Bundle ID
|
||||
|
||||
```
|
||||
com.scarfgo.app
|
||||
```
|
||||
|
||||
### Primary category
|
||||
|
||||
Developer Tools
|
||||
|
||||
### Secondary category (optional)
|
||||
|
||||
Productivity
|
||||
|
||||
### Age rating
|
||||
|
||||
4+ (no restricted content)
|
||||
|
||||
### Support URL
|
||||
|
||||
```
|
||||
https://github.com/awizemann/scarf/wiki/Support
|
||||
```
|
||||
|
||||
### Marketing URL (optional)
|
||||
|
||||
```
|
||||
https://github.com/awizemann/scarf
|
||||
```
|
||||
|
||||
### Privacy Policy URL
|
||||
|
||||
```
|
||||
https://awizemann.github.io/scarf/privacy/
|
||||
```
|
||||
|
||||
### Copyright
|
||||
|
||||
```
|
||||
© 2026 Alan Wizemann
|
||||
```
|
||||
|
||||
### Trade representative information
|
||||
|
||||
Not required for sole-developer accounts.
|
||||
|
||||
---
|
||||
|
||||
## Per-version metadata (resubmit on each App Store release)
|
||||
|
||||
### Promotional text (max 170 chars, editable without resubmission)
|
||||
|
||||
```
|
||||
Manage your Hermes AI agent from your phone. Connect to any SSH-reachable Hermes host, run sessions, edit memory, browse cron jobs, resume conversations.
|
||||
```
|
||||
_153 / 170 chars._
|
||||
|
||||
### Description (max 4000 chars)
|
||||
|
||||
```
|
||||
ScarfGo is the iPhone companion to Scarf, the open-source macOS GUI for the Hermes AI agent. It connects from your phone to a Hermes server you operate — your Mac, a home Linux box, a cloud VM, anything reachable over SSH — and lets you run sessions, browse memory, manage cron jobs, and resume conversations on the go.
|
||||
|
||||
A fully native iOS app, not a web view or a remote desktop. ScarfGo speaks SSH directly using a pure-Swift implementation, reads Hermes state via SFTP and SQLite snapshots, and streams real-time agent output over the Agent Client Protocol on a long-lived SSH exec channel. Every byte stays between your device and the Hermes host you configured.
|
||||
|
||||
What you can do:
|
||||
|
||||
• Multi-server. Configure as many Hermes hosts as you like and switch between them with a tap. Soft Disconnect keeps your credentials cached; Forget wipes a server end-to-end.
|
||||
|
||||
• Dashboard. Stats and the 25 most recent sessions, with project badges so you can tell at a glance which work is which.
|
||||
|
||||
• Project-scoped chat. Pick a project from your registry and ScarfGo writes the same Scarf-managed AGENTS.md context block the Mac app does, so the agent boots with the right project context. The resulting session is attributed correctly across both clients.
|
||||
|
||||
• Session resume. Tap any row on the Dashboard to open that session's transcript in Chat. CLI-started sessions hydrate from the Hermes state database; ACP sessions show an empty-state because Hermes does not persist ACP transcripts to the database.
|
||||
|
||||
• Memory editor. Read and edit MEMORY.md and USER.md with a Saved indicator that survives keyboard dismissal and a one-tap Revert.
|
||||
|
||||
• Cron list. Human-readable schedules ("Every 6 hours", "Weekdays at 09:00") instead of raw cron expressions, plus a relative next-run estimate. Read-only in this release; editing comes in a future update.
|
||||
|
||||
• Skills browser. Read-only category tree with the SKILL.md frontmatter chips (allowed tools, related skills, dependencies) the Mac app shows.
|
||||
|
||||
• Settings viewer. Read-only inspection of your config.yaml. Edit values from the Mac app or a remote shell.
|
||||
|
||||
Privacy. ScarfGo does not collect, transmit, or store your data on any server controlled by the developer. There are no analytics, no telemetry, no ad identifiers. SSH keys are generated on-device and stored in the iOS Keychain with the ThisDeviceOnly attribute, so they are unreachable while the device is locked and never sync to iCloud. The complete privacy policy lives at awizemann.github.io/scarf/privacy.
|
||||
|
||||
Open-source under the MIT license. Source, issue tracker, and contributor docs at github.com/awizemann/scarf. Bug reports tagged component:scarfgo go straight to the developer.
|
||||
|
||||
Requirements. iOS 18.0 or later. An SSH-reachable Hermes server (Hermes v0.10.0 or later recommended; full v0.11.0 features supported). Your phone needs to reach that server on the network — same Wi-Fi, VPN, Tailscale, or any port-forwarded address SSH can dial.
|
||||
```
|
||||
_2873 / 4000 chars._
|
||||
|
||||
### Keywords (max 100 chars, comma-separated, no spaces between terms)
|
||||
|
||||
```
|
||||
hermes,ai agent,ssh,terminal,llm,assistant,developer tools,coding,remote,monitor,chat
|
||||
```
|
||||
_85 / 100 chars._
|
||||
|
||||
Brand-safe — no competitor product names. Apple flags trademarks like "Claude" or "OpenAI" as unauthorized brand use during review even when they appear as descriptive context.
|
||||
|
||||
### What's New text (max 4000 chars)
|
||||
|
||||
For v2.5.0 — first public App Store release. Trimmed from `RELEASE_NOTES.md`'s ScarfGo section to fit the iOS audience.
|
||||
|
||||
```
|
||||
First public release of ScarfGo, the iPhone companion to the Scarf macOS app.
|
||||
|
||||
What's in this release:
|
||||
|
||||
• Multi-server. Configure multiple Hermes hosts and switch between them with a tap.
|
||||
|
||||
• Dashboard. Sessions, messages, and tool-call counts, plus the 25 most recent sessions with project badges and a project filter.
|
||||
|
||||
• Chat. Streamed agent responses over SSH with tool-call disclosure groups, code blocks, and project-scoped session start.
|
||||
|
||||
• Session resume. Tap any session on the Dashboard to open it in Chat.
|
||||
|
||||
• Memory editor. Read and edit MEMORY.md and USER.md with on-device save indication and one-tap Revert.
|
||||
|
||||
• Cron list. Human-readable schedules ("Every 6 hours", "Weekdays at 09:00") with relative next-run.
|
||||
|
||||
• Skills browser. Read-only category tree with SKILL.md frontmatter chips.
|
||||
|
||||
• Settings viewer. Read-only inspection of config.yaml. Edit values from the Mac app.
|
||||
|
||||
Known limitations in v1: no push notifications (the skeleton is in the binary, gated behind an internal flag pending Apple Developer Program enrollment and an APNs key); no in-app config editor; no template install UI; English only. iPad layout works via the system sidebar adaptive style but has not been polished — feedback welcome via TestFlight.
|
||||
|
||||
Privacy. No analytics, no telemetry, no developer-controlled servers. Read the full policy at awizemann.github.io/scarf/privacy.
|
||||
```
|
||||
_1150 / 4000 chars._
|
||||
|
||||
### Build (autopopulated)
|
||||
|
||||
Apple fills this in once the binary uploads + processes. The same build that went through TestFlight Beta Review is the one you ship to the public store.
|
||||
|
||||
### Version
|
||||
|
||||
Marketing version: `2.5.0` — the same number `release.sh` will write to `MARKETING_VERSION` for the macOS Scarf release. Keeping the iOS + Mac versions in lockstep is the convention this project uses.
|
||||
|
||||
---
|
||||
|
||||
## Build artifact
|
||||
|
||||
### App icon (1024×1024)
|
||||
|
||||
```
|
||||
scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png
|
||||
```
|
||||
|
||||
The full appiconset is in repo and the Xcode target references it via `AppIcon`. App Store Connect pulls the 1024 from the binary on upload — no separate upload step.
|
||||
|
||||
### Screenshots
|
||||
|
||||
**Required for the public App Store, NOT required for TestFlight.** Scope deliberately excluded from this prep pass — capture from the simulator before flipping the App Store listing live. Apple requires:
|
||||
|
||||
- iPhone 6.7" (e.g. iPhone 16 Pro Max) — at least 5, up to 10
|
||||
- iPhone 6.5" (e.g. iPhone 14 Plus) — at least 5, up to 10
|
||||
- iPhone 5.5" (e.g. iPhone 8 Plus) — at least 5, up to 10
|
||||
- iPad — only if you flip the iPad flag in the target. Skip for v2.5.
|
||||
|
||||
Suggested screen captures (rough order):
|
||||
1. Dashboard with stats + recent sessions list
|
||||
2. Chat in mid-stream with a tool-call disclosure expanded
|
||||
3. Project picker sheet
|
||||
4. Sessions tab with project filter active
|
||||
5. Memory editor with Saved indicator
|
||||
6. Skills detail with frontmatter chips visible
|
||||
7. Server list (showing multi-server)
|
||||
8. Onboarding step 5 (public-key display)
|
||||
|
||||
### App preview video (optional)
|
||||
|
||||
Skip for v1. Apple will accept the listing without it.
|
||||
|
||||
---
|
||||
|
||||
## Beta App Review (TestFlight) — already submitted
|
||||
|
||||
Cross-reference [TESTFLIGHT_CHECKLIST.md](TESTFLIGHT_CHECKLIST.md). Once Apple's Beta Review approves the first build, the public TestFlight URL `https://testflight.apple.com/join/qCrRpcTz` accepts new joiners. Until then the link 404s with a "not accepting testers" splash.
|
||||
|
||||
## Public App Store submission flow (after TestFlight stabilizes)
|
||||
|
||||
1. App Store Connect → My Apps → ScarfGo → App Store tab → iOS App.
|
||||
2. Paste every field above into the matching form.
|
||||
3. Set the build to the same one that's been on TestFlight (Apple lets you reuse a TestFlight build verbatim — no re-upload).
|
||||
4. Submit for review. Apple's standard App Review queue (separate from Beta Review) is typically 24–72h. Watch your inbox for "We have a question" emails and reply via App Store Connect's review-team chat.
|
||||
5. On approval, choose "Manually release this version" so you can announce on a schedule.
|
||||
|
||||
## Update cadence
|
||||
|
||||
The same `releases/v<VERSION>/` directory pattern this file lives in is the canonical staging area for every future iOS release. When v2.6 (or whatever ships next) bumps the iOS app, copy this file forward and update:
|
||||
|
||||
- **Promotional text** — refreshed marketing wedge.
|
||||
- **What's New text** — what changed since the last App Store release.
|
||||
- Everything else above stays unless you're changing categories, support URL, or privacy stance.
|
||||
|
||||
The Mac `release.sh` does not yet drive the iOS release — that's a separate Xcode Archive + App Store Connect upload. See `TESTFLIGHT_CHECKLIST.md` Phase 4 for the archive flow.
|
||||
@@ -83,6 +83,10 @@ Pre-2.5, both Mac and iOS rendered cron jobs as `0 */6 * * *` raw. The new `Cron
|
||||
- **`RichChatViewModel`** carries the ACP error triplet (`acpError`, `acpErrorHint`, `acpErrorDetails`) for both platforms — Mac's `ChatViewModel` now delegates instead of duplicating.
|
||||
- **Test reliability.** Cross-suite races on `ServerContext.sshTransportFactory` resolved by consolidating every factory-touching test into a single `.serialized` suite. 163 tests across 12 suites, three consecutive green runs.
|
||||
- **Surface silent failures.** Several `try?` swallows in iOS lifecycle code now surface to the user — Keychain unlock errors no longer dump people back into onboarding, partial Forget operations report what failed, project-context-block writes that fail surface a banner instead of silently degrading agent context.
|
||||
- **iOS exec channel hardening.** `CitadelServerTransport.runProcess` was wrapping Citadel's `executeCommand`, which throws `CommandFailed` on non-zero exit and discards the captured stdout buffer in the throw path. `hermes skills browse` happens to print its full table and *then* exit non-zero on some hosts, so iOS got nothing while Mac (Foundation `Process`) got the full output with `exitCode=1`. v2.5 drives `executeCommandStream` directly, drains stdout + stderr regardless of outcome, and recovers the actual exit code from the `CommandFailed` catch. Same channel now also inline-prepends `PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"` on every invocation — Citadel's raw exec channel doesn't source the user's shell rc files, so non-interactive sessions land with a stripped `PATH` (`/usr/bin:/bin`) and pipx's default install dir is invisible. Mac's OpenSSH sshd handles this transparently; we now match.
|
||||
- **fd-leak cleanup.** `LocalTransport` / `SSHTransport` / `ProcessACPChannel` all close the parent's copy of every pipe write end after spawn so EOF reaches the reader once the child exits, plus close read ends after draining. Was leaking one fd per `runProcess` / `streamLines` / ACP turn under load.
|
||||
- **Status-poll backoff.** `ServerLiveStatus` now uses 10s → 30s → 60s → 120s → 300s exponential backoff on consecutive probe failures, resetting on the first full success. Previously a registered remote going unreachable hammered `pgrep` + `gateway_state.json` every 10s indefinitely; offline servers now settle to a 5-minute cadence while live ones stay snappy.
|
||||
- **Logger conversion.** Remaining `print("[Scarf] …")` debug statements in `HermesDataService`, `HermesLogService`, and `ProjectDashboardService` swap to `os.Logger` calls (subsystem `com.scarf`), matching the global rule that production code uses `Logger` and `print()` is reserved for previews + test helpers.
|
||||
|
||||
### Notes for users running 2.3
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -19,9 +19,14 @@ public final class ConnectionStatusViewModel {
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||
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,29 @@ 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 {
|
||||
/// `config.yaml` is missing entirely. Most common cause: Hermes
|
||||
/// hasn't run `setup` yet on this remote.
|
||||
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.
|
||||
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 +70,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,7 +106,7 @@ 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
|
||||
@@ -97,57 +123,83 @@ 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) instead of the prior
|
||||
// collapsed-to-binary "can't read config.yaml". Causes:
|
||||
// no-home — $H itself doesn't exist
|
||||
// missing — config.yaml absent
|
||||
// perm — exists but unreadable by SSH user
|
||||
// profile:<name> — config 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/config.yaml" ]; then
|
||||
echo TIER2:0
|
||||
elif [ ! -d "$H" ]; then
|
||||
echo TIER2:1:no-home
|
||||
elif [ ! -e "$H/config.yaml" ]; 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 +228,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 set up yet",
|
||||
"`\(hermesHome)/config.yaml` is missing. Run `hermes setup` (or your first `hermes chat`) on the remote to create it. Scarf will go green automatically once it appears."
|
||||
)
|
||||
case .configUnreadable:
|
||||
return (
|
||||
"Permission denied on config.yaml",
|
||||
"`\(hermesHome)/config.yaml` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/config.yaml`. 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 config lives at `~/.hermes/profiles/\(name)/config.yaml`, not `\(hermesHome)/config.yaml`. 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)/config.yaml`. 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
|
||||
@@ -759,7 +774,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.
|
||||
|
||||
@@ -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,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)
|
||||
@@ -138,6 +138,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 +185,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 {
|
||||
|
||||
@@ -185,8 +185,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 +332,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()
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 818 KiB |
@@ -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
|
||||
@@ -200,6 +206,7 @@ struct ChatView: View {
|
||||
message: msg,
|
||||
turnDuration: controller.vm.turnDuration(forMessageId: msg.id)
|
||||
)
|
||||
.equatable()
|
||||
.id(msg.id)
|
||||
}
|
||||
if controller.vm.isGenerating {
|
||||
@@ -233,6 +240,11 @@ 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)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -310,9 +322,27 @@ struct ChatView: View {
|
||||
.lineLimit(1...5)
|
||||
.disabled(controller.state != .ready)
|
||||
.submitLabel(.send)
|
||||
.focused($composerFocused)
|
||||
.onSubmit {
|
||||
Task { await controller.send() }
|
||||
}
|
||||
// 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).
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button {
|
||||
composerFocused = false
|
||||
} label: {
|
||||
Image(systemName: "keyboard.chevron.compact.down")
|
||||
}
|
||||
.accessibilityLabel("Hide keyboard")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await controller.send() }
|
||||
@@ -1006,7 +1036,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 +1044,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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -4,11 +4,5 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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 = 25;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -546,17 +546,21 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -567,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 = 25;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -584,17 +588,21 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
@@ -604,7 +612,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -627,7 +635,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -650,7 +658,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -672,7 +680,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -826,7 +834,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -840,7 +848,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -862,7 +870,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -876,7 +884,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -894,12 +902,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -916,12 +924,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -937,11 +945,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -957,11 +965,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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"
|
||||
}
|
||||
|
||||
/// 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)%"
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ struct RichChatMessageList: View {
|
||||
|
||||
ForEach(groups) { group in
|
||||
MessageGroupView(group: group, turnDurations: turnDurations)
|
||||
.equatable()
|
||||
.id("group-\(group.id)")
|
||||
}
|
||||
|
||||
@@ -136,7 +137,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 +145,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 +214,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 +234,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,6 +22,13 @@ 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
|
||||
|
||||
/// In ACP mode, events drive updates directly — no DB polling needed.
|
||||
private var isACPMode: Bool { chatViewModel.isACPConnected }
|
||||
|
||||
@@ -42,6 +49,7 @@ struct RichChatView: View {
|
||||
.frame(width: 320)
|
||||
}
|
||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,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):
|
||||
@@ -282,164 +286,6 @@ 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" }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
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
|
||||
|
||||
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)
|
||||
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 +59,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||