M4: NucleicRemote iPhone app — SwiftUI client over the shared protocol
A real iOS Xcode app (ios/NucleicRemote) linking the NucleicProtocol SwiftPM library as a local package. Builds for the iOS 27 simulator and launches to the pairing screen. - Transport: NWFrameChannel (NWConnection) + LANDiscovery (Bonjour _nucleic._tcp). - Engine: drives NucleicProtocol.SyncClient (Noise XXpsk0 pair / IK reconnect, hello/welcome, HostMsg→Event stream). - State: RemoteStore (ObservableObject) — the single on-device projection of host state; IdentityStore persists the device identity (Keychain) + pinned host. - UI (UX_IOS): attention-first SessionsView, SessionDetailView (transcript/diff + status-driven action area / composer), ApprovalCardView with Face ID gate on high-risk approvals + allow-always menu, PairingScannerView (AVFoundation QR), SettingsView, connection chip. Same status glyphs/semantics as the Mac. Add-iPhone QR display + server start live on the macOS side (follow-up); push / Live Activity are M5 (needs the relay). gitignore keeps this .xcodeproj despite the blanket *.xcodeproj rule. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "red" : "0.118", "green" : "0.565", "blue" : "0.553", "alpha" : "1.000" } }, "idiom" : "universal" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan the pairing QR code shown on your Mac.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Connect to your Mac running Nucleic on the local network.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_nucleic._tcp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import NucleicProtocol
|
||||
|
||||
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
|
||||
/// reconnect), a display name, and an optional LAN hint. The pairing secret is *not* stored —
|
||||
/// it's one-time. Non-secret, so UserDefaults is fine; the device private key goes to Keychain.
|
||||
struct PairedHost: Codable, Equatable {
|
||||
var deviceID: String
|
||||
var hostName: String
|
||||
var hostStaticKey: Data
|
||||
var fingerprint: String
|
||||
var lanHost: String?
|
||||
var lanPort: UInt16?
|
||||
}
|
||||
|
||||
/// Loads/persists this device's long-term `DeviceIdentity` (Keychain) and the pinned host
|
||||
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
|
||||
enum IdentityStore {
|
||||
private static let keychainAccount = "com.nucleic.remote.identity"
|
||||
private static let pairedHostKey = "nucleic.pairedHost"
|
||||
private static let deviceIDKey = "nucleic.deviceID"
|
||||
|
||||
static func loadOrCreateIdentity() -> DeviceIdentity {
|
||||
if let data = keychainRead(), let identity = try? DeviceIdentity(importingRaw: data) {
|
||||
return identity
|
||||
}
|
||||
let identity = DeviceIdentity()
|
||||
keychainWrite(identity.exportRaw())
|
||||
return identity
|
||||
}
|
||||
|
||||
/// Stable per-install device id (re-used across reconnects so the host can match the pin).
|
||||
static func deviceID() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
if let existing = defaults.string(forKey: deviceIDKey) { return existing }
|
||||
let id = "iphone-" + UUID().uuidString.prefix(8).lowercased()
|
||||
defaults.set(id, forKey: deviceIDKey)
|
||||
return id
|
||||
}
|
||||
|
||||
static func loadPairedHost() -> PairedHost? {
|
||||
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
|
||||
return try? JSONDecoder().decode(PairedHost.self, from: data)
|
||||
}
|
||||
|
||||
static func savePairedHost(_ host: PairedHost) {
|
||||
if let data = try? JSONEncoder().encode(host) {
|
||||
UserDefaults.standard.set(data, forKey: pairedHostKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func clearPairedHost() {
|
||||
UserDefaults.standard.removeObject(forKey: pairedHostKey)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private static func keychainRead() -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
|
||||
return item as? Data
|
||||
}
|
||||
|
||||
private static func keychainWrite(_ data: Data) {
|
||||
let delete: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
]
|
||||
SecItemDelete(delete as CFDictionary)
|
||||
let add: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainAccount,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
|
||||
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
|
||||
typealias WireSessionSummary = NucleicProtocol.SessionSummary
|
||||
|
||||
/// The phone's single source of UI state — a pure projection of the host (UX_IOS §1.2). Owns
|
||||
/// the `SyncClient`, reflects connectivity truth, and exposes the session list + the open
|
||||
/// session's transcript/approvals. No canonical state is invented on-device.
|
||||
@MainActor
|
||||
final class RemoteStore: ObservableObject {
|
||||
enum Connectivity: Equatable {
|
||||
case unpaired
|
||||
case connecting
|
||||
case reconnecting
|
||||
case connected // LAN
|
||||
case hostOffline
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .unpaired: "Not paired"
|
||||
case .connecting: "Connecting…"
|
||||
case .reconnecting: "Reconnecting…"
|
||||
case .connected: "Connected · LAN"
|
||||
case .hostOffline: "Mac offline"
|
||||
case .failed(let m): m
|
||||
}
|
||||
}
|
||||
var isLive: Bool { self == .connected }
|
||||
}
|
||||
|
||||
@Published private(set) var connectivity: Connectivity = .unpaired
|
||||
@Published private(set) var hostName: String = ""
|
||||
@Published private(set) var sessions: [WireSessionSummary] = []
|
||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
||||
|
||||
// Open session projection.
|
||||
@Published private(set) var openSessionID: SessionID?
|
||||
@Published private(set) var openEvents: [AgentEvent] = []
|
||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
||||
|
||||
/// Sessions needing a human (drives the app-icon badge + NEEDS YOU section).
|
||||
var needsYouCount: Int { sessions.filter { $0.status == .awaitingApproval }.count }
|
||||
|
||||
private let identity = IdentityStore.loadOrCreateIdentity()
|
||||
let discovery = LANDiscovery()
|
||||
private var client: SyncClient?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var seenSeq: Set<UInt64> = []
|
||||
private var reconnectAttempts = 0
|
||||
|
||||
var isPaired: Bool { IdentityStore.loadPairedHost() != nil }
|
||||
var deviceFingerprint: String { identity.fingerprint }
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func onAppear() {
|
||||
discovery.start()
|
||||
if isPaired { reconnect() }
|
||||
}
|
||||
|
||||
/// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
|
||||
/// and on success pin the host key for future IK reconnects.
|
||||
func pair(with payload: PairingPayload) {
|
||||
teardown()
|
||||
connectivity = .connecting
|
||||
hostName = payload.hostName
|
||||
let deviceID = IdentityStore.deviceID()
|
||||
guard let endpoint = resolveEndpoint(
|
||||
fingerprint: payload.hostStaticKey.fingerprintHex,
|
||||
lanHost: payload.lanHost, lanPort: payload.lanPort)
|
||||
else { connectivity = .failed("No Mac found on this network"); return }
|
||||
|
||||
let channel = makeChannel(endpoint)
|
||||
let client = SyncClient(
|
||||
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
|
||||
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
|
||||
deviceLabel: UIDevice.current.name)
|
||||
self.client = client
|
||||
consume(client, pairingPayload: payload)
|
||||
}
|
||||
|
||||
/// Reconnect to the already-paired host using IK against the pinned static key.
|
||||
func reconnect() {
|
||||
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
|
||||
teardown()
|
||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||
hostName = host.hostName
|
||||
guard let endpoint = resolveEndpoint(
|
||||
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
|
||||
else { connectivity = .hostOffline; scheduleRetry(); return }
|
||||
|
||||
let channel = makeChannel(endpoint)
|
||||
let client = SyncClient(
|
||||
channel: channel, identity: identity, hostStaticKey: host.hostStaticKey,
|
||||
mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name)
|
||||
self.client = client
|
||||
consume(client, pairingPayload: nil)
|
||||
}
|
||||
|
||||
func unpair() {
|
||||
teardown()
|
||||
IdentityStore.clearPairedHost()
|
||||
connectivity = .unpaired
|
||||
sessions = []
|
||||
}
|
||||
|
||||
// MARK: - Intents (UX_IOS §9)
|
||||
|
||||
func open(_ sessionID: SessionID) {
|
||||
openSessionID = sessionID
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
seenSeq.removeAll()
|
||||
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
||||
}
|
||||
|
||||
func closeOpen() {
|
||||
if let id = openSessionID { send(.unsubscribe(id)) }
|
||||
openSessionID = nil
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
}
|
||||
|
||||
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
|
||||
send(.approvalRespond(approval.id, decision))
|
||||
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
||||
}
|
||||
|
||||
func sendInput(_ text: String, to sessionID: SessionID) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
send(.sendInput(sessionID, AgentInput(text: trimmed)))
|
||||
}
|
||||
|
||||
func refreshSessions() { send(.listSessions) }
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
private func send(_ msg: ClientMsg) {
|
||||
guard let client else { return }
|
||||
Task { await client.send(msg) }
|
||||
}
|
||||
|
||||
private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel {
|
||||
NWFrameChannel(endpoint: endpoint)
|
||||
}
|
||||
|
||||
private func resolveEndpoint(fingerprint: String?, lanHost: String?, lanPort: UInt16?) -> NWEndpoint? {
|
||||
discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort)
|
||||
}
|
||||
|
||||
private func consume(_ client: SyncClient, pairingPayload: PairingPayload?) {
|
||||
eventTask = Task { [weak self] in
|
||||
let stream = await client.start()
|
||||
for await event in stream {
|
||||
await self?.handle(event, pairingPayload: pairingPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ event: SyncClient.Event, pairingPayload: PairingPayload?) async {
|
||||
switch event {
|
||||
case .connecting:
|
||||
break
|
||||
case .ready(let welcome):
|
||||
reconnectAttempts = 0
|
||||
connectivity = .connected
|
||||
hostName = welcome.host.hostName
|
||||
capabilities = welcome.capabilities
|
||||
grantedScope = welcome.grantedScope
|
||||
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
|
||||
IdentityStore.savePairedHost(PairedHost(
|
||||
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
|
||||
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
||||
lanHost: payload.lanHost, lanPort: payload.lanPort))
|
||||
}
|
||||
send(.listSessions)
|
||||
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
|
||||
case .sessionList(let list):
|
||||
sessions = list
|
||||
case .sessionUpdated(let summary):
|
||||
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
|
||||
else { sessions.append(summary) }
|
||||
case .snapshot(let snapshot):
|
||||
guard snapshot.summary.sessionID == openSessionID else { break }
|
||||
seenSeq = Set(snapshot.recentEvents.map(\.seq))
|
||||
openEvents = snapshot.recentEvents
|
||||
openApprovals = snapshot.pendingApprovals
|
||||
case .events(let batch):
|
||||
guard batch.sessionID == openSessionID else { break }
|
||||
for e in batch.events where !seenSeq.contains(e.seq) {
|
||||
seenSeq.insert(e.seq)
|
||||
openEvents.append(e)
|
||||
}
|
||||
case .approvalRequested(let req):
|
||||
if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) {
|
||||
openApprovals.append(req)
|
||||
}
|
||||
case .approvalResolved(let resolved):
|
||||
openApprovals.removeAll { $0.id == resolved.id }
|
||||
case .wireError:
|
||||
break // surfaced contextually by callers; not fatal
|
||||
case .failed(let message):
|
||||
connectivity = .failed(message)
|
||||
scheduleRetry()
|
||||
case .closed:
|
||||
if connectivity == .connected { connectivity = .reconnecting }
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRetry() {
|
||||
guard isPaired else { return }
|
||||
reconnectAttempts += 1
|
||||
let delay = min(Double(reconnectAttempts) * 1.5, 10)
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard let self, self.connectivity != .connected else { return }
|
||||
self.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() {
|
||||
eventTask?.cancel()
|
||||
eventTask = nil
|
||||
if let client { Task { await client.disconnect() } }
|
||||
client = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
/// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256).
|
||||
var fingerprintHex: String {
|
||||
DeviceIdentity.fingerprint(ofStaticKey: self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import NucleicProtocol
|
||||
|
||||
/// Browses Bonjour for nucleic hosts on the LAN (`_nucleic._tcp`) and resolves an endpoint to
|
||||
/// connect to. The phone tries the QR's explicit LAN hint first (fast path), then falls back
|
||||
/// to a Bonjour match on the host's identity fingerprint.
|
||||
@MainActor
|
||||
final class LANDiscovery: ObservableObject {
|
||||
struct Found: Identifiable, Hashable {
|
||||
let id: String // endpoint description
|
||||
let endpoint: NWEndpoint
|
||||
let fingerprint: String?
|
||||
let name: String
|
||||
}
|
||||
|
||||
@Published private(set) var hosts: [Found] = []
|
||||
private var browser: NWBrowser?
|
||||
|
||||
func start() {
|
||||
guard browser == nil else { return }
|
||||
let params = NWParameters()
|
||||
params.includePeerToPeer = true
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: LANService.type, domain: nil), using: params)
|
||||
self.browser = browser
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in self?.apply(results) }
|
||||
}
|
||||
browser.start(queue: .main)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
hosts = []
|
||||
}
|
||||
|
||||
/// Resolve an endpoint to dial: explicit host:port from the QR if present, else a Bonjour
|
||||
/// result whose advertised fingerprint matches the pinned host key.
|
||||
func endpoint(forFingerprint fingerprint: String?, lanHost: String?, lanPort: UInt16?) -> NWEndpoint? {
|
||||
if let lanHost, let lanPort, let port = NWEndpoint.Port(rawValue: lanPort) {
|
||||
return .hostPort(host: .init(lanHost), port: port)
|
||||
}
|
||||
if let fingerprint, let match = hosts.first(where: { $0.fingerprint == fingerprint }) {
|
||||
return match.endpoint
|
||||
}
|
||||
return hosts.first?.endpoint
|
||||
}
|
||||
|
||||
private func apply(_ results: Set<NWBrowser.Result>) {
|
||||
hosts = results.map { result in
|
||||
var fingerprint: String?
|
||||
var name = "Mac"
|
||||
if case .bonjour(let txt) = result.metadata {
|
||||
fingerprint = txt["fp"]
|
||||
}
|
||||
if case .service(let serviceName, _, _, _) = result.endpoint {
|
||||
name = serviceName
|
||||
}
|
||||
return Found(
|
||||
id: "\(result.endpoint)", endpoint: result.endpoint,
|
||||
fingerprint: fingerprint, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LANService {
|
||||
static let type = "_nucleic._tcp"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import NucleicProtocol
|
||||
|
||||
/// Client-side `FrameChannel` over an outbound `NWConnection` (SYNC_PROTOCOL §3.1). De-frames
|
||||
/// inbound bytes with `FrameAccumulator`; length-prefixes outbound frames. Mirrors the host's
|
||||
/// `LANChannel` but for the phone's side of the TCP connection.
|
||||
final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
||||
let peerDescription: String
|
||||
private let connection: NWConnection
|
||||
private let queue = DispatchQueue(label: "nucleic.remote.channel")
|
||||
private let accumulator = FrameAccumulator()
|
||||
private let stream: AsyncStream<Data>
|
||||
private let continuation: AsyncStream<Data>.Continuation
|
||||
|
||||
/// State callbacks so the store can reflect connectivity truth (UX_IOS §6).
|
||||
var onReady: (@Sendable () -> Void)?
|
||||
var onFailed: (@Sendable (String) -> Void)?
|
||||
|
||||
init(endpoint: NWEndpoint) {
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
connection = NWConnection(to: endpoint, using: params)
|
||||
peerDescription = "\(endpoint)"
|
||||
var cont: AsyncStream<Data>.Continuation!
|
||||
stream = AsyncStream(bufferingPolicy: .unbounded) { cont = $0 }
|
||||
continuation = cont
|
||||
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
switch state {
|
||||
case .ready: self?.onReady?()
|
||||
case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish()
|
||||
case .cancelled: self?.continuation.finish()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
receiveLoop()
|
||||
}
|
||||
|
||||
func frames() -> AsyncStream<Data> { stream }
|
||||
|
||||
func send(_ frame: Data) {
|
||||
connection.send(content: WireFraming.frame(frame), completion: .idempotent)
|
||||
}
|
||||
|
||||
func close() {
|
||||
connection.cancel()
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
private func receiveLoop() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
if let data, !data.isEmpty {
|
||||
if let frames = try? self.accumulator.push(data) {
|
||||
for frame in frames { self.continuation.yield(frame) }
|
||||
} else {
|
||||
self.close(); return
|
||||
}
|
||||
}
|
||||
if isComplete || error != nil { self.continuation.finish(); return }
|
||||
self.receiveLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct NucleicRemoteApp: App {
|
||||
@StateObject private var store = RemoteStore()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environmentObject(store)
|
||||
.onAppear { store.onAppear() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RootView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
|
||||
var body: some View {
|
||||
if store.isPaired {
|
||||
TabView {
|
||||
SessionsView()
|
||||
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
||||
.badge(store.needsYouCount)
|
||||
SettingsView()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||
}
|
||||
} else {
|
||||
PairingIntroView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
import NucleicProtocol
|
||||
|
||||
/// The defining interaction (UX_IOS §5): answer an approval. High-risk (destructive/network)
|
||||
/// requires Face ID/Touch ID before Allow is enabled — deliberate friction mirroring the Mac.
|
||||
/// First-responder-wins: if the Mac (or another phone) answers first, the host's
|
||||
/// `approvalResolved` removes this card.
|
||||
struct ApprovalCardView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let approval: ApprovalRequest
|
||||
|
||||
@State private var biometricPassed = false
|
||||
@State private var authError: String?
|
||||
@State private var showAlwaysMenu = false
|
||||
|
||||
private var requiresBiometric: Bool { approval.risk.isHigh }
|
||||
private var allowEnabled: Bool {
|
||||
store.connectivity.isLive && (!requiresBiometric || biometricPassed)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
||||
Text("Permission requested").font(.subheadline.weight(.semibold))
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(approval.toolName).font(.caption.weight(.bold))
|
||||
Text(approval.risk.label)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(approval.risk.color.opacity(0.2), in: Capsule())
|
||||
.foregroundStyle(approval.risk.color)
|
||||
}
|
||||
Text(approval.input.compactSummary)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(3)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if requiresBiometric && !biometricPassed {
|
||||
Button {
|
||||
authenticate()
|
||||
} label: {
|
||||
Label("Face ID required to allow", systemImage: "faceid").font(.caption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
if let authError {
|
||||
Text(authError).font(.caption2).foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(role: .destructive) {
|
||||
store.respond(approval, .deny(reason: nil))
|
||||
} label: {
|
||||
Text("Deny").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
store.respond(approval, .allow())
|
||||
} label: {
|
||||
Text("Allow").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!allowEnabled)
|
||||
}
|
||||
|
||||
if !store.capabilities.allowAlwaysScopes.isEmpty {
|
||||
Menu("Allow always…") {
|
||||
ForEach(store.capabilities.allowAlwaysScopes, id: \.self) { scope in
|
||||
Button(alwaysLabel(scope)) {
|
||||
store.respond(approval, .allowAlways(scope))
|
||||
}
|
||||
.disabled(!allowEnabled)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.onChange(of: approval.id) { _, _ in biometricPassed = false; authError = nil }
|
||||
}
|
||||
|
||||
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
||||
switch scope {
|
||||
case .session: "This command, this session"
|
||||
case .toolName: "Any \(approval.toolName), this session"
|
||||
case .toolNameWithPattern: "\(approval.toolName) matching this pattern"
|
||||
}
|
||||
}
|
||||
|
||||
private func authenticate() {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
||||
authError = "Biometrics unavailable"; return
|
||||
}
|
||||
context.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: "Approve a \(approval.risk.label) action"
|
||||
) { success, evalError in
|
||||
Task { @MainActor in
|
||||
if success { biometricPassed = true; authError = nil }
|
||||
else { authError = evalError?.localizedDescription ?? "Authentication failed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Persistent connectivity indicator (UX_IOS §6). Connectivity is unambiguous: green LAN
|
||||
/// when live, amber while reconnecting, gray when the Mac is unreachable.
|
||||
struct ConnectionChip: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(color).frame(width: 8, height: 8)
|
||||
Text(store.connectivity.label).font(.footnote.weight(.medium))
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch store.connectivity {
|
||||
case .connected: .green
|
||||
case .connecting, .reconnecting: .orange
|
||||
case .hostOffline, .unpaired: .gray
|
||||
case .failed: .red
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import NucleicProtocol
|
||||
|
||||
/// Camera QR scanner (UX_IOS §7). Decodes the Mac's `nucleic://pair?d=…` QR into a
|
||||
/// `PairingPayload` and hands it back exactly once.
|
||||
struct PairingScannerView: View {
|
||||
let onScanned: (PairingPayload) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
QRScannerRepresentable { value in handle(value) }
|
||||
.ignoresSafeArea()
|
||||
VStack {
|
||||
Spacer()
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(.white.opacity(0.8), lineWidth: 3)
|
||||
.frame(width: 240, height: 240)
|
||||
Spacer()
|
||||
if let error {
|
||||
Text(error).foregroundStyle(.white)
|
||||
.padding().background(.red.opacity(0.8), in: Capsule())
|
||||
} else {
|
||||
Text("Point at the QR code on your Mac")
|
||||
.foregroundStyle(.white)
|
||||
.padding().background(.black.opacity(0.5), in: Capsule())
|
||||
}
|
||||
Spacer().frame(height: 40)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Scan to pair")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ value: String) {
|
||||
guard let payload = try? PairingPayload(qrString: value) else {
|
||||
error = "That isn't a Nucleic pairing code"; return
|
||||
}
|
||||
onScanned(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/// UIKit bridge for the AVCaptureSession metadata scan.
|
||||
struct QRScannerRepresentable: UIViewControllerRepresentable {
|
||||
let onFound: (String) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> QRScannerController {
|
||||
let controller = QRScannerController()
|
||||
controller.onFound = onFound
|
||||
return controller
|
||||
}
|
||||
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
|
||||
}
|
||||
|
||||
final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onFound: ((String) -> Void)?
|
||||
private let session = AVCaptureSession()
|
||||
private var didFind = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else { return }
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return }
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.layer.bounds
|
||||
view.layer.addSublayer(preview)
|
||||
|
||||
Task.detached { [session] in session.startRunning() }
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
session.stopRunning()
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard !didFind,
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let value = object.stringValue else { return }
|
||||
didFind = true
|
||||
onFound?(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// One session: a segmented Transcript/Diff read view over a status-driven action area
|
||||
/// (UX_IOS §4). Subscribes on appear, unsubscribes on disappear.
|
||||
struct SessionDetailView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let sessionID: SessionID
|
||||
@State private var tab = 0
|
||||
@State private var draft = ""
|
||||
|
||||
private var summary: WireSessionSummary? {
|
||||
store.sessions.first { $0.sessionID == sessionID }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Picker("", selection: $tab) {
|
||||
Text("Transcript").tag(0)
|
||||
Text("Diff").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
if tab == 0 {
|
||||
TranscriptList(events: store.openEvents)
|
||||
} else {
|
||||
DiffSummaryView(diffStat: summary?.diffStat)
|
||||
}
|
||||
|
||||
actionArea
|
||||
}
|
||||
.navigationTitle(summary?.title ?? "Session")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { store.open(sessionID) }
|
||||
.onDisappear { store.closeOpen() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionArea: some View {
|
||||
Divider()
|
||||
if let approval = store.openApprovals.first {
|
||||
ApprovalCardView(approval: approval)
|
||||
.padding()
|
||||
} else if let status = summary?.status, status == .awaitingInput || status.isTerminal {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Send a follow-up…", text: $draft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
Button {
|
||||
store.sendInput(draft, to: sessionID)
|
||||
draft = ""
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill").font(.title2)
|
||||
}
|
||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
||||
}
|
||||
.padding()
|
||||
} else if summary?.status == .running {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Agent is working…").font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TranscriptList: View {
|
||||
let events: [AgentEvent]
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(events, id: \.seq) { event in
|
||||
TranscriptRow(event: event).id(event.seq)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: events.count) {
|
||||
if let last = events.last { withAnimation { proxy.scrollTo(last.seq, anchor: .bottom) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiffSummaryView: View {
|
||||
let diffStat: DiffStat?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let diff = diffStat, diff.filesChanged > 0 {
|
||||
Label("\(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") changed", systemImage: "doc.text")
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
Text("+\(diff.added)").foregroundStyle(.green)
|
||||
Text("−\(diff.removed)").foregroundStyle(.red)
|
||||
}
|
||||
.font(.title3.monospacedDigit())
|
||||
Text("Open the Mac for the full patch.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
} else {
|
||||
ContentUnavailableView("No changes yet", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, with a
|
||||
/// persistent connection chip. Tapping a row pushes detail.
|
||||
struct SessionsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
|
||||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||
let sorted = store.sessions.sorted {
|
||||
let a = StatusStyle.sortRank($0.status), b = StatusStyle.sortRank($1.status)
|
||||
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
||||
}
|
||||
let needs = sorted.filter { $0.status == .awaitingApproval || $0.status == .awaitingInput }
|
||||
let running = sorted.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
||||
let done = sorted.filter { $0.status.isTerminal }
|
||||
return [
|
||||
("Needs you", needs), ("Running", running), ("Done", done),
|
||||
].filter { !$0.rows.isEmpty }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.sessions.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No sessions", systemImage: "square.stack.3d.up",
|
||||
description: Text(store.connectivity.isLive
|
||||
? "Start a session on your Mac to see it here."
|
||||
: "Waiting to connect to \(store.hostName)…"))
|
||||
} else {
|
||||
List {
|
||||
ForEach(grouped, id: \.title) { group in
|
||||
Section(group.title.uppercased()) {
|
||||
ForEach(group.rows, id: \.sessionID) { summary in
|
||||
NavigationLink {
|
||||
SessionDetailView(sessionID: summary.sessionID)
|
||||
} label: {
|
||||
SessionRow(summary: summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { store.refreshSessions() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
ConnectionChip().padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionRow: View {
|
||||
let summary: WireSessionSummary
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: StatusStyle.symbol(summary.status))
|
||||
.foregroundStyle(StatusStyle.color(summary.status))
|
||||
.font(.title3)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
|
||||
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if let diff = summary.diffStat, diff.filesChanged > 0 {
|
||||
Text("+\(diff.added)/−\(diff.removed)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
@State private var showScanner = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Connection") {
|
||||
LabeledContent("Status", value: store.connectivity.label)
|
||||
if !store.hostName.isEmpty {
|
||||
LabeledContent("Mac", value: store.hostName)
|
||||
}
|
||||
Button("Reconnect") { store.reconnect() }
|
||||
.disabled(!store.isPaired)
|
||||
}
|
||||
|
||||
Section("This device") {
|
||||
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
|
||||
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
|
||||
.font(.footnote.monospaced())
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
if store.isPaired {
|
||||
Button(role: .destructive) { store.unpair() } label: {
|
||||
Label("Unpair this device", systemImage: "minus.circle")
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Code and diffs are end-to-end encrypted. A relay (later) can't read them.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.sheet(isPresented: $showScanner) {
|
||||
PairingScannerView { payload in
|
||||
showScanner = false
|
||||
store.pair(with: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.
|
||||
struct PairingIntroView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
@State private var showScanner = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
|
||||
Text("Pair with your Mac").font(.title2.weight(.semibold))
|
||||
Text("On your Mac, open Nucleic ▸ Settings ▸ Add iPhone to show a QR code, then scan it here.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 32)
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
.sheet(isPresented: $showScanner) {
|
||||
PairingScannerView { payload in
|
||||
showScanner = false
|
||||
store.pair(with: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Same status glyphs/semantics as the Mac (UX_MACOS §1 / UX_IOS §3) so the two devices read
|
||||
/// identically.
|
||||
enum StatusStyle {
|
||||
static func symbol(_ status: SessionStatus) -> String {
|
||||
switch status {
|
||||
case .idle: "circle"
|
||||
case .provisioning: "circle.dotted"
|
||||
case .running: "circle.lefthalf.filled"
|
||||
case .awaitingApproval: "exclamationmark.triangle.fill"
|
||||
case .awaitingInput: "bubble.left"
|
||||
case .finished: "checkmark.circle.fill"
|
||||
case .interrupted: "stop.circle"
|
||||
case .error: "xmark.octagon.fill"
|
||||
}
|
||||
}
|
||||
|
||||
static func color(_ status: SessionStatus) -> Color {
|
||||
switch status {
|
||||
case .awaitingApproval: .orange
|
||||
case .running, .provisioning: .blue
|
||||
case .finished: .green
|
||||
case .error: .red
|
||||
case .awaitingInput: .purple
|
||||
case .idle, .interrupted: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
/// Attention-first ordering for the session list (UX_IOS §3).
|
||||
static func sortRank(_ status: SessionStatus) -> Int {
|
||||
switch status {
|
||||
case .awaitingApproval: 0
|
||||
case .awaitingInput: 1
|
||||
case .running, .provisioning: 2
|
||||
case .idle: 3
|
||||
case .finished, .interrupted, .error: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Risk {
|
||||
var isHigh: Bool { self == .destructive || self == .network }
|
||||
var label: String { rawValue }
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .destructive, .network: .red
|
||||
case .execute: .orange
|
||||
case .write: .yellow
|
||||
case .readOnly: .secondary
|
||||
case .unknown: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Read-friendly rendering of one transcript event (UX_IOS §4). Assistant/user text as
|
||||
/// bubbles; tool calls as collapsible rows; everything else compact.
|
||||
struct TranscriptRow: View {
|
||||
let event: AgentEvent
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
switch event.kind {
|
||||
case .userText(let chunk):
|
||||
bubble(chunk.text, role: .user)
|
||||
case .assistantText(let chunk):
|
||||
bubble(chunk.text, role: .assistant)
|
||||
case .thinking(let chunk):
|
||||
Text(chunk.text)
|
||||
.font(.callout.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
toolRow(name: call.name, detail: call.input.compactSummary)
|
||||
case .toolResult(let result):
|
||||
DisclosureGroup("Result") {
|
||||
Text(result.content.compactSummary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
case .approvalRequested(let req):
|
||||
Label("Approval requested: \(req.toolName)", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
case .runFinished(let finished):
|
||||
Label("Run \(finished.outcome.rawValue)", systemImage: "flag.checkered")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
case .error(let err):
|
||||
Label(err.message, systemImage: "xmark.octagon.fill")
|
||||
.font(.caption).foregroundStyle(.red)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private enum Role { case user, assistant }
|
||||
|
||||
@ViewBuilder
|
||||
private func bubble(_ text: String, role: Role) -> some View {
|
||||
HStack {
|
||||
if role == .user { Spacer(minLength: 40) }
|
||||
Text(text)
|
||||
.padding(10)
|
||||
.background(role == .user ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground),
|
||||
in: RoundedRectangle(cornerRadius: 12))
|
||||
.frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading)
|
||||
if role == .assistant { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
|
||||
private func toolRow(name: String, detail: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wrench.and.screwdriver").font(.caption).foregroundStyle(.secondary)
|
||||
Text(name).font(.caption.weight(.semibold))
|
||||
Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONValue {
|
||||
/// A one-line gist of a tool input/result for the collapsed row.
|
||||
var compactSummary: String {
|
||||
switch self {
|
||||
case .string(let s): return s
|
||||
case .object(let o):
|
||||
if let cmd = o["command"]?.stringValue { return cmd }
|
||||
if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path }
|
||||
return o.keys.sorted().joined(separator: ", ")
|
||||
case .array(let a): return "[\(a.count) items]"
|
||||
case .number(let n): return String(n)
|
||||
case .bool(let b): return String(b)
|
||||
case .null: return "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user