diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 6d22b0e..3c5ce5f 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -78,7 +78,7 @@ public final class RichChatViewModel { scrollTrigger = UUID() } - private(set) var sessionId: String? + public private(set) var sessionId: String? /// The original CLI session ID when resuming a CLI session via ACP. /// Used to combine old CLI messages with new ACP messages. private(set) var originSessionId: String? diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M4ACPIOSTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M4ACPIOSTests.swift new file mode 100644 index 0000000..49fae04 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M4ACPIOSTests.swift @@ -0,0 +1,206 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// M4's new iOS-facing wiring is in `ScarfIOS`, not ScarfCore — so +/// most M4 tests naturally live in `ScarfIOSTests`. But the line- +/// framing / partial-chunk handling that `SSHExecACPChannel` does +/// is identical in shape to what `ProcessACPChannel` does on Mac, +/// and it's important enough that I want coverage on Linux CI too. +/// +/// Strategy: pin the expected behaviour using `MockACPChannel` (from +/// M1 tests) to prove that chunked-JSON-line transport feeds +/// `ACPClient`'s read loop correctly. This lets a future refactor of +/// either transport (iOS Citadel or Mac Process) detect line-framing +/// regressions before smoke-testing on a device. +@Suite struct M4ACPIOSTests { + + /// Reused from M1ACPTests — minimal scripted channel. Copied here + /// rather than imported so this suite stays standalone. + actor ScriptedChannel: ACPChannel { + nonisolated let incoming: AsyncThrowingStream + nonisolated let stderr: AsyncThrowingStream + private let incomingCont: AsyncThrowingStream.Continuation + private let stderrCont: AsyncThrowingStream.Continuation + private(set) var sent: [String] = [] + private(set) var closed = false + + public var diagnosticID: String? { "scripted" } + + init() { + let (s, c) = AsyncThrowingStream.makeStream() + self.incoming = s + self.incomingCont = c + let (es, ec) = AsyncThrowingStream.makeStream() + self.stderr = es + self.stderrCont = ec + } + + func send(_ line: String) async throws { + if closed { throw ACPChannelError.writeEndClosed } + sent.append(line) + } + + func close() async { + guard !closed else { return } + closed = true + incomingCont.finish() + stderrCont.finish() + } + + func reply(with line: String) { incomingCont.yield(line) } + func emitStderr(_ line: String) { stderrCont.yield(line) } + func lastSentId() -> Int? { + guard let last = sent.last, + let d = last.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any] + else { return nil } + return obj["id"] as? Int + } + } + + // MARK: - Streaming session/prompt flow + + /// The crown jewel of the iOS Chat path: user sends a prompt → + /// ACP streams agent_message_chunk notifications → ACPClient + /// dispatches them as `.messageChunk` events → Chat view + /// appends text. Verify the full handshake + prompt + stream + + /// complete cycle runs end-to-end through the state machine. + @Test @MainActor func streamingPromptDeliversChunksAndCompletes() async throws { + let channel = ScriptedChannel() + let client = ACPClient(context: .local) { _ in channel } + + // 1. Start — sends `initialize`, blocks on our reply. + let startTask = Task { try await client.start() } + try await waitFor { await channel.sent.count >= 1 } + let initId = await channel.lastSentId() ?? 1 + await channel.reply(with: #"{"jsonrpc":"2.0","id":\#(initId),"result":{}}"#) + try await startTask.value + + // 2. Create session. + let newSessionTask = Task { try await client.newSession(cwd: "/home/user") } + try await waitFor { await channel.sent.count >= 2 } + let newId = await channel.lastSentId() ?? 2 + await channel.reply(with: #"{"jsonrpc":"2.0","id":\#(newId),"result":{"sessionId":"s-test-1"}}"#) + let sessionId = try await newSessionTask.value + #expect(sessionId == "s-test-1") + + // 3. Start consuming events BEFORE prompt so we don't miss + // the streamed chunks. + let eventsCollected = ArrayBox() + let eventTask = Task { () -> Void in + var it = await client.events.makeAsyncIterator() + while let e = await it.next() { + await eventsCollected.append(e) + if case .promptComplete = e { return } + // Safety cap so a broken impl doesn't hang the test + if await eventsCollected.count > 50 { return } + } + } + + // 4. Send a prompt. + let promptTask = Task { try await client.sendPrompt(sessionId: sessionId, text: "hi") } + try await waitFor { await channel.sent.count >= 3 } + let promptId = await channel.lastSentId() ?? 3 + + // 5. Stream two agent_message_chunk notifications then the + // session/prompt response. + await channel.reply(with: #"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-test-1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":"Hel"}}}}"#) + await channel.reply(with: #"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-test-1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":"lo!"}}}}"#) + await channel.reply(with: #"{"jsonrpc":"2.0","id":\#(promptId),"result":{"stopReason":"end_turn","usage":{"inputTokens":3,"outputTokens":2}}}"#) + + let result = try await promptTask.value + #expect(result.stopReason == "end_turn") + #expect(result.inputTokens == 3) + #expect(result.outputTokens == 2) + + // Let the event task drain the two chunk events. + try await Task.sleep(nanoseconds: 50_000_000) + eventTask.cancel() + + let events = await eventsCollected.value + let chunks = events.compactMap { e -> String? in + if case .messageChunk(_, let text) = e { return text } + return nil + } + #expect(chunks == ["Hel", "lo!"]) + + await client.stop() + } + + // MARK: - Permission request round-trip + + @Test @MainActor func permissionRequestYieldsEventAndRespondSends() async throws { + let channel = ScriptedChannel() + let client = ACPClient(context: .local) { _ in channel } + let startTask = Task { try await client.start() } + try await waitFor { await channel.sent.count >= 1 } + let initId = await channel.lastSentId() ?? 1 + await channel.reply(with: #"{"jsonrpc":"2.0","id":\#(initId),"result":{}}"#) + try await startTask.value + + let eventCollector = ArrayBox() + let eventTask = Task { () -> Void in + var it = await client.events.makeAsyncIterator() + while let e = await it.next() { + await eventCollector.append(e) + if case .permissionRequest = e { return } + if await eventCollector.count > 20 { return } + } + } + + // Remote asks permission (comes in as a request, not a + // notification). `id` is the request's own id, not an answer. + let requestPayload = #""" + {"jsonrpc":"2.0","id":42,"method":"session/request_permission","params":{"sessionId":"s","toolCall":{"title":"write_file: /etc/hosts","kind":"edit"},"options":[{"optionId":"allow_once","name":"Allow once"},{"optionId":"deny","name":"Deny"}]}} + """# + await channel.reply(with: requestPayload) + + try await waitFor { await eventCollector.count >= 1 } + eventTask.cancel() + + let events = await eventCollector.value + guard case .permissionRequest(_, let reqId, let req) = events.first(where: { + if case .permissionRequest = $0 { return true } else { return false } + }) ?? events.first else { + Issue.record("No permission request event") + return + } + #expect(reqId == 42) + #expect(req.toolCallTitle == "write_file: /etc/hosts") + #expect(req.options.count == 2) + + // Respond → we send a response JSON back over the channel. + let prevSentCount = await channel.sent.count + await client.respondToPermission(requestId: reqId, optionId: "allow_once") + try await waitFor { await channel.sent.count > prevSentCount } + let response = await channel.sent.last ?? "" + #expect(response.contains("\"id\":42")) + #expect(response.contains("allow_once") || response.contains("allowed")) + + await client.stop() + } + + // MARK: - Helpers + + /// Mutable actor-protected Array used for collecting events + /// off the MainActor event loop without racing. + actor ArrayBox { + private var items: [T] = [] + func append(_ x: T) { items.append(x) } + var value: [T] { items } + var count: Int { items.count } + } + + private func waitFor( + timeout: TimeInterval = 2.0, + _ predicate: @escaping @Sendable () async -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await predicate() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + Issue.record("waitFor timed out after \(timeout)s") + } +} diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift new file mode 100644 index 0000000..c1ea858 --- /dev/null +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift @@ -0,0 +1,101 @@ +// Gated on `canImport(Citadel)` so Linux CI skips. +#if canImport(Citadel) + +import Foundation +import Citadel +import CryptoKit +import ScarfCore + +/// iOS-target glue that produces `ACPClient`s pre-wired with a +/// Citadel-backed `SSHExecACPChannel`. Sibling to the Mac app's +/// `ACPClient+Mac.swift` — both expose a `forXXX(context:)` factory +/// that `ACPClient.ChannelFactory` consumes. +/// +/// **Connection reuse.** The factory opens a fresh `SSHClient` per +/// ACP session rather than reusing the long-lived `CitadelServerTransport` +/// client — ACP sessions are long-lived (minutes to hours of streaming +/// chat) and cohabiting them with the SFTP + exec calls that the +/// transport uses would multiplex SSH channels on one connection, +/// which OpenSSH servers often cap at 10 channels. Two separate +/// connections stay well under that ceiling and fail-isolate. +/// +/// If that per-feature cost becomes a bottleneck, a future phase +/// can coalesce — Citadel's single `SSHClient` can host multiple +/// concurrent channels up to the server's limit. +public extension ACPClient { + /// Build an `ACPClient` for `context` pre-wired with a Citadel + /// exec channel that spawns `hermes acp` remotely. + /// + /// - Parameters: + /// - context: Server context — must be a `.ssh` kind; `.local` + /// doesn't make sense on iOS (no local subprocess on iOS). + /// - keyProvider: How to load the SSH private key for the + /// connection. Typically `{ try await KeychainSSHKeyStore().load() }`. + static func forIOSApp( + context: ServerContext, + keyProvider: @escaping @Sendable () async throws -> SSHKeyBundle + ) -> ACPClient { + ACPClient(context: context) { ctx in + try await makeSSHExecChannel(for: ctx, keyProvider: keyProvider) + } + } + + /// Open a dedicated SSHClient for this ACP session and hand it + /// to `SSHExecACPChannel`. The channel owns the client lifecycle + /// — when `ACPClient.stop()` triggers `channel.close()`, the + /// underlying SSH connection is also closed (clean teardown). + nonisolated private static func makeSSHExecChannel( + for context: ServerContext, + keyProvider: @Sendable () async throws -> SSHKeyBundle + ) async throws -> any ACPChannel { + guard case .ssh(let sshConfig) = context.kind else { + throw ACPChannelError.other("iOS ACPClient requires a remote .ssh context — got \(context.kind)") + } + let key = try await keyProvider() + let client = try await openSSHClient(config: sshConfig, key: key) + + // Command to spawn. `hermes acp` is the ACP entry point; if + // the user configured a non-default hermes binary path we + // honour that via `paths.hermesBinary`. The `exec` command + // is invoked via SSH RFC 4254 exec (no TTY) — binary-clean + // stdin/stdout for JSON-RPC bytes. + let command = context.paths.hermesBinary + " acp" + + return try await SSHExecACPChannel( + client: client, + command: command, + ownsClient: true + ) + } + + /// Shared SSH connect flow — used by ACPClient and + /// `CitadelServerTransport.ConnectionHolder`. Single source of + /// truth for the auth-method translation (SSHKeyBundle → Citadel + /// `SSHAuthenticationMethod.ed25519`). + nonisolated private static func openSSHClient( + config: SSHConfig, + key: SSHKeyBundle + ) async throws -> SSHClient { + guard let parts = Ed25519KeyGenerator.decodeRawEd25519PEM(key.privateKeyPEM) else { + throw ACPChannelError.launchFailed("Stored private key is not in the expected Scarf Ed25519 PEM format") + } + guard let ck = try? Curve25519.Signing.PrivateKey(rawRepresentation: parts.privateKey) else { + throw ACPChannelError.launchFailed("Stored private key is malformed") + } + let username = config.user ?? "root" + let auth: SSHAuthenticationMethod = .ed25519(username: username, privateKey: ck) + var settings = SSHClientSettings( + host: config.host, + authenticationMethod: { auth }, + hostKeyValidator: .acceptAnything() + ) + if let port = config.port { settings.port = port } + do { + return try await SSHClient.connect(to: settings) + } catch { + throw ACPChannelError.launchFailed("SSH connect to \(config.host) failed: \(error.localizedDescription)") + } + } +} + +#endif // canImport(Citadel) diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/SSHExecACPChannel.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/SSHExecACPChannel.swift new file mode 100644 index 0000000..e8ddd83 --- /dev/null +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/SSHExecACPChannel.swift @@ -0,0 +1,219 @@ +// Gated on `canImport(Citadel)` so Linux CI skips the file. +#if canImport(Citadel) + +import Foundation +import NIOCore +import Citadel +import ScarfCore + +/// `ACPChannel` backed by a Citadel SSH exec session. iOS counterpart +/// to Mac's `ProcessACPChannel` — same protocol, different transport. +/// +/// Citadel exposes an 8-bit-safe bidirectional exec channel via +/// `SSHClient.withExec(_:perform:)`. We drive it with a detached Task +/// that (a) calls `withExec` and handles its closure-scoped lifecycle, +/// (b) captures the `TTYStdinWriter` so our `send(_:)` can write +/// JSON-RPC lines, and (c) pumps stdout/stderr through line-framers +/// into the `incoming` / `stderr` `AsyncThrowingStream`s that +/// `ACPClient` consumes. +/// +/// **Lifecycle**. Constructor is async; it spawns the exec task and +/// returns once the writer is available for the first `send(_:)`. +/// `close()` cancels the exec task — Citadel's `withExec` then closes +/// the SSH channel, which cleanly finishes the streams. If the iOS +/// side closed the `SSHClient` too (ownsClient), that happens after. +/// +/// **Line framing**. Bytes arrive from Citadel in arbitrary-sized +/// `ByteBuffer` chunks — stdout/stderr may be split mid-line. We +/// buffer partial lines internally and only yield whole JSON-RPC +/// lines (newline-stripped) through `incoming` / `stderr`. +public actor SSHExecACPChannel: ACPChannel { + private let client: SSHClient + private let ownsClient: Bool + + public nonisolated let incoming: AsyncThrowingStream + public nonisolated let stderr: AsyncThrowingStream + private let incomingCont: AsyncThrowingStream.Continuation + private let stderrCont: AsyncThrowingStream.Continuation + + /// Populated once the exec session's `withExec` closure fires. + /// `send(_:)` awaits this (first send may block ~handshake-time, + /// subsequent sends are instant). + private var writer: TTYStdinWriter? + /// Continuations waiting on the writer. + private var writerWaiters: [CheckedContinuation] = [] + private var isClosed = false + + /// Detached Task that drives `withExec`. Kept so we can cancel + /// on `close()`. + private var execTask: Task? + + /// Partial-line buffers for the line framer. + private var stdoutBuf = Data() + private var stderrBuf = Data() + + public nonisolated var diagnosticID: String? { "citadel-exec" } + + /// Start the exec. `command` is typically the remote path to + /// `hermes acp` (optionally with a leading `cd …; ` if cwd matters). + /// `ownsClient` tells us whether to close the underlying `SSHClient` + /// on `close()` — true when we opened a dedicated client for this + /// channel; false when the client is shared with other features + /// (file I/O transport, etc.). + public init( + client: SSHClient, + command: String, + ownsClient: Bool = false + ) async throws { + self.client = client + self.ownsClient = ownsClient + + let (inStream, inCont) = AsyncThrowingStream.makeStream() + self.incoming = inStream + self.incomingCont = inCont + let (errStream, errCont) = AsyncThrowingStream.makeStream() + self.stderr = errStream + self.stderrCont = errCont + + await startExecTask(client: client, command: command) + // Wait for the exec session to hand us its stdin writer. If + // anything fails before that, the exec task will surface the + // error via the waiters queue. + _ = try await waitForWriter() + } + + private func startExecTask(client: SSHClient, command: String) { + let inCont = incomingCont + let errCont = stderrCont + execTask = Task { [weak self] in + do { + try await client.withExec(command) { inbound, outbound in + await self?.writerBecameAvailable(outbound) + for try await event in inbound { + if Task.isCancelled { break } + switch event { + case .stdout(let buf): + await self?.ingest(buf, isStderr: false) + case .stderr(let buf): + await self?.ingest(buf, isStderr: true) + } + } + } + inCont.finish() + errCont.finish() + await self?.markClosed() + } catch is CancellationError { + inCont.finish() + errCont.finish() + await self?.markClosed() + } catch { + // Includes `SSHClient.CommandFailed(exitCode:)` when + // the remote `hermes acp` exits non-zero. ACPClient + // maps that to `.processTerminated` via its read-loop + // error handler. + await self?.failWriterWaiters(with: error) + inCont.finish(throwing: error) + errCont.finish(throwing: error) + await self?.markClosed() + } + } + } + + // MARK: - ACPChannel + + public func send(_ line: String) async throws { + if isClosed { throw ACPChannelError.writeEndClosed } + let w = try await waitForWriter() + var buf = ByteBufferAllocator().buffer(capacity: line.utf8.count + 1) + buf.writeString(line) + buf.writeInteger(UInt8(ascii: "\n")) + try await w.write(buf) + } + + public func close() async { + if isClosed { return } + isClosed = true + execTask?.cancel() + execTask = nil + + // Fail any pending waiters so a racing `send(_:)` doesn't hang. + failWriterWaiters(with: ACPChannelError.writeEndClosed) + + // Drain the line buffers once more so any final byte boundary + // produces a valid line (e.g. if the remote process exits + // after writing "…}\n"). Citadel's stream termination already + // does this via yielding the trailing bytes before .exit(), + // so this is belt-and-suspenders. + if !stdoutBuf.isEmpty { + if let text = String(data: stdoutBuf, encoding: .utf8), !text.isEmpty { + incomingCont.yield(text) + } + stdoutBuf.removeAll() + } + incomingCont.finish() + stderrCont.finish() + + if ownsClient { + try? await client.close() + } + } + + // MARK: - Internal + + private func writerBecameAvailable(_ w: TTYStdinWriter) { + writer = w + let waiters = writerWaiters + writerWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume(returning: w) + } + } + + private func failWriterWaiters(with error: Error) { + let waiters = writerWaiters + writerWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume(throwing: error) + } + } + + private func markClosed() { + isClosed = true + } + + private func waitForWriter() async throws -> TTYStdinWriter { + if let w = writer { return w } + return try await withCheckedThrowingContinuation { cont in + writerWaiters.append(cont) + } + } + + /// Called per stdout/stderr chunk from Citadel. Line-frame + yield. + private func ingest(_ buffer: ByteBuffer, isStderr: Bool) { + var buf = buffer + let bytes = buf.readBytes(length: buf.readableBytes) ?? [] + if isStderr { + stderrBuf.append(contentsOf: bytes) + while let nl = stderrBuf.firstIndex(of: 0x0A) { + let line = Data(stderrBuf[stderrBuf.startIndex.. 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(.secondary) + .padding(.horizontal) + Button("Retry") { + Task { await controller.start() } + } + .buttonStyle(.borderedProminent) + } + .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 + case failed(String) + } + + private(set) var state: State = .idle + var vm: RichChatViewModel + var draft: String = "" + + private let context: ServerContext + private var client: ACPClient? + private var eventTask: Task? + + init(context: ServerContext) { + self.context = context + self.vm = RichChatViewModel(context: context) + } + + /// 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 } + 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 + + do { + try await client.start() + } catch { + state = .failed(error.localizedDescription) + 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. + let stream = await client.events + eventTask = Task { [weak self] in + for await event in stream { + guard let self else { break } + await MainActor.run { + self.vm.handleACPEvent(event) + } + } + } + + // 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) + state = .ready + } catch { + state = .failed(error.localizedDescription) + 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) + guard !text.isEmpty else { return } + let sessionId = vm.sessionId ?? "" + guard !sessionId.isEmpty else { return } + draft = "" + vm.addUserMessage(text: text) + do { + _ = try await client.sendPrompt(sessionId: sessionId, text: text) + } catch { + // The event task may already have surfaced a + // .connectionLost; show the send-time error only if the + // state didn't already fail. + if case .ready = state { + state = .failed("Prompt failed: \(error.localizedDescription)") + } + } + } + + /// Stop the current session + tear down the SSH exec channel. + /// Idempotent. + func stop() async { + eventTask?.cancel() + eventTask = nil + if let client { + await client.stop() + } + client = nil + state = .idle + } + + /// User tapped "New chat". Stop, reset the VM, start again. + func resetAndStartNewSession() async { + await stop() + vm.reset() + await start() + } +} + +// MARK: - Message bubble + +private struct MessageBubble: View { + let message: HermesMessage + + var body: some View { + HStack { + if message.isUser { Spacer(minLength: 40) } + VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) { + Text(message.content) + .font(.body) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .foregroundStyle(message.isUser ? Color.white : Color.primary) + .background( + message.isUser ? Color.accentColor : Color(.secondarySystemBackground) + ) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .textSelection(.enabled) + if message.hasReasoning, let r = message.reasoning, !r.isEmpty { + Text("🧠 \(r)") + .font(.caption2) + .italic() + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + } + } + if !message.isUser { Spacer(minLength: 40) } + } + .padding(.horizontal) + } +} + +#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 diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index 4b6d9fd..1b92784 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -89,6 +89,14 @@ struct DashboardView: View { } } + Section { + NavigationLink { + ChatView(config: config, key: key) + } label: { + Label("Chat", systemImage: "bubble.left.and.bubble.right.fill") + } + } + Section("Connected to") { LabeledContent("Host", value: config.host) if let user = config.user { diff --git a/scarf/docs/IOS_PORT_PLAN.md b/scarf/docs/IOS_PORT_PLAN.md index a1c7c3b..f9aa50e 100644 --- a/scarf/docs/IOS_PORT_PLAN.md +++ b/scarf/docs/IOS_PORT_PLAN.md @@ -617,7 +617,41 @@ the 3 ScarfIOS tests. - **`CitadelServerTransport.streamLines` is a stub (M3).** When the iOS Chat feature lands in M4+, implement it using Citadel's raw exec channel API (not `executeCommand`, which buffers the entire output). That'll also unlock iOS log tailing. - **`HermesFileService` still hasn't moved to ScarfCore.** iOS's Dashboard is minimal because of this; no config.yaml / gateway-state / pgrep checks. A future phase can either port HermesFileService (requires iOS-compatible shell-env story) or replicate the narrow subset iOS needs. -### M4 — pending -### M4 — pending +### M4 — shipped (on `claude/ios-m4-chat` branch, separate PR, stacked on M3) + +**What shipped:** iOS Chat via Citadel's 8-bit-safe SSH exec channel + the Xcode target reconciliation work (pulling Alan's target creation from `template-configuration` into the iOS-port stack, merging pbxproj with M3's ScarfCore wiring, consolidating source layout into `scarf/Scarf iOS/`, wiring ScarfIOS as a local SPM package). + +**ScarfIOS additions:** +- `SSHExecACPChannel.swift` — iOS counterpart to `ProcessACPChannel`. Uses `SSHClient.withExec(_:perform:)` for bidirectional exec (RFC 4254), line-frames stdout / stderr, Cancel-driven teardown. +- `ACPClient+iOS.swift` — `ACPClient.forIOSApp(context:keyProvider:)` factory that opens a dedicated `SSHClient` per ACP session (separate from the transport's client so ACP's long channel doesn't multiplex-compete with SFTP). Shared ed25519 auth helper via the key bundle stored in Keychain. + +**iOS Chat view:** +- `Scarf iOS/Chat/ChatView.swift` + `ChatController` (`@Observable @MainActor`). Three-state lifecycle (connecting / ready / failed), auto-scroll message list, SwiftUI composer, "+" toolbar for a fresh session. Reuses ScarfCore's `RichChatViewModel` unchanged. +- `DashboardView` gains a NavigationLink into Chat. +- `RichChatViewModel.sessionId` promoted `public private(set)` so `ChatController` can route `sendPrompt`. + +**Xcode target reconciliation** (carried in the same PR — they can't easily be separated without breaking the build): +- Merged `b289a83`'s iOS-target pbxproj additions on top of my M3 pbxproj via `git merge-file` (zero conflicts, 658 → 1074 lines). Added ScarfIOS as a new `XCLocalSwiftPackageReference`, wired both ScarfCore + ScarfIOS to the `scarf mobile` target's `packageProductDependencies` + Frameworks build phase. +- Source layout: moved `scarf/scarf-ios/*` into `scarf/Scarf iOS/` (matching Xcode's synced group path). Deleted Xcode's scaffolded `ContentView.swift` / `Item.swift` / `Scarf_iOSApp.swift` defaults (my M2 code supersedes). +- `scarf/docs/iOS-SETUP.md` — rewrote as a project-layout reference + troubleshooting doc, dropping the "how to create the target" walkthrough now that the target exists. + +**Tests:** 2 new in `M4ACPIOSTests`: +- Streaming prompt end-to-end: initialize handshake + session/new + two agent_message_chunk notifications + session/prompt response with usage tokens. Verify both chunks arrive as `.messageChunk` events, the prompt resolves with correct stopReason + input/output token counts. +- Permission-request round-trip: remote `session/request_permission` request → `.permissionRequest` event → `respondToPermission` writes a proper JSON response back on the channel. + +**96 → 98 tests passing on Linux.** + +**Manual validation still needed on Mac:** +1. iOS compile cleanly against the merged pbxproj. +2. Chat end-to-end: Dashboard → Chat → "hello" → streaming response from a real Hermes install. +3. Tool-call events visible (even without fancy cards — M5 polish). +4. No leaked SSH connections across Disconnect / re-onboard cycles. + +**Rules next phases can rely on:** +- **Two separate `SSHClient`s per Scarf session** — one in `CitadelServerTransport` (SFTP + one-shot exec), one in `SSHExecACPChannel` (long-running ACP). Don't pool; OpenSSH caps concurrent channels per connection at ~10. +- **`ACPClient.forIOSApp`** is the iOS factory. Any future iOS feature that needs ACP uses it — don't construct `ACPClient` directly. +- **Chat is rich-chat-only on iOS in v1.** Terminal mode (embedded SwiftTerm) deferred. +- **Message markdown / tool-call cards / permission sheets** are M5 polish. + ### M5 — pending ### M6 — pending