Files
scarf/scarf/Scarf iOS/Chat/ChatView.swift
T
Alan Wizemann 1354568992 feat(hermes-v12): ACP multimodal image input on Mac + iOS (Phase C)
Hermes v0.12 advertises `prompt_capabilities.image = true` and accepts
image content blocks in `session/prompt`. This wires a producer flow on
both targets so users can attach images alongside text and have them
routed to the vision-capable model automatically.

Pipeline:

- ChatImageAttachment: Sendable value type holding base64 payload +
  thumbnail, MIME type, source filename, and approximate byte count.
- ImageEncoder: detached-only Sendable service that downsamples to
  Anthropic's 1568px long-edge cap, JPEG-encodes at q=0.85, and
  produces a small inline thumbnail for composer chips. Cross-platform
  (NSImage on Mac, UIImage on iOS, JPEG-passthrough on Linux/CI).
- ACPClient.sendPrompt(sessionId:text:images:) overload emits a content
  array `[{type: "text"...}, {type: "image", data, mimeType}]` matching
  the wire shape in hermes-agent/acp_adapter/server.py. The
  zero-arg-images convenience overload preserves the v0.11 wire shape
  for any unmodified callers.

Mac UI:

- RichChatInputBar grew an `attachments: [ChatImageAttachment]` state
  array, a paperclip button (NSOpenPanel multi-pick), drag-drop and
  paste handlers, and a horizontal preview chip strip. The "send"
  callback's signature is `(String, [ChatImageAttachment]) -> Void`
  threaded through RichChatView -> ChatTranscriptPane -> ChatView ->
  ChatViewModel.sendText(text, images:). Image-only prompts are
  permitted ("describe this") once at least one attachment is queued.

iOS UI:

- ChatView's composer adopts a paperclip + PhotosPicker flow with the
  same chip strip and 5-attachment cap. Attachments live on
  ChatController so they survive across PhotosPicker presentations.
  loadTransferable(type: Data.self) feeds raw bytes into the same
  ImageEncoder; encode work runs detached so MainActor stays
  responsive on cellular.

Capability gating:

- Both composers hide the entire attachment surface when
  HermesCapabilities.hasACPImagePrompts is false (pre-v0.12 hosts).
  No paperclip button, no drop target, no paste accept — the input bar
  is byte-for-byte the v0.11 surface against an older Hermes.

Tests: 209 ScarfCore tests pass; both Mac and iOS schemes build clean.
The encoder's pixel work is hard to unit-test at the package level
(no NSImage/UIImage in plain Swift CI) — manual end-to-end testing
is the verification path here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:28:41 +02:00

2392 lines
100 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import ScarfCore
import ScarfIOS
import ScarfDesign
import os
#if canImport(PhotosUI)
import PhotosUI
#endif
// The Chat feature on iOS is gated on `canImport(SQLite3)` because
// `RichChatViewModel` reads session history from `HermesDataService`
// (which is itself SQLite3-gated). iOS always has SQLite3 available,
// so on any real iOS build this renders normally. The guard exists
// so ScarfCore-agnostic static analysis doesn't choke.
#if canImport(SQLite3)
/// M4 iOS Chat: streams JSON-RPC over a Citadel SSH exec channel to a
/// remote `hermes acp` process. Reuses ScarfCore's `RichChatViewModel`
/// state machine (from M0d) + `ACPClient` (from M1).
///
/// Scope: one active session, rich-chat mode only (no terminal /
/// SwiftTerm mode). Permission prompts, tool-call display, markdown,
/// voice all deferred to M5+ polish.
struct ChatView: View {
let config: IOSServerConfig
let key: SSHKeyBundle
@Environment(\.scarfGoCoordinator) private var coordinator
@Environment(\.serverContext) private var envContext
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var controller: ChatController
@State private var showProjectPicker = false
@State private var showSlashCommandsSheet = false
/// PhotosPicker selection. Bridge between SwiftUI's selection
/// binding and our `ChatImageAttachment` payload `loadTransferable`
/// produces raw `Data` we then hand to `ImageEncoder`. v0.12+ only.
@State private var pickerSelection: [PhotosPickerItem] = []
@State private var showPhotoPicker = false
@State private var isEncodingAttachment = false
@State private var attachmentError: String?
private static let maxAttachments = 5
private var supportsImagePrompts: Bool {
capabilitiesStore?.capabilities.hasACPImagePrompts ?? 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
self.key = key
let ctx = config.toServerContext(id: Self.sharedContextID)
_controller = State(initialValue: ChatController(context: ctx))
}
/// Same UUID DashboardView uses, so the transport's cached SSH
/// connection (if still open) can be reused when the user hops
/// between Chat and Dashboard.
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
)!
var body: some View {
VStack(spacing: 0) {
connectionBanner
errorBanner
projectContextBar
messageList
Divider()
if let hint = controller.vm.transientHint {
steeringToast(hint)
}
composer
}
.background(ScarfColor.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
// Principal: "Chat" title + small folder chip below when
// the current session is project-attributed. iOS-native
// equivalent of Mac's SessionInfoBar project-chip pattern.
ToolbarItem(placement: .topBarTrailing) {
Button {
showProjectPicker = true
} label: {
Image(systemName: "plus.bubble")
}
.disabled(controller.state == .connecting)
}
}
.sheet(isPresented: $showProjectPicker) {
ProjectPickerSheet(
context: config.toServerContext(id: Self.sharedContextID),
onQuickChat: {
Task { await controller.resetAndStartNewSession() }
},
onProject: { project in
Task { await controller.resetAndStartInProject(project) }
}
)
}
.task {
// Dashboard row taps set `pendingResumeSessionID`, Project
// Detail's "New Chat" sets `pendingProjectChat`. Both fire
// a tab switch to .chat alongside the value set; we
// consume + clear here on first appear. Resume wins over
// project-chat if both somehow get set in a single hop
// but in practice the coordinator never sets both at once.
if let sessionID = coordinator?.pendingResumeSessionID {
coordinator?.pendingResumeSessionID = nil
await controller.startResuming(sessionID: sessionID)
} else if let projectPath = coordinator?.pendingProjectChat {
coordinator?.pendingProjectChat = nil
await consumePendingProjectChat(projectPath)
} else {
await controller.start()
}
}
// React to coordinator changes that happen while Chat is
// already mounted (e.g., user is in Chat, taps Projects, opens
// a project detail, taps "New Chat" coordinator flips the
// tab AND sets pendingProjectChat. The `.task` above only
// fires on first appear; these are the mid-session hooks.)
.onChange(of: coordinator?.pendingResumeSessionID) { _, new in
guard let sessionID = new else { return }
coordinator?.pendingResumeSessionID = nil
Task { await controller.startResuming(sessionID: sessionID) }
}
.onChange(of: coordinator?.pendingProjectChat) { _, new in
guard let projectPath = new else { return }
coordinator?.pendingProjectChat = nil
Task { await consumePendingProjectChat(projectPath) }
}
// React to network reachability transitions. The service
// updates its `transitionTick` on every `.satisfied <->
// .unsatisfied` edge; the `.onChange` here funnels each
// edge into ChatController so the reconnect machinery can
// suspend on link-down and resume on link-up.
.onChange(of: NetworkReachabilityService.shared.transitionTick) { _, _ in
Task { await controller.handleReachabilityChange() }
}
// React to scene-phase transitions (background active etc).
// Source of truth is the coordinator, not `@Environment(\.scenePhase)`,
// so the chat tab still picks up phase changes that happened
// while it was unmounted (the user is on Dashboard when the
// app backgrounds; sees Chat after resume).
.onChange(of: coordinator?.scenePhaseTick) { _, _ in
guard let phase = coordinator?.scenePhase else { return }
Task { await controller.handleScenePhase(phase) }
}
// Deliberately NOT tearing down the ACP session on .onDisappear.
// `TabView` unmounts tab content when the user switches tabs
// (disappear fires), but `@State var controller` keeps the
// ChatController alive across those switches, so dropping the
// SSH exec channel + re-opening on next appear would cost the
// user a ~1-2s reconnect every time they hop to Dashboard
// and back. The ACPClient stays open; the controller cleans up
// properly when:
// - the user Disconnects / Forgets the server (RootModel
// flips out of .connected, whole tab root unmounts, and
// ChatController.deinit + transport teardown runs),
// - or the app goes to background (iOS will terminate the
// socket eventually if memory pressure hits anyway).
// If a future iPad / multi-window variant wants to explicitly
// pause idle connections, add a coordinator-driven stop() on
// app-lifecycle phase changes instead.
.overlay {
if case .failed(let msg) = controller.state {
errorOverlay(msg)
} else if controller.state == .connecting {
connectingOverlay
}
}
.sheet(isPresented: Binding(
get: { controller.modelPreflightReason != nil },
set: { newValue in
if !newValue { controller.cancelModelPreflight() }
}
)) {
IOSModelPreflightSheet(
reason: controller.modelPreflightReason ?? "",
serverDisplayName: controller.context.displayName,
onSelect: { model, provider in
controller.confirmModelPreflight(model: model, provider: provider)
},
onCancel: { controller.cancelModelPreflight() }
)
}
.sheet(item: Binding(
get: { controller.vm.pendingPermission.map(PermissionWrapper.init) },
set: { if $0 == nil { controller.vm.pendingPermission = nil } }
)) { wrapper in
PermissionSheet(permission: wrapper.value) { optionId in
await controller.respondToPermission(
requestId: wrapper.value.requestId,
optionId: optionId
)
}
// Custom detents `.medium` is either too tall (empty
// space above) or too short (options clipped). A 220pt
// peek shows the prompt + first ~3 options; users can
// drag to large for long option lists.
.presentationDetents([.height(220), .large])
.presentationDragIndicator(.visible)
}
}
/// Resolve a project absolute path to a `ProjectEntry` via the
/// transport-backed registry, then dispatch `resetAndStartInProject`.
/// If the path isn't registered (race with a Mac-app removal, or
/// SFTP read failure), fall back to a synthesized entry whose name
/// is the path's last component chat still starts and the user
/// sees a usable project chip.
private func consumePendingProjectChat(_ path: String) async {
let ctx = config.toServerContext(id: Self.sharedContextID)
let entry: ProjectEntry = await Task.detached {
let registry = ProjectDashboardService(context: ctx).loadRegistry()
if let match = registry.projects.first(where: { $0.path == path }) {
return match
}
return ProjectEntry(
name: (path as NSString).lastPathComponent.isEmpty ? path : (path as NSString).lastPathComponent,
path: path
)
}.value
await controller.resetAndStartInProject(entry)
}
// MARK: - Subviews
@ViewBuilder
private var messageList: some View {
ScrollView {
LazyVStack(spacing: 12) {
if controller.vm.messages.isEmpty, controller.state == .ready {
if controller.vm.sessionId != nil {
// Resumed-session path: session ID is set but
// no messages loaded. ACP-native sessions don't
// persist their transcript to state.db (only
// CLI/terminal sessions do), so resuming one
// reconnects to the agent but can't surface
// the history client-side. Explain to the user
// rather than showing a blank canvas.
resumedEmptyState
} else {
emptyState
}
}
if controller.vm.hasMoreHistory {
loadEarlierButton
}
ForEach(controller.vm.messages) { msg in
MessageBubble(
message: msg,
turnDuration: controller.vm.turnDuration(forMessageId: msg.id)
)
.equatable()
.id(msg.id)
}
if controller.vm.isGenerating {
HStack {
ProgressView()
Text("Agent is thinking…")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
} else if controller.vm.isPostProcessing {
HStack(spacing: 6) {
Image(systemName: "ellipsis")
.font(.caption2)
.foregroundStyle(.tertiary)
Text("Finishing up…")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
}
.padding(.vertical)
}
// iOS 17+ keeps the scroll pinned to the newest content at
// the bottom; iOS 18's `.sizeChanges` variant also tracks
// when a message grows (streaming chunks, Expand-all on a
// code block). Replaces the old manual proxy.scrollTo dance
// which fought with the user's own scroll gestures.
.defaultScrollAnchor(.bottom)
.defaultScrollAnchor(.bottom, for: .sizeChanges)
// Drag the messages downward to interactively collapse the
// keyboard the standard iOS chat gesture. Without this the
// keyboard could never be dismissed once it rose, hiding the
// top-trailing nav button on small phones (issue #51).
.scrollDismissesKeyboard(.interactively)
}
/// "Load earlier messages" affordance pinned above the oldest
/// loaded bubble. Only rendered when `vm.hasMoreHistory == true`,
/// so it disappears organically once the user has paged back to
/// the start of the session.
@ViewBuilder
private var loadEarlierButton: some View {
Button {
Task { await controller.vm.loadEarlier() }
} label: {
HStack(spacing: 6) {
if controller.vm.isLoadingEarlier {
ProgressView()
.scaleEffect(0.7)
} else {
Image(systemName: "arrow.up.circle")
.font(.caption)
}
Text(controller.vm.isLoadingEarlier ? "Loading earlier…" : "Load earlier messages")
.font(.caption)
}
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.regularMaterial, in: Capsule())
}
.buttonStyle(.plain)
.disabled(controller.vm.isLoadingEarlier)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
@ViewBuilder
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 40))
.foregroundStyle(.tertiary)
Text("Ask Hermes something")
.font(.headline)
.foregroundStyle(ScarfColor.foregroundMuted)
Text("Connected to \(config.displayName)")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
/// Friendlier-than-blank state for a session resumed from the
/// Dashboard that had no transcript persisted to `state.db`.
/// Hermes doesn't write ACP-native session messages to the
/// client DB only CLI/terminal sessions leave a history there
/// so resuming a "recent session" started via Chat means the
/// agent has the context but the client can't replay it. The
/// user can keep chatting and the agent will have full memory.
@ViewBuilder
private var resumedEmptyState: some View {
VStack(spacing: 8) {
Image(systemName: "arrow.clockwise.circle")
.font(.system(size: 40))
.foregroundStyle(.tertiary)
Text("Session resumed")
.font(.headline)
.foregroundStyle(ScarfColor.foregroundMuted)
Text("Hermes has the context for this session, but the transcript isn't cached locally. Send a message to continue.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
/// Top-of-screen banner for transient connection states. `.failed`
/// keeps using the existing full-screen overlay (so the user has
/// somewhere obvious to tap "Retry"); `.reconnecting` and
/// `.offline` are non-modal so the user can keep reading the
/// transcript while we work in the background.
@ViewBuilder
private var connectionBanner: some View {
switch controller.state {
case .reconnecting(let attempt, let total):
connectionBannerStrip(
text: "Reconnecting (\(attempt)/\(total))…",
tint: ScarfColor.warning,
showSpinner: true
)
case .offline(let reason):
connectionBannerStrip(
text: reason,
tint: ScarfColor.danger,
showSpinner: false
)
default:
EmptyView()
}
}
private func connectionBannerStrip(
text: String,
tint: Color,
showSpinner: Bool
) -> some View {
HStack(spacing: 8) {
if showSpinner {
ProgressView()
.scaleEffect(0.7)
.tint(tint)
} else {
Image(systemName: "wifi.slash")
.font(.caption)
.foregroundStyle(tint)
}
Text(text)
.font(.caption)
.foregroundStyle(tint)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint.opacity(0.16))
.transition(.move(edge: .top).combined(with: .opacity))
}
@ViewBuilder
/// Soft pill above the composer confirming a non-interruptive
/// command was received (e.g. `/steer`). Auto-clears via the
/// 4-second Task in `ChatController.send()`.
private func steeringToast(_ hint: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "arrowshape.turn.up.right.fill")
.foregroundStyle(.tint)
.font(.caption)
Text(hint)
.font(.caption)
.foregroundStyle(.primary)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.tint.opacity(0.12))
.transition(.opacity)
}
private var composer: some View {
VStack(alignment: .leading, spacing: 4) {
if !controller.attachments.isEmpty || isEncodingAttachment || attachmentError != nil {
attachmentStrip
}
composerRow
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial)
#if canImport(PhotosUI)
.photosPicker(
isPresented: $showPhotoPicker,
selection: $pickerSelection,
maxSelectionCount: max(0, Self.maxAttachments - controller.attachments.count),
matching: .images
)
.onChange(of: pickerSelection) { _, items in
ingestPickerItems(items)
}
#endif
}
@ViewBuilder
private var attachmentStrip: some View {
HStack(alignment: .center, spacing: 8) {
if isEncodingAttachment {
ProgressView().controlSize(.small)
Text("Encoding…")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(controller.attachments) { attachment in
attachmentChip(attachment)
}
if let err = attachmentError {
Text(err)
.font(.caption)
.foregroundStyle(ScarfColor.danger)
}
Spacer(minLength: 0)
if !controller.attachments.isEmpty {
Text("\(controller.attachments.count)/\(Self.maxAttachments)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
@ViewBuilder
private func attachmentChip(_ attachment: ChatImageAttachment) -> some View {
HStack(spacing: 4) {
attachmentChipThumbnail(attachment)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
Button {
controller.attachments.removeAll { $0.id == attachment.id }
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Remove attached image")
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(ScarfColor.backgroundSecondary)
)
}
@ViewBuilder
private func attachmentChipThumbnail(_ attachment: ChatImageAttachment) -> some View {
if let thumb = attachment.thumbnailBase64,
let data = Data(base64Encoded: thumb),
let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "photo")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ScarfColor.backgroundSecondary)
}
}
private var composerRow: some View {
HStack(alignment: .bottom, spacing: 8) {
if supportsImagePrompts {
Button {
showPhotoPicker = true
} label: {
Image(systemName: "paperclip")
.font(.system(size: 22))
.foregroundStyle(.secondary)
.padding(.bottom, 4)
}
.buttonStyle(.plain)
.disabled(controller.state != .ready || controller.attachments.count >= Self.maxAttachments)
.accessibilityLabel("Attach image")
}
TextField(
"Message…",
text: $controller.draft,
axis: .vertical
)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.disabled(controller.state != .ready)
.submitLabel(.send)
.focused($composerFocused)
.onSubmit {
Task { await controller.send() }
}
// Persist the half-typed message across app suspensions
// and force-quits. Debounced inside `scheduleDraftSave`
// so we coalesce per-keystroke writes.
.onChange(of: controller.draft) { _, _ in
controller.scheduleDraftSave()
}
// Explicit dismiss-keyboard affordance, complementing the
// interactive scroll-to-dismiss on the message list. iOS
// shows a keyboard accessory toolbar above the system
// keyboard whenever a focused TextField is on screen;
// putting a "Done" chevron there is the most-discoverable
// dismissal pattern (issue #51). Pinned to the LEADING
// edge (Spacer trails) so the chevron doesn't visually
// stack above the trailing-edge send button in the
// composer below that stacking was the complaint in
// issue #57. Matches iOS convention (Notes, Mail, Reminders
// all put accessory dismiss on the leading side).
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button {
composerFocused = false
} label: {
Image(systemName: "keyboard.chevron.compact.down")
}
.accessibilityLabel("Hide keyboard")
Spacer()
}
}
Button {
Task { await controller.send() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 28))
}
.disabled(!canSendComposer)
}
}
/// Send is enabled when ready AND we have either text or at least
/// one attachment. Image-only sends are valid for vision models.
private var canSendComposer: Bool {
guard controller.state == .ready else { return false }
if !controller.attachments.isEmpty { return true }
return !controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Pull JPEG/PNG bytes out of each PhotosPickerItem and feed them
/// through ImageEncoder. Detached so the heavyweight resize +
/// JPEG-encode work doesn't block MainActor; the resulting
/// attachment hops back to MainActor for state mutation.
///
/// PhotosPickerItem can deliver `Data` directly via the
/// `Transferable` API. After ingestion the binding is reset so a
/// follow-up pick triggers `onChange` again.
#if canImport(PhotosUI)
private func ingestPickerItems(_ items: [PhotosPickerItem]) {
guard !items.isEmpty else { return }
// Capture the items, immediately clear the binding so a future
// pick triggers onChange even when the user re-selects the
// same image set. PhotosPicker behavior: identical selection
// doesn't re-fire onChange unless the binding flips through nil.
let snapshot = items
pickerSelection = []
isEncodingAttachment = true
Task { @MainActor in
for item in snapshot {
guard controller.attachments.count < Self.maxAttachments else { break }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
let attachment = try await Task.detached(priority: .userInitiated) {
try ImageEncoder().encode(rawBytes: data, sourceFilename: nil)
}.value
controller.attachments.append(attachment)
} catch {
attachmentError = (error as? LocalizedError)?.errorDescription ?? "Couldn't encode image"
Task { @MainActor in
try? await Task.sleep(nanoseconds: 4_000_000_000)
attachmentError = nil
}
}
}
isEncodingAttachment = false
}
}
#endif
@State private var showErrorDetails: Bool = false
/// Inline error banner rendered above the message list when the
/// ACP layer signals a non-retryable failure (provider HTTP 4xx,
/// malformed model, missing credentials). Mirrors the Mac pattern
/// in scarf/scarf/Features/Chat/Views/ChatView.swift:errorBanner;
/// both now pull from RichChatViewModel's shared error triplet.
/// Pass-1 M7 #2 previously errors vanished into stderr and the
/// user saw a perpetual spinner.
@ViewBuilder
private var errorBanner: some View {
if let err = controller.vm.acpError {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
if let hint = controller.vm.acpErrorHint {
Text(hint)
.font(.callout)
.textSelection(.enabled)
}
Text(err)
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.textSelection(.enabled)
.lineLimit(showErrorDetails ? nil : 2)
}
Spacer(minLength: 4)
if controller.vm.acpErrorDetails != nil {
Button(showErrorDetails ? "Hide" : "Details") {
showErrorDetails.toggle()
}
.buttonStyle(.borderless)
.controlSize(.small)
}
Button {
let payload = [
controller.vm.acpErrorHint,
err,
controller.vm.acpErrorDetails
]
.compactMap { $0 }
.joined(separator: "\n\n")
UIPasteboard.general.string = payload
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.controlSize(.small)
}
if showErrorDetails, let details = controller.vm.acpErrorDetails {
ScrollView(.vertical) {
Text(details)
.font(.caption2.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 140)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.12))
}
}
/// Contextual header rendered BELOW the navigation bar when the
/// current session is scoped to a Scarf project. Sits full-width
/// so the project name has room to breathe (the nav bar's
/// `.principal` slot gets squeezed to icon-only by adjacent
/// toolbar buttons on iPhone exactly the pass-2 bug). Drawn as
/// a subtle tinted strip so it doesn't dominate but is clearly
/// informational.
@ViewBuilder
private var projectContextBar: some View {
if let projectName = controller.currentProjectName,
!projectName.isEmpty
{
HStack(spacing: 8) {
Image(systemName: "folder.fill")
.foregroundStyle(.tint)
.font(.caption)
VStack(alignment: .leading, spacing: 1) {
Text("Project chat")
.font(.caption2)
.foregroundStyle(ScarfColor.foregroundMuted)
HStack(spacing: 6) {
Text(projectName)
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
if let branch = controller.currentGitBranch, !branch.isEmpty {
Label(branch, systemImage: "arrow.triangle.branch")
.font(.caption2)
.foregroundStyle(.tint)
.labelStyle(.titleAndIcon)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.tint.opacity(0.15), in: Capsule())
.lineLimit(1)
}
}
}
Spacer()
if !controller.vm.projectScopedCommands.isEmpty {
Button {
showSlashCommandsSheet = true
} label: {
Label(
"\(controller.vm.projectScopedCommands.count) slash",
systemImage: "slash.circle.fill"
)
.font(.caption)
.foregroundStyle(.tint)
.labelStyle(.titleAndIcon)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.tint.opacity(0.18), in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.tint.opacity(0.1))
.sheet(isPresented: $showSlashCommandsSheet) {
ProjectSlashCommandsBrowser(
projectName: projectName,
commands: controller.vm.projectScopedCommands
)
}
}
}
/// Shown while we're opening the SSH exec channel + spawning
/// `hermes acp` + creating the ACP session. Typically ~0.51.5 s
/// on a warm network silent before this overlay existed, which
/// made the app feel frozen (pass-1 M7 #3).
@ViewBuilder
private var connectingOverlay: some View {
VStack(spacing: 12) {
ProgressView()
.controlSize(.large)
Text("Connecting to \(config.displayName)")
.font(.callout)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.padding(24)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
@ViewBuilder
private func errorOverlay(_ message: String) -> some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 32))
.foregroundStyle(.orange)
Text("Chat connection failed")
.font(.headline)
Text(message)
.font(.callout)
.multilineTextAlignment(.center)
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(.horizontal)
Button("Retry") {
Task { await controller.start() }
}
.buttonStyle(ScarfPrimaryButton())
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding()
}
}
// MARK: - ChatController
/// Owns the ACPClient + RichChatViewModel lifecycle for one iOS chat
/// screen. Kept out of `ChatView.body` so SwiftUI view re-renders don't
/// spawn or tear down SSH connections unintentionally.
@Observable
@MainActor
final class ChatController {
enum State: Equatable {
case idle
case connecting
case ready
/// Mid-recovery: the SSH exec channel died but the agent on
/// the remote may still be running. We're trying to reattach
/// via `session/resume` (or `session/load` as a fallback).
case reconnecting(attempt: Int, of: Int)
/// Network reachability is unsatisfied. Distinct from
/// `.failed` so the banner can stay tinted yellow ("we'll
/// retry") instead of red ("dead").
case offline(reason: String)
case failed(String)
}
private(set) var state: State = .idle
var vm: RichChatViewModel
var draft: String = ""
/// v0.12+ image attachments queued to send with the next prompt.
/// Capped at 5 by the composer UI; the cap matches the Mac behavior
/// and keeps total ACP prompt payload under ~2 MB even on a slow
/// cellular link. Cleared after each successful `send()`.
var attachments: [ChatImageAttachment] = []
/// Set when chat-start is blocked because the active server's
/// `config.yaml` has no `model.default` / `model.provider`. ChatView
/// observes this to present an inline "pick a model" sheet the
/// Mac picker UI doesn't ship on iOS today, so the iOS sheet
/// captures model + provider as text fields and persists them via
/// the same `hermes config set` path. Reset on cancel or after a
/// successful retry.
var modelPreflightReason: String?
/// Stash of the original chat-start intent while we wait for the
/// user to fill in a model. Captured by the gate inside `start`,
/// `startInternal`, `startResuming`; replayed verbatim once
/// `confirmModelPreflight` writes the chosen values to config.yaml
/// so the chat the user originally tried to open lands without
/// them having to click the project row again.
private enum PendingStart {
case fresh
case project(path: String, name: String)
case resume(sessionID: String)
}
private var pendingStartIntent: PendingStart?
/// Display name of the Scarf project this session is scoped to,
/// or nil for "quick chat" / global sessions. Surfaced as a
/// subtitle under the "Chat" title in the nav bar so users can
/// see at a glance which project the agent is operating inside.
/// Set by `resetAndStartInProject` and by `startResuming` when
/// the resumed session is attributed to a registered project.
private(set) var currentProjectName: String?
/// Git branch of the project's working directory at session start
/// (v2.5). Nil for non-project sessions and projects that aren't
/// git repos / have git missing on the host. Surfaced as a small
/// chip on the right side of the project context bar.
private(set) var currentGitBranch: String?
/// Public so the surrounding `ChatView` can read `displayName`
/// when presenting sheets (e.g., the model preflight). Still
/// `let` set once at init, never mutated after.
let context: ServerContext
private var client: ACPClient?
private var eventTask: Task<Void, Never>?
private var healthMonitorTask: Task<Void, Never>?
private var reconnectTask: Task<Void, Never>?
private var isHandlingDisconnect = false
private var pendingDraftSave: Task<Void, Never>?
/// Session id of the currently-active chat. Saved when state
/// reaches `.ready` and cleared on explicit `stop()` so a
/// user-initiated disconnect doesn't get auto-reconnected when
/// network/scene events fire later.
private var lastActiveSessionID: String?
/// Optional project working directory of the currently-active
/// session. Used as `cwd` on the recovery path so a project-
/// scoped session reconnects with the right scope.
private var lastProjectPath: String?
// Reconnect tuning verbatim from the Mac implementation at
// scarf/Features/Chat/ViewModels/ChatViewModel.swift:563-693.
private static let maxReconnectAttempts = 5
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1s
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16s
private static let logger = Logger(
subsystem: "com.scarf.ios",
category: "ChatController"
)
// MARK: - Draft persistence
private static let draftKeyPrefix = "scarf.chat.draft.v1"
private static let draftMaxAge: TimeInterval = 7 * 24 * 60 * 60 // 7 days
private static func draftKey(serverID: ServerID, sessionID: String?) -> String {
// `_no_session` covers the brief connecting window before
// `vm.setSessionId` lands. The TextField is disabled in that
// window today, so this slot is essentially never written
// but the sentinel is here so the key is always well-formed.
"\(draftKeyPrefix).\(serverID.uuidString).\(sessionID ?? "_no_session")"
}
private static func draftTimestampKey(forKey key: String) -> String { key + ".ts" }
private func saveDraft() {
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
let tsKey = Self.draftTimestampKey(forKey: key)
if draft.isEmpty {
UserDefaults.standard.removeObject(forKey: key)
UserDefaults.standard.removeObject(forKey: tsKey)
} else {
UserDefaults.standard.set(draft, forKey: key)
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: tsKey)
}
}
private func loadDraft() {
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
if let saved = UserDefaults.standard.string(forKey: key), !saved.isEmpty {
draft = saved
}
}
private func clearStoredDraft() {
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
UserDefaults.standard.removeObject(forKey: key)
UserDefaults.standard.removeObject(forKey: Self.draftTimestampKey(forKey: key))
}
/// Debounced draft save. The view layer hooks this off
/// `.onChange(of: controller.draft)` so per-keystroke writes are
/// coalesced into one UserDefaults flush per ~1s of typing.
func scheduleDraftSave() {
pendingDraftSave?.cancel()
pendingDraftSave = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled else { return }
self?.saveDraft()
}
}
/// One-shot janitor invoked at app launch. Removes draft slots
/// whose timestamp sidecar predates `draftMaxAge`. Cheap enough
/// to call synchronously UserDefaults is in-memory at runtime.
static func pruneStaleDrafts(now: Date = Date()) {
let defaults = UserDefaults.standard
let cutoff = now.timeIntervalSince1970 - draftMaxAge
for key in defaults.dictionaryRepresentation().keys
where key.hasPrefix(draftKeyPrefix) && key.hasSuffix(".ts")
{
guard let ts = defaults.object(forKey: key) as? TimeInterval, ts < cutoff else { continue }
let baseKey = String(key.dropLast(3)) // strip ".ts"
defaults.removeObject(forKey: baseKey)
defaults.removeObject(forKey: key)
}
}
init(context: ServerContext) {
self.context = context
self.vm = RichChatViewModel(context: context)
}
/// Pre-flight: returns true when `config.yaml` has both
/// `model.default` and `model.provider`. Returns false and stashes
/// the start intent so the preflight sheet can replay it after the
/// user picks a model. Reads via `context.readText` (transport-
/// aware) and parses with the ScarfCore YAML parser same path
/// `IOSSettingsViewModel.load` uses, just synchronous because the
/// preflight runs before any `state = .connecting` UI transition.
private func passModelPreflight(intent: PendingStart) -> Bool {
let raw = context.readText(context.paths.configYAML) ?? ""
let config = HermesConfig(yaml: raw)
let result = ModelPreflight.check(config)
if result.isConfigured { return true }
pendingStartIntent = intent
modelPreflightReason = result.reason
return false
}
/// User confirmed model + provider in the preflight sheet. Persist
/// to `config.yaml` via `hermes config set` (transport-aware runs
/// over SSH on the active server) and replay the original start
/// intent. iOS picker is a free-form text input today (matches the
/// Mac overlay-provider field for `nous`), so trust the user's
/// input Hermes will surface a runtime error if the model isn't
/// valid for the provider.
func confirmModelPreflight(model: String, provider: String) {
let intent = pendingStartIntent
modelPreflightReason = nil
pendingStartIntent = nil
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
guard !trimmedProvider.isEmpty else { return }
let ctx = context
Task.detached { [weak self] in
// Same PATH-prefix trick `IOSSettingsViewModel.saveValue`
// uses so non-interactive shells find `hermes` even when
// it's in ~/.local/bin / /opt/homebrew/bin.
let hermes = ctx.paths.hermesBinary
let providerScript = """
PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH" \
\(hermes) config set 'model.provider' '\(Self.escapeShellArg(trimmedProvider))'
"""
let providerOK = (try? ctx.makeTransport().runProcess(
executable: "/bin/sh",
args: ["-c", providerScript],
stdin: nil,
timeout: 15
))?.exitCode == 0
var modelOK = true
if providerOK, !trimmedModel.isEmpty {
let modelScript = """
PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH" \
\(hermes) config set 'model.default' '\(Self.escapeShellArg(trimmedModel))'
"""
modelOK = (try? ctx.makeTransport().runProcess(
executable: "/bin/sh",
args: ["-c", modelScript],
stdin: nil,
timeout: 15
))?.exitCode == 0
}
await MainActor.run { [weak self] in
guard let self else { return }
if providerOK, modelOK, let intent {
Task { @MainActor in
switch intent {
case .fresh:
await self.start()
case .project(let path, let name):
await self.start(projectPath: path, projectName: name)
case .resume(let id):
await self.startResuming(sessionID: id)
}
}
} else if !(providerOK && modelOK) {
self.state = .failed("Couldn't save model+provider to config.yaml.")
}
}
}
}
/// Single-quote escape a shell argument. Handles embedded single
/// quotes via the standard `'"'"'` trick. Mirrors the helper on
/// `IOSSettingsViewModel`. `nonisolated static` so the
/// `Task.detached` body can call it without a `self` capture and
/// without hopping back to the MainActor.
nonisolated private static func escapeShellArg(_ s: String) -> String {
s.replacingOccurrences(of: "'", with: "'\"'\"'")
}
func cancelModelPreflight() {
modelPreflightReason = nil
pendingStartIntent = nil
}
/// Open the SSH exec channel, send ACP `initialize`, then
/// `session/new` so that by the time `state == .ready` the user
/// can type and hit send immediately.
func start() async {
if state == .connecting || state == .ready { return }
guard passModelPreflight(intent: .fresh) else { return }
state = .connecting
vm.reset()
let client = ACPClient.forIOSApp(
context: context,
keyProvider: {
let store = KeychainSSHKeyStore()
guard let key = try await store.load() else {
throw SSHKeyStoreError.backendFailure(
message: "No SSH key in Keychain — re-run onboarding.",
osStatus: nil
)
}
return key
}
)
self.client = client
// Hand the VM a closure that can fetch the ACPClient's recent
// stderr when it needs to enrich the error banner on a non-
// retryable `promptComplete` (pass-1 M7 #2). The VM caches
// this; we only need to set it once per client.
vm.acpStderrProvider = { [weak client] in
await client?.recentStderr ?? ""
}
do {
try await client.start()
} catch {
state = .failed(error.localizedDescription)
await vm.recordACPFailure(error, client: client)
return
}
// Start streaming ACP events into the view-model BEFORE we
// send session/new, so the `available_commands_update`
// notification that the server sends on session init is
// captured. Health monitor catches socket-level death the
// event-stream EOF wouldn't see (e.g., a hung remote read).
startACPEventLoop(client: client)
startHealthMonitor(client: client)
// Create a fresh ACP session. `cwd` is the remote user's home
// directory Hermes defaults to that for tool scoping.
do {
let home = await context.resolvedUserHome()
let sessionId = try await client.newSession(cwd: home)
vm.setSessionId(sessionId)
loadDraft()
state = .ready
lastActiveSessionID = sessionId
lastProjectPath = nil
} catch {
state = .failed(error.localizedDescription)
await vm.recordACPFailure(error, client: client)
await stop()
}
}
/// Send the current draft as a prompt. Fire-and-forget the
/// assistant reply streams back as ACP notifications handled by
/// the event task.
func send() async {
guard state == .ready, let client else { return }
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
// v0.12+ allows image-only sends vision models accept "describe
// this" with no text. Bail only when both fields are empty.
guard !text.isEmpty || !attachments.isEmpty else { return }
let sessionId = vm.sessionId ?? ""
guard !sessionId.isEmpty else { return }
let images = attachments
attachments = []
draft = ""
clearStoredDraft()
if !text.isEmpty {
vm.addUserMessage(text: text)
} else {
// Surface an image-only message so the user sees their bubble
// even when they didn't type any caption.
vm.addUserMessage(text: "[image attached]")
}
// /steer is non-interruptive the agent is still on its
// current turn; the guidance applies after the next tool call.
// Surface a transient toast confirming the guidance was
// received. v2.5 / Hermes v2026.4.23+.
if vm.isNonInterruptiveSlash(text) {
vm.transientHint = "Guidance queued — applies after the next tool call."
Task { @MainActor [weak vm] in
try? await Task.sleep(nanoseconds: 4_000_000_000)
if vm?.transientHint == "Guidance queued — applies after the next tool call." {
vm?.transientHint = nil
}
}
}
// Project-scoped slash commands expand client-side: the user
// bubble shows the literal `/<name> args` they typed (above);
// Hermes receives the expanded prompt template body. Other
// command sources (ACP, quick_commands) keep going to Hermes
// literally. v2.5.
let wireText = expandIfProjectScoped(text)
do {
_ = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
} catch {
// The event task may already have surfaced a
// .connectionLost; show the send-time error only if the
// state didn't already fail. Always populate the error
// banner so the user sees actionable detail regardless
// of which path raised first (M7 #2).
await vm.recordACPFailure(error, client: client)
if case .ready = state {
state = .failed("Prompt failed: \(error.localizedDescription)")
}
}
}
/// Mirror of `ChatViewModel.expandIfProjectScoped(_:)` on Mac.
/// `/<name> args` matching a loaded project-scoped command is
/// expanded; everything else is sent literally.
private func expandIfProjectScoped(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("/") else { return text }
let withoutSlash = String(trimmed.dropFirst())
let name: String
let argument: String
if let space = withoutSlash.firstIndex(of: " ") {
name = String(withoutSlash[..<space])
argument = String(withoutSlash[withoutSlash.index(after: space)...])
} else {
name = withoutSlash
argument = ""
}
guard !name.isEmpty,
let cmd = vm.projectScopedCommand(named: name)
else { return text }
return ProjectSlashCommandService(context: context).expand(cmd, withArgument: argument)
}
/// Stop the current session + tear down the SSH exec channel.
/// Idempotent.
func stop() async {
eventTask?.cancel(); eventTask = nil
healthMonitorTask?.cancel(); healthMonitorTask = nil
reconnectTask?.cancel(); reconnectTask = nil
if let client {
await client.stop()
}
client = nil
state = .idle
// Explicit user-initiated disconnect clear the session
// memory so reachability/scenePhase events don't try to
// resurrect the dead chat.
lastActiveSessionID = nil
lastProjectPath = nil
isHandlingDisconnect = false
}
// MARK: - Reconnect machinery (Section 1)
/// Stream ACP events into the view-model. When the stream ends
/// without us cancelling it, the channel died; route into the
/// reconnect path. Direct port of Mac's `startACPEventLoop`
/// (scarf/Features/Chat/ViewModels/ChatViewModel.swift:563).
private func startACPEventLoop(client: ACPClient) {
eventTask = Task { @MainActor [weak self] in
let stream = await client.events
for await event in stream {
guard !Task.isCancelled else { break }
self?.vm.handleACPEvent(event)
}
// Stream ended if we weren't explicitly cancelled the
// channel died (EOF on stdin/out, write to dead pipe,
// SSH socket gone). The Mac caller calls
// `handleConnectionDied`; we mirror that.
if !Task.isCancelled {
self?.handleConnectionDied()
}
}
}
/// 5-second heartbeat that catches dead channels which don't
/// explicitly EOF the stream (e.g., a hung SSH socket waiting
/// for the next chunk that never arrives). When `isHealthy`
/// returns false, route into the reconnect path. Mirrors Mac's
/// `startHealthMonitor`.
private func startHealthMonitor(client: ACPClient) {
healthMonitorTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
guard !Task.isCancelled else { break }
let healthy = await client.isHealthy
if !healthy {
self?.handleConnectionDied()
break
}
}
}
}
/// One-stop cleanup + reconnect dispatch. Idempotent guarded by
/// `isHandlingDisconnect` so concurrent triggers (event-stream
/// EOF + health monitor + write failure) don't tear down the same
/// client twice.
private func handleConnectionDied() {
guard client != nil, !isHandlingDisconnect else { return }
isHandlingDisconnect = true
Self.logger.warning("ACP connection died")
// Capture any in-progress streaming text into a finalized
// message before we attempt to merge against the DB. The VM
// doesn't add a system "Connection lost" bubble that would
// create a phantom message during reconnect.
vm.finalizeOnDisconnect()
let savedSessionId = vm.sessionId
// Tear down the dead client. The eventTask will be cancelled
// immediately; awaiting `stop()` on the dead client is the
// detached fire-and-forget pattern Mac uses (its `Task` block).
eventTask?.cancel(); eventTask = nil
healthMonitorTask?.cancel(); healthMonitorTask = nil
if let dead = client { Task { await dead.stop() } }
client = nil
guard let savedSessionId else {
// No session id to resume surface the failure.
state = .failed("Connection lost")
isHandlingDisconnect = false
return
}
attemptReconnect(sessionId: savedSessionId)
}
/// React to an iOS scene-phase transition.
///
/// `.background`: cancel the keepalive iOS will suspend the
/// socket within ~30s anyway, and fighting it via background
/// tasks costs battery for marginal benefit (the agent's work is
/// persisted to state.db on the remote, so we recover on resume).
///
/// `.active`: if we had a session running before suspension and
/// the channel is now unhealthy, route into the reconnect path
/// so the user sees fresh state without having to tap anything.
func handleScenePhase(_ phase: ScenePhase) async {
switch phase {
case .background:
healthMonitorTask?.cancel(); healthMonitorTask = nil
case .active:
// No session worth verifying.
guard let id = lastActiveSessionID else { return }
// Already mid-recovery let it finish.
if case .reconnecting = state { return }
await verifyAndResume(sessionId: id)
case .inactive:
break // brief: control center, banners, split-screen
@unknown default:
break
}
}
/// Probe the existing client's health on resume. If alive,
/// just re-arm the heartbeat; if dead, route into the reconnect
/// path (which preserves the session id and reconciles against
/// the DB).
private func verifyAndResume(sessionId: String) async {
if let client {
if await client.isHealthy {
startHealthMonitor(client: client)
return
}
}
handleConnectionDied()
}
/// React to a transition in `NetworkReachabilityService`. While
/// the device has no network, suppress reconnect attempts (they'd
/// just burn the 5-attempt budget against guaranteed failures);
/// when the network comes back, kick a fresh cycle if we're
/// stuck in `.failed` / `.offline` with a saved session id.
func handleReachabilityChange() async {
let satisfied = NetworkReachabilityService.shared.isSatisfied
if !satisfied {
// Stop the in-flight reconnect cycle every attempt
// will fail until the link is back. We'll restart on
// the next `.satisfied` edge.
reconnectTask?.cancel(); reconnectTask = nil
if case .reconnecting = state {
state = .offline(reason: "No network")
}
return
}
// Network back. If we have a session worth restoring AND
// we're currently in a non-recoverable state, kick a fresh
// reconnect cycle.
guard let id = lastActiveSessionID else { return }
switch state {
case .offline, .failed:
attemptReconnect(sessionId: id)
default:
break
}
}
/// 5-attempt exponential-backoff reconnect targeting the same
/// session id. Tries `session/resume` first (correct semantics
/// for live recovery), falls back to `session/load` for older
/// remotes. NEVER `session/new` that would lose the agent's
/// in-context conversation. After a successful reattach, calls
/// `vm.reconcileWithDB` so messages the agent wrote during the
/// outage become visible.
private func attemptReconnect(sessionId: String) {
reconnectTask?.cancel()
reconnectTask = Task { @MainActor [weak self] in
guard let self else { return }
for attempt in 1...Self.maxReconnectAttempts {
guard !Task.isCancelled else { return }
state = .reconnecting(attempt: attempt, of: Self.maxReconnectAttempts)
// Skip backoff on the first attempt so a quick
// recovery (e.g., a momentary SSH socket flap) feels
// instant. Subsequent attempts back off 124816s.
if attempt > 1 {
let delay = min(
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
Self.maxReconnectDelay
)
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
}
let client = ACPClient.forIOSApp(
context: context,
keyProvider: {
let store = KeychainSSHKeyStore()
guard let key = try await store.load() else {
throw SSHKeyStoreError.backendFailure(
message: "No SSH key in Keychain — re-run onboarding.",
osStatus: nil
)
}
return key
}
)
do {
try await client.start()
// Project-scoped sessions reconnect with their
// project path as cwd; everything else uses the
// remote user's home directory.
let cwd: String
if let path = lastProjectPath {
cwd = path
} else {
cwd = await context.resolvedUserHome()
}
let resolvedSessionId: String
do {
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
} catch {
Self.logger.info(
"session/resume failed, trying session/load: \(error.localizedDescription, privacy: .public)"
)
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
}
// Wire up the new client BEFORE merging messages
// so any streaming chunks that arrive during the
// reconcile land in the right place.
self.client = client
vm.acpStderrProvider = { [weak client] in
await client?.recentStderr ?? ""
}
vm.setSessionId(resolvedSessionId)
// Merge in-memory state (any local-only user
// messages typed before the disconnect) with
// whatever Hermes has persisted to state.db
// since we last looked. This is what makes the
// "agent kept working while you were locked"
// case visible to the user.
let countBefore = vm.messages.count
await vm.reconcileWithDB(sessionId: resolvedSessionId)
let added = vm.messages.count - countBefore
if added > 0 {
vm.transientHint = "Resynced \(added) new message\(added == 1 ? "" : "s")."
Task { @MainActor [weak vm] in
try? await Task.sleep(nanoseconds: 4_000_000_000)
if vm?.transientHint?.hasPrefix("Resynced") == true {
vm?.transientHint = nil
}
}
}
startACPEventLoop(client: client)
startHealthMonitor(client: client)
state = .ready
lastActiveSessionID = resolvedSessionId
isHandlingDisconnect = false
Self.logger.info("Reconnected on attempt \(attempt)")
return
} catch {
Self.logger.warning(
"Reconnect attempt \(attempt) failed: \(error.localizedDescription, privacy: .public)"
)
await client.stop()
continue
}
}
// Exhausted all attempts. Surface a manual-recovery prompt.
guard !Task.isCancelled else { return }
state = .failed("Connection lost")
isHandlingDisconnect = false
}
}
/// User tapped "New chat". Stop, reset the VM, start again.
func resetAndStartNewSession() async {
await stop()
vm.reset()
currentProjectName = nil
currentGitBranch = nil
// Quick-chat sessions don't have a project; clear any leftover
// project-scoped slash commands from a prior session.
vm.loadProjectScopedCommands(at: nil)
await start()
}
/// User tapped "In project <project>". Stop, reset, and start
/// with the project's path as cwd. Writes the Scarf-managed
/// AGENTS.md block via ProjectContextBlock BEFORE spawning `hermes
/// acp`, so Hermes sees the project context at boot. Records the
/// returned session id in the attribution sidecar.
func resetAndStartInProject(_ project: ProjectEntry) async {
await stop()
vm.reset()
currentProjectName = project.name
currentGitBranch = nil
// Pull any project-authored slash commands at
// <project.path>/.scarf/slash-commands/ into the chat menu.
// Async + non-fatal degrades cleanly on SFTP failures (logged).
vm.loadProjectScopedCommands(at: project.path)
// v2.5 git branch indicator. Async + nil on failure the chip
// simply doesn't render if the project isn't a git repo.
let ctx = context
let projectPath = project.path
Task { @MainActor [weak self] in
let branch = await GitBranchService(context: ctx).branch(at: projectPath)
if self?.currentProjectName == project.name {
self?.currentGitBranch = branch
}
}
// Synchronously load the slash command NAMES so we can list them
// in the AGENTS.md block (the agent needs to know what commands
// are available). This is a separate read from the async one
// above because the block has to land on disk BEFORE `hermes acp`
// boots async loads might lose the race. Blocking load on a
// detached task to keep the MainActor responsive.
let slashNames: [String] = await Task.detached {
ProjectSlashCommandService(context: ctx)
.loadCommands(at: projectPath)
.map(\.name)
}.value
// Write the context block first. Non-fatal on failure chat
// still starts, just without the managed block. We capture the
// failure (rather than swallowing via `try?`) so the user gets
// a yellow banner explaining the agent won't see project context
// for this session, with the underlying error in "Show details".
let block = ProjectContextBlock.renderMinimalBlock(
projectName: project.name,
projectPath: project.path,
slashCommandNames: slashNames
)
let writeResult: Result<Void, Error> = await Task.detached {
do {
try ProjectContextBlock.writeBlock(
block,
forProjectAt: projectPath,
context: ctx
)
return .success(())
} catch {
return .failure(error)
}
}.value
if case .failure(let error) = writeResult {
Self.logger.error(
"ProjectContextBlock.writeBlock failed for \(projectPath, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
vm.acpError = "Project context not written — agent will proceed without it."
vm.acpErrorHint = "Check that the SSH user can write to \(projectPath)/AGENTS.md."
vm.acpErrorDetails = error.localizedDescription
}
await start(projectPath: project.path, projectName: project.name)
}
/// Inline variant of `start()` that accepts a cwd + attribution
/// hooks. The default `start()` delegates to this with nil project
/// fields, so the ACP code path stays single-sourced.
private func startInternal(
projectPath: String?,
projectName: String?
) async {
if state == .connecting || state == .ready { return }
let intent: PendingStart
if let projectPath, let projectName {
intent = .project(path: projectPath, name: projectName)
} else {
intent = .fresh
}
guard passModelPreflight(intent: intent) else { return }
state = .connecting
let client = ACPClient.forIOSApp(
context: context,
keyProvider: {
let store = KeychainSSHKeyStore()
guard let key = try await store.load() else {
throw SSHKeyStoreError.backendFailure(
message: "No SSH key in Keychain — re-run onboarding.",
osStatus: nil
)
}
return key
}
)
self.client = client
vm.acpStderrProvider = { [weak client] in
await client?.recentStderr ?? ""
}
do {
try await client.start()
} catch {
state = .failed(error.localizedDescription)
await vm.recordACPFailure(error, client: client)
return
}
startACPEventLoop(client: client)
startHealthMonitor(client: client)
do {
// Use the project's path as cwd when provided; else the
// remote user's home, matching the pre-M9 default.
let cwd: String
if let projectPath {
cwd = projectPath
} else {
cwd = await context.resolvedUserHome()
}
let sessionId = try await client.newSession(cwd: cwd)
vm.setSessionId(sessionId)
loadDraft()
state = .ready
lastActiveSessionID = sessionId
lastProjectPath = projectPath
// If this was a project-scoped session, record the
// attribution so Dashboard's Sessions tab can render the
// project badge for it. Best-effort and intentionally fire-
// and-forget `SessionAttributionService.persist` already
// logs SFTP failures via `os.Logger` (see the
// `Self.logger.error` in `persist`), and a failed write
// here is purely cosmetic: the chat works, only the badge
// is missing until the next reconcile. We deliberately
// don't surface this to the chat banner because it would
// alarm users about a non-issue.
if let projectPath {
let ctx = context
Task.detached {
SessionAttributionService(context: ctx)
.attribute(sessionID: sessionId, toProjectPath: projectPath)
}
}
_ = projectName // reserved for future chat-header chip
} catch {
state = .failed(error.localizedDescription)
await vm.recordACPFailure(error, client: client)
await stop()
}
}
/// Public entry used internally by resetAndStartInProject.
func start(projectPath: String, projectName: String) async {
await startInternal(projectPath: projectPath, projectName: projectName)
}
/// Resume an existing ACP session. Called from ChatView when the
/// coordinator carries a `pendingResumeSessionID` (Dashboard row
/// tap). If we're currently on a different session, stop first
/// so there's no phantom ACP process hanging around. Falls back
/// to `session/load` if the remote doesn't support `session/resume`
/// (Hermes < 0.9.x).
func startResuming(sessionID: String) async {
guard passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
await stop()
vm.reset()
// Clear eagerly so a lingering project name from a prior
// session doesn't flash onto the new header while the
// attribution lookup runs.
currentProjectName = nil
// Resolve the project name for this session (if any) via the
// attribution sidecar + project registry. Set BEFORE the ACP
// handshake so the nav-bar subtitle is visible the moment the
// "Connecting" overlay disappears. Run off-thread so we
// don't block while the SFTP reads happen. Empty-string names
// are treated as nil registry entries should never have
// empty names in practice, but guard against a surprise
// JSON-decode edge case that would render just a folder icon
// with no text (pass-2 bug: user saw exactly that).
let ctx = context
// Resolve both the path AND the name so we can (a) render the
// header chip with the name and (b) load any project-scoped
// slash commands at the project's `.scarf/slash-commands/` dir.
let resolved: (path: String, name: String)? = await Task.detached {
let attribution = SessionAttributionService(context: ctx)
guard let path = attribution.projectPath(for: sessionID) else { return nil }
let registry = ProjectDashboardService(context: ctx).loadRegistry()
guard let name = registry.projects.first(where: { $0.path == path })?.name,
!name.isEmpty
else { return nil }
return (path: path, name: name)
}.value
currentProjectName = resolved?.name
currentGitBranch = nil
vm.loadProjectScopedCommands(at: resolved?.path)
// v2.5 git branch indicator for the resumed-session header.
if let resumePath = resolved?.path {
let resolvedName = resolved?.name
Task { @MainActor [weak self] in
let branch = await GitBranchService(context: ctx).branch(at: resumePath)
// Guard against a project switch landing while we
// were resolving only set if the chat hasn't moved.
if self?.currentProjectName == resolvedName {
self?.currentGitBranch = branch
}
}
}
state = .connecting
let client = ACPClient.forIOSApp(
context: context,
keyProvider: {
let store = KeychainSSHKeyStore()
guard let key = try await store.load() else {
throw SSHKeyStoreError.backendFailure(
message: "No SSH key in Keychain — re-run onboarding.",
osStatus: nil
)
}
return key
}
)
self.client = client
vm.acpStderrProvider = { [weak client] in
await client?.recentStderr ?? ""
}
do {
try await client.start()
} catch {
state = .failed(error.localizedDescription)
await vm.recordACPFailure(error, client: client)
return
}
startACPEventLoop(client: client)
startHealthMonitor(client: client)
do {
let home = await context.resolvedUserHome()
// Prefer `session/resume` for true resume semantics
// (same session id preserved in state.db); fall back to
// `session/load` if the remote doesn't know resume.
let resolvedID: String
do {
resolvedID = try await client.resumeSession(cwd: home, sessionId: sessionID)
} catch {
resolvedID = try await client.loadSession(cwd: home, sessionId: sessionID)
}
vm.setSessionId(resolvedID)
loadDraft()
// Pull the transcript out of state.db so the user sees
// everything said up to now. Mirrors the Mac resume flow
// (scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift:376).
// `loadSessionHistory` refreshes the SQLite snapshot first
// so we pick up messages Hermes wrote between the
// Dashboard's last load and now.
await vm.loadSessionHistory(
sessionId: sessionID,
acpSessionId: resolvedID == sessionID ? nil : resolvedID
)
state = .ready
lastActiveSessionID = resolvedID
lastProjectPath = resolved?.path
} catch {
state = .failed(error.localizedDescription)
await vm.recordACPFailure(error, client: client)
await stop()
}
}
/// Dispatch the user's answer to a pending permission request.
/// Called by `PermissionSheet`.
func respondToPermission(requestId: Int, optionId: String) async {
guard let client else { return }
await client.respondToPermission(requestId: requestId, optionId: optionId)
vm.pendingPermission = nil
}
}
/// `Identifiable` wrapper so SwiftUI's `.sheet(item:)` can key off
/// the pending permission. Two permissions for the same request-id
/// are treated as identical (rare would only happen if the remote
/// sends a duplicate).
private struct PermissionWrapper: Identifiable {
let value: RichChatViewModel.PendingPermission
var id: Int { value.requestId }
}
// MARK: - Message bubble
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
/// bubble when present. Nil for user / streaming / pre-v2.5
/// 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)
} else {
HStack(alignment: .bottom) {
if message.isUser { Spacer(minLength: 40) }
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
// v2.5: prefer reasoning_content (Hermes v0.11+);
// fall back to legacy reasoning when only it's set.
if message.hasReasoning, let r = message.preferredReasoning, !r.isEmpty {
ReasoningDisclosure(reasoning: r)
}
// Only render the bubble when there's actual text
// to show. Assistant messages can exist in a
// "reasoning-only" or "tool-calls-only" state
// while the agent is thinking / invoking tools
// rendering an empty gray bubble next to every
// "Thinking" disclosure looked like a ghost
// message. User bubbles we always render (the
// user explicitly submitted content, even if
// it's just whitespace, they saw it land).
if message.isUser || !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
bubbleContent
}
if !message.toolCalls.isEmpty {
VStack(alignment: .leading, spacing: 6) {
ForEach(message.toolCalls) { call in
ToolCallCard(call: call)
}
}
}
// Per-turn stopwatch assistant only, when the
// turn duration was captured (live ACP turns).
if !message.isUser, let seconds = turnDuration {
Text(RichChatViewModel.formatTurnDuration(seconds))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if !message.isUser { Spacer(minLength: 40) }
}
.padding(.horizontal)
}
}
@ViewBuilder
private var bubbleContent: some View {
// User bubbles are plain text no reason to parse what the
// user just typed. Assistant bubbles route through the
// ChatContentFormatter so fenced code blocks get horizontal
// scrolling instead of soft-wrapping into ugly 4-line
// vertical columns on an iPhone.
if message.isUser {
Text(message.content)
.font(.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.foregroundStyle(ScarfColor.onAccent)
.background(
UnevenRoundedRectangle(cornerRadii:
.init(topLeading: 14, bottomLeading: 14, bottomTrailing: 4, topTrailing: 14))
.fill(ScarfColor.accent)
)
.textSelection(.enabled)
.contextMenu { messageContextMenu }
} else {
HStack(alignment: .top, spacing: 8) {
// Assistant avatar rust gradient sparkles tile,
// matches the Mac side and the ScarfChatView reference.
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(ScarfGradient.brand)
.frame(width: 24, height: 24)
.overlay(
Image(systemName: "sparkles")
.foregroundStyle(.white)
.font(.system(size: 10, weight: .semibold))
)
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(ChatContentFormatter.segments(for: message.content).enumerated()), id: \.offset) { _, segment in
switch segment {
case .text(let body):
Self.markdownText(body)
.font(.body)
.textSelection(.enabled)
case .code(let lang, let body):
CodeBlockView(language: lang, body: body)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.foregroundStyle(ScarfColor.foregroundPrimary)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
.fill(ScarfColor.backgroundSecondary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
.contextMenu { messageContextMenu }
}
}
}
/// Shared context-menu actions for user + assistant bubbles.
/// Copy is the most-used action; Share hands off to the system
/// share sheet via ShareLink. Regenerate is intentionally absent
/// ACP doesn't support it natively and the pattern would require
/// non-trivial session-state surgery.
@ViewBuilder
private var messageContextMenu: some View {
Button {
UIPasteboard.general.string = message.content
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
ShareLink(item: message.content) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
/// Parses message text as markdown for the assistant side. Text-
/// only segments coming from ChatContentFormatter can contain
/// inline backticks / bold / links; `.inlineOnlyPreservingWhitespace`
/// preserves newlines + spacing and won't mangle the output if
/// the input isn't valid markdown.
private static func markdownText(_ body: String) -> Text {
if let attributed = try? AttributedString(
markdown: body,
options: AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
) {
return Text(attributed)
}
return Text(body)
}
}
/// Horizontally-scrollable fenced code block. ~240pt max height
/// collapsed (Expand button reveals full height). Monospaced
/// .footnote font keeps the bubble narrow enough to still show
/// adjacent text on the same screen. Language label is a tiny
/// header when present.
private struct CodeBlockView: View {
let language: String?
let code: String
@State private var expanded = false
private let collapsedMaxHeight: CGFloat = 240
init(language: String?, body: String) {
self.language = language
self.code = body
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
if let lang = language, !lang.isEmpty {
Text(lang.uppercased())
.font(.caption2.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted)
}
Spacer()
Button(expanded ? "Collapse" : "Expand") {
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
}
.font(.caption2)
.buttonStyle(.plain)
.foregroundStyle(.tint)
Button {
UIPasteboard.general.string = code
} label: {
Image(systemName: "doc.on.doc")
.font(.caption2)
}
.buttonStyle(.plain)
.foregroundStyle(ScarfColor.foregroundMuted)
}
ScrollView(.horizontal, showsIndicators: true) {
Text(code)
.font(.footnote.monospaced())
.textSelection(.enabled)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.fixedSize(horizontal: true, vertical: false)
}
.frame(maxHeight: expanded ? nil : collapsedMaxHeight)
.background(Color(.tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
/// Inline, expandable "chain-of-thought" disclosure shown above the
/// assistant's primary message when the remote surfaces `reasoning`.
/// Collapsed by default so a chatty model doesn't dominate the scroll
/// position.
private struct ReasoningDisclosure: View {
let reasoning: String
@State private var isExpanded = false
var body: some View {
DisclosureGroup(isExpanded: $isExpanded) {
Text(reasoning)
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.italic()
.textSelection(.enabled)
.padding(.top, 4)
} label: {
HStack(spacing: 5) {
Image(systemName: "brain")
.font(.caption)
Text("REASONING")
.font(.caption2)
.fontWeight(.semibold)
.tracking(0.5)
}
.foregroundStyle(ScarfColor.warning)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(ScarfColor.warning.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1)
)
)
}
}
/// Expanding card for a single `HermesToolCall` kind-tinted with
/// uppercase tracked label, matches the Mac ToolCallCard treatment.
private struct ToolCallCard: View {
let call: HermesToolCall
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button {
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
} label: {
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: call.toolKind.icon)
.foregroundStyle(toolColor)
.font(.caption2)
Text(toolLabel)
.font(.caption2)
.fontWeight(.semibold)
.tracking(0.4)
.foregroundStyle(toolColor)
}
Text(call.functionName)
.font(.caption.monospaced())
.fontWeight(.semibold)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(call.argumentsSummary.prefix(60))
.font(.caption.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 4)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(ScarfColor.foregroundFaint)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(toolColor.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(toolColor.opacity(0.30), lineWidth: 1)
)
)
}
.buttonStyle(.plain)
if isExpanded {
Text(call.arguments)
.font(.caption2.monospaced())
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(ScarfColor.backgroundSecondary)
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
)
.padding(.leading, 4)
}
}
}
private var toolLabel: String {
switch call.toolKind {
case .read: return "READ"
case .edit: return "EDIT"
case .execute: return "EXECUTE"
case .fetch: return "FETCH"
case .browser: return "BROWSER"
case .other: return "TOOL"
}
}
private var toolColor: Color {
switch call.toolKind {
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
}
}
}
/// Row showing a tool-result (role="tool"). Styled as a small
/// quoted block beneath whichever assistant message preceded it.
private struct ToolResultRow: View {
let message: HermesMessage
@State private var isExpanded = false
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Button {
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.turn.down.right")
.font(.caption2)
.foregroundStyle(ScarfColor.foregroundMuted)
Text("Tool output")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
Text(message.content.prefix(80))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
if isExpanded {
Text(message.content)
.font(.caption2.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted)
.textSelection(.enabled)
.padding(.top, 2)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(.tertiarySystemBackground))
)
Spacer(minLength: 40)
}
.padding(.horizontal)
}
}
// MARK: - Permission sheet
/// Sheet presented when the remote asks for permission (e.g.,
/// "allow write to /etc/hosts"). Renders the VM's `PendingPermission`
/// options as tappable buttons. Tapping responds via the ChatController
/// which dispatches the answer over the ACP channel.
private struct PermissionSheet: View {
let permission: RichChatViewModel.PendingPermission
let onRespond: (_ optionId: String) async -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(permission.title)
.font(.headline)
.textSelection(.enabled)
Text("Kind: \(permission.kind)")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.padding(.vertical, 4)
}
Section("Your response") {
// Visual numbering 1-9 matches the Mac sheet's
// keyboard shortcuts; on iPhone the numbers serve
// as a hierarchy hint rather than an accelerator
// (no hardware keyboard binding). Mirrors the new
// Hermes v2026.4.23 TUI pattern.
ForEach(Array(permission.options.enumerated()), id: \.element.optionId) { idx, opt in
Button {
Task {
await onRespond(opt.optionId)
dismiss()
}
} label: {
HStack {
if idx < 9 {
Text("\(idx + 1).")
.font(.body.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted)
}
Text(opt.name)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
}
}
.navigationTitle("Agent permission")
.navigationBarTitleDisplayMode(.inline)
}
}
}
/// iOS preflight sheet for the model + provider on a server whose
/// `config.yaml` is missing them. The Mac picker (`ModelPickerSheet`)
/// doesn't ship in the iOS target the catalog UI is Mac-only today
/// so this is a pair of `TextField`s plus a hint pointing at common
/// formats. Confirms via the same `setModelAndProvider` path the Mac
/// preflight uses, so persistence + replay logic stays single-sourced
/// in `ChatController.confirmModelPreflight`.
private struct IOSModelPreflightSheet: View {
let reason: String
let serverDisplayName: String
let onSelect: (_ model: String, _ provider: String) -> Void
let onCancel: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var model: String = ""
@State private var provider: String = ""
var body: some View {
NavigationStack {
Form {
Section {
Text(reasonLine)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("Provider") {
TextField("e.g. anthropic, nous, openai", text: $provider)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section("Model") {
TextField("e.g. claude-sonnet-4.6, hermes-3", text: $model)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Text("Hermes will pass these through verbatim. Leave model blank if you're using Nous Portal — Hermes picks its default.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Pick a model")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
onCancel()
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save & Start") {
let p = provider.trimmingCharacters(in: .whitespaces)
let m = model.trimmingCharacters(in: .whitespaces)
guard !p.isEmpty else { return }
onSelect(m, p)
dismiss()
}
.disabled(provider.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private var reasonLine: String {
let suffix = "Scarf will save these to `config.yaml` on \(serverDisplayName) and start the chat."
guard !reason.isEmpty else { return suffix }
return "\(reason) \(suffix)"
}
}
#endif // canImport(SQLite3)
// Empty shim so the file compiles on platforms without SQLite3 the
// target never runs there, but the typechecker visits the file.
#if !canImport(SQLite3)
struct ChatView: View {
let config: IOSServerConfig
let key: SSHKeyBundle
var body: some View {
Text("Chat requires SQLite3 — this platform is not supported.")
}
}
#endif