chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.build/
|
||||
.build-ios/
|
||||
JCodeMobile.xcodeproj/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
|
||||
# Python harness caches
|
||||
TestHarness/__pycache__/
|
||||
@@ -0,0 +1,23 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "JCodeKit",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(name: "JCodeKit", targets: ["JCodeKit"])
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "JCodeKit",
|
||||
swiftSettings: [.enableUpcomingFeature("StrictConcurrency")]
|
||||
),
|
||||
.testTarget(
|
||||
name: "JCodeKitTests",
|
||||
dependencies: ["JCodeKit"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
|
||||
/// Connection lifecycle reported to the UI.
|
||||
public enum ConnectionPhase: Equatable, Sendable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
/// Waiting before the next reconnect attempt.
|
||||
case reconnecting(attempt: Int)
|
||||
/// Gave up or was told to stop; `reason` is user-displayable.
|
||||
case failed(reason: String)
|
||||
}
|
||||
|
||||
/// Everything the UI observes from a connection: lifecycle changes plus
|
||||
/// decoded server events.
|
||||
public enum ConnectionOutput: Equatable, Sendable {
|
||||
case phase(ConnectionPhase)
|
||||
case event(ServerEvent)
|
||||
}
|
||||
|
||||
/// Actor owning one WebSocket connection to a jcode server.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// - connect, authenticate, subscribe
|
||||
/// - decode incoming NDJSON frames into `ServerEvent`s
|
||||
/// - automatic reconnect with capped exponential backoff
|
||||
/// - request-ID assignment
|
||||
///
|
||||
/// It deliberately knows nothing about app state; consumers feed the output
|
||||
/// stream into `SessionReducer`.
|
||||
public actor Connection {
|
||||
public struct Configuration: Sendable {
|
||||
public var gateway: Gateway
|
||||
public var authToken: String
|
||||
/// Maximum reconnect attempts before reporting `.failed`. Nil retries forever.
|
||||
public var maxReconnectAttempts: Int?
|
||||
/// Base backoff delay in seconds, doubled per attempt and capped at 30s.
|
||||
public var baseBackoffSeconds: Double
|
||||
|
||||
public init(
|
||||
gateway: Gateway,
|
||||
authToken: String,
|
||||
maxReconnectAttempts: Int? = nil,
|
||||
baseBackoffSeconds: Double = 1.0
|
||||
) {
|
||||
self.gateway = gateway
|
||||
self.authToken = authToken
|
||||
self.maxReconnectAttempts = maxReconnectAttempts
|
||||
self.baseBackoffSeconds = baseBackoffSeconds
|
||||
}
|
||||
}
|
||||
|
||||
private let configuration: Configuration
|
||||
private let makeTransport: @Sendable () -> any WebSocketTransport
|
||||
private var transport: (any WebSocketTransport)?
|
||||
private var nextRequestID: UInt64 = 1
|
||||
private var runTask: Task<Void, Never>?
|
||||
private var continuation: AsyncStream<ConnectionOutput>.Continuation?
|
||||
private var targetSessionID: String?
|
||||
private var stopped = false
|
||||
/// Set when the server announced a reload; the next reconnect attempt
|
||||
/// skips backoff because the drop is expected and the server returns fast.
|
||||
private var expectServerReload = false
|
||||
/// Set when the server asked this client to close; reconnecting would
|
||||
/// fight the server, so the loop ends with a failed phase instead.
|
||||
private var closeRequestedReason: String?
|
||||
|
||||
public init(
|
||||
configuration: Configuration,
|
||||
makeTransport: @escaping @Sendable () -> any WebSocketTransport = {
|
||||
URLSessionWebSocketTransport()
|
||||
}
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.makeTransport = makeTransport
|
||||
}
|
||||
|
||||
/// Starts the connection loop. The returned stream yields phase changes
|
||||
/// and decoded events until `stop()` is called or the stream is cancelled.
|
||||
public func start(resumeSessionID: String? = nil) -> AsyncStream<ConnectionOutput> {
|
||||
targetSessionID = resumeSessionID
|
||||
stopped = false
|
||||
expectServerReload = false
|
||||
closeRequestedReason = nil
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: ConnectionOutput.self)
|
||||
self.continuation = continuation
|
||||
runTask = Task { await runLoop() }
|
||||
continuation.onTermination = { _ in
|
||||
Task { [weak self] in await self?.stop() }
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
public func stop() async {
|
||||
guard !stopped else { return }
|
||||
stopped = true
|
||||
runTask?.cancel()
|
||||
runTask = nil
|
||||
if let transport {
|
||||
await transport.close()
|
||||
}
|
||||
transport = nil
|
||||
continuation?.yield(.phase(.disconnected))
|
||||
continuation?.finish()
|
||||
continuation = nil
|
||||
}
|
||||
|
||||
/// Sends a request, assigning it a fresh ID. Returns the assigned ID.
|
||||
@discardableResult
|
||||
public func send(_ build: @Sendable (UInt64) -> Request) async throws -> UInt64 {
|
||||
guard let transport else { throw TransportError.notConnected }
|
||||
let id = nextRequestID
|
||||
nextRequestID += 1
|
||||
let request = build(id)
|
||||
try await transport.send(text: request.encodedLine())
|
||||
return id
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func runLoop() async {
|
||||
var attempt = 0
|
||||
while !Task.isCancelled && !stopped {
|
||||
yield(.phase(attempt == 0 ? .connecting : .reconnecting(attempt: attempt)))
|
||||
let transport = makeTransport()
|
||||
do {
|
||||
try await transport.connect(
|
||||
url: configuration.gateway.webSocketURL,
|
||||
authToken: configuration.authToken
|
||||
)
|
||||
self.transport = transport
|
||||
yield(.phase(.connected))
|
||||
attempt = 0
|
||||
try await subscribeAndSync()
|
||||
try await receiveLoop(transport: transport)
|
||||
// Clean close: fall through to reconnect.
|
||||
} catch {
|
||||
if Task.isCancelled || stopped { break }
|
||||
if case TransportError.unauthorized = error {
|
||||
// Retrying with the same token can never succeed.
|
||||
self.transport = nil
|
||||
yield(
|
||||
.phase(
|
||||
.failed(
|
||||
reason:
|
||||
"This device is no longer paired with the server. Re-pair to reconnect."
|
||||
)))
|
||||
return
|
||||
}
|
||||
}
|
||||
self.transport = nil
|
||||
if Task.isCancelled || stopped { break }
|
||||
if let reason = closeRequestedReason {
|
||||
await transport.close()
|
||||
yield(.phase(.failed(reason: reason)))
|
||||
return
|
||||
}
|
||||
attempt += 1
|
||||
if let max = configuration.maxReconnectAttempts, attempt > max {
|
||||
yield(.phase(.failed(reason: "Could not reach server after \(max) attempts")))
|
||||
return
|
||||
}
|
||||
if expectServerReload {
|
||||
// The server told us it is restarting: reconnect eagerly with a
|
||||
// short fixed delay instead of exponential backoff.
|
||||
expectServerReload = false
|
||||
try? await Task.sleep(nanoseconds: UInt64(configuration.baseBackoffSeconds * 500_000_000))
|
||||
continue
|
||||
}
|
||||
let delay = min(
|
||||
configuration.baseBackoffSeconds * pow(2.0, Double(attempt - 1)), 30.0)
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
private func subscribeAndSync() async throws {
|
||||
let sessionID = targetSessionID
|
||||
try await send { .subscribe(id: $0, targetSessionID: sessionID) }
|
||||
try await send { .getHistory(id: $0) }
|
||||
}
|
||||
|
||||
private func receiveLoop(transport: any WebSocketTransport) async throws {
|
||||
while !Task.isCancelled && !stopped {
|
||||
guard let text = try await transport.receiveText() else { return }
|
||||
// A frame may contain multiple newline-delimited events.
|
||||
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
if let event = try? ServerEvent.decode(line: String(line)) {
|
||||
switch event {
|
||||
case .sessionID(let sessionID):
|
||||
targetSessionID = sessionID
|
||||
case .reloading:
|
||||
expectServerReload = true
|
||||
case .sessionCloseRequested(let reason):
|
||||
closeRequestedReason =
|
||||
reason.isEmpty ? "Server closed this session" : reason
|
||||
default:
|
||||
break
|
||||
}
|
||||
yield(.event(event))
|
||||
if closeRequestedReason != nil { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func yield(_ output: ConnectionOutput) {
|
||||
continuation?.yield(output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
|
||||
/// A saved server credential.
|
||||
public struct ServerCredential: Codable, Equatable, Sendable, Identifiable {
|
||||
public var id: String { "\(host):\(port)" }
|
||||
|
||||
public var host: String
|
||||
public var port: UInt16
|
||||
public var token: String
|
||||
public var serverName: String
|
||||
public var serverVersion: String
|
||||
public var pairedAt: Date
|
||||
|
||||
public init(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
token: String,
|
||||
serverName: String,
|
||||
serverVersion: String,
|
||||
pairedAt: Date = Date()
|
||||
) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.token = token
|
||||
self.serverName = serverName
|
||||
self.serverVersion = serverVersion
|
||||
self.pairedAt = pairedAt
|
||||
}
|
||||
|
||||
public var gateway: Gateway {
|
||||
Gateway(host: host, port: port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage for paired-server credentials.
|
||||
public protocol CredentialStore: Sendable {
|
||||
func loadAll() -> [ServerCredential]
|
||||
func save(_ credential: ServerCredential)
|
||||
func remove(id: String)
|
||||
}
|
||||
|
||||
/// Keychain-backed store used on device. All credentials live under one
|
||||
/// keychain item as a JSON array; the auth token is the only secret and the
|
||||
/// set is expected to stay tiny.
|
||||
///
|
||||
/// Falls back to a JSON file in Application Support when the keychain is
|
||||
/// unavailable (e.g. unsigned simulator builds returning
|
||||
/// `errSecMissingEntitlement`). Real signed builds use the keychain.
|
||||
public struct KeychainCredentialStore: CredentialStore {
|
||||
private static let service = "com.jcode.mobile.servers"
|
||||
private static let account = "paired-servers"
|
||||
|
||||
public init() {}
|
||||
|
||||
public func loadAll() -> [ServerCredential] {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let data = result as? Data {
|
||||
return (try? JSONDecoder().decode([ServerCredential].self, from: data)) ?? []
|
||||
}
|
||||
if status != errSecItemNotFound {
|
||||
NSLog("JCodeKit: keychain read failed (%d), trying file fallback", status)
|
||||
}
|
||||
if let data = try? Data(contentsOf: Self.fallbackURL) {
|
||||
return (try? JSONDecoder().decode([ServerCredential].self, from: data)) ?? []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
public func save(_ credential: ServerCredential) {
|
||||
var all = loadAll().filter { $0.id != credential.id }
|
||||
all.append(credential)
|
||||
persist(all)
|
||||
}
|
||||
|
||||
public func remove(id: String) {
|
||||
persist(loadAll().filter { $0.id != id })
|
||||
}
|
||||
|
||||
private func persist(_ credentials: [ServerCredential]) {
|
||||
guard let data = try? JSONEncoder().encode(credentials) else { return }
|
||||
var query = baseQuery()
|
||||
let attributes: [String: Any] = [kSecValueData as String: data]
|
||||
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
status = SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
if status != errSecSuccess {
|
||||
NSLog("JCodeKit: keychain write failed (%d), using file fallback", status)
|
||||
persistToFile(data)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistToFile(_ data: Data) {
|
||||
let url = Self.fallbackURL
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try data.write(to: url, options: [.atomic, .completeFileProtection])
|
||||
} catch {
|
||||
NSLog("JCodeKit: file fallback write failed: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static var fallbackURL: URL {
|
||||
let base =
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
||||
.first ?? FileManager.default.temporaryDirectory
|
||||
return base.appendingPathComponent("jcode-servers.json")
|
||||
}
|
||||
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Self.service,
|
||||
kSecAttrAccount as String: Self.account,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store for tests and previews.
|
||||
public final class InMemoryCredentialStore: CredentialStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var credentials: [ServerCredential] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func loadAll() -> [ServerCredential] {
|
||||
lock.withLock { credentials }
|
||||
}
|
||||
|
||||
public func save(_ credential: ServerCredential) {
|
||||
lock.withLock {
|
||||
credentials.removeAll { $0.id == credential.id }
|
||||
credentials.append(credential)
|
||||
}
|
||||
}
|
||||
|
||||
public func remove(id: String) {
|
||||
lock.withLock {
|
||||
credentials.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
/// Describes a jcode server gateway endpoint.
|
||||
///
|
||||
/// The server exposes:
|
||||
/// - `GET http://host:port/health` reachability + version probe
|
||||
/// - `POST http://host:port/pair` pairing-code -> auth-token exchange
|
||||
/// - `ws://host:port/ws` newline-delimited JSON protocol over WebSocket
|
||||
public struct Gateway: Hashable, Sendable {
|
||||
public static let defaultPort: UInt16 = 7643
|
||||
|
||||
public var host: String
|
||||
public var port: UInt16
|
||||
|
||||
public init(host: String, port: UInt16 = Gateway.defaultPort) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
}
|
||||
|
||||
public var healthURL: URL {
|
||||
url(scheme: "http", path: "/health")
|
||||
}
|
||||
|
||||
public var pairURL: URL {
|
||||
url(scheme: "http", path: "/pair")
|
||||
}
|
||||
|
||||
public var webSocketURL: URL {
|
||||
url(scheme: "ws", path: "/ws")
|
||||
}
|
||||
|
||||
private func url(scheme: String, path: String) -> URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.port = Int(port)
|
||||
components.path = path
|
||||
guard let url = components.url else {
|
||||
// Host strings from pairing are validated before reaching here;
|
||||
// a failure indicates programmer error.
|
||||
preconditionFailure("invalid gateway endpoint: \(scheme)://\(host):\(port)\(path)")
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `jcode://pair?host=H&port=P&code=C` URIs from QR codes.
|
||||
public enum PairURI {
|
||||
public struct Payload: Equatable, Sendable {
|
||||
public var gateway: Gateway
|
||||
public var code: String
|
||||
|
||||
public init(gateway: Gateway, code: String) {
|
||||
self.gateway = gateway
|
||||
self.code = code
|
||||
}
|
||||
}
|
||||
|
||||
public static func parse(_ string: String) -> Payload? {
|
||||
guard let components = URLComponents(string: string),
|
||||
components.scheme == "jcode",
|
||||
components.host == "pair" || components.path == "pair"
|
||||
|| components.host == nil && components.path == "/pair"
|
||||
else { return nil }
|
||||
let items = components.queryItems ?? []
|
||||
func value(_ name: String) -> String? {
|
||||
items.first(where: { $0.name == name })?.value
|
||||
}
|
||||
guard let host = value("host"), !host.isEmpty,
|
||||
let code = value("code"), !code.isEmpty
|
||||
else { return nil }
|
||||
let port = value("port").flatMap(UInt16.init) ?? Gateway.defaultPort
|
||||
return Payload(gateway: Gateway(host: host, port: port), code: code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
/// Exchanges a pairing code for a long-lived auth token via `POST /pair`.
|
||||
public struct PairingClient: Sendable {
|
||||
public struct Response: Equatable, Sendable {
|
||||
public var token: String
|
||||
public var serverName: String
|
||||
public var serverVersion: String
|
||||
|
||||
public init(token: String, serverName: String, serverVersion: String) {
|
||||
self.token = token
|
||||
self.serverName = serverName
|
||||
self.serverVersion = serverVersion
|
||||
}
|
||||
}
|
||||
|
||||
public enum PairingError: Error, Equatable {
|
||||
case invalidCode(String)
|
||||
case serverError(statusCode: Int, message: String)
|
||||
case invalidResponse
|
||||
}
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public func pair(
|
||||
gateway: Gateway,
|
||||
code: String,
|
||||
deviceID: String,
|
||||
deviceName: String
|
||||
) async throws -> Response {
|
||||
var request = URLRequest(url: gateway.pairURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 15
|
||||
let body: [String: String] = [
|
||||
"code": code,
|
||||
"device_id": deviceID,
|
||||
"device_name": deviceName,
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PairingError.invalidResponse
|
||||
}
|
||||
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
|
||||
guard http.statusCode == 200 else {
|
||||
let message = object["error"] as? String ?? "HTTP \(http.statusCode)"
|
||||
if http.statusCode == 401 {
|
||||
throw PairingError.invalidCode(message)
|
||||
}
|
||||
throw PairingError.serverError(statusCode: http.statusCode, message: message)
|
||||
}
|
||||
guard let token = object["token"] as? String, !token.isEmpty else {
|
||||
throw PairingError.invalidResponse
|
||||
}
|
||||
return Response(
|
||||
token: token,
|
||||
serverName: object["server_name"] as? String ?? "jcode",
|
||||
serverVersion: object["server_version"] as? String ?? "unknown"
|
||||
)
|
||||
}
|
||||
|
||||
/// Probes `GET /health`. Returns true when the gateway is reachable.
|
||||
public func checkHealth(gateway: Gateway) async -> Bool {
|
||||
var request = URLRequest(url: gateway.healthURL)
|
||||
request.timeoutInterval = 5
|
||||
guard let (_, response) = try? await session.data(for: request),
|
||||
let http = response as? HTTPURLResponse
|
||||
else { return false }
|
||||
return http.statusCode == 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import Foundation
|
||||
|
||||
/// One entry in the rendered transcript.
|
||||
public struct TranscriptEntry: Equatable, Sendable, Identifiable {
|
||||
public enum Role: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case system
|
||||
}
|
||||
|
||||
public struct ToolCall: Equatable, Sendable, Identifiable {
|
||||
public enum Status: Equatable, Sendable {
|
||||
case streamingInput
|
||||
case running
|
||||
case succeeded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var input: String
|
||||
public var output: String
|
||||
public var status: Status
|
||||
|
||||
public init(
|
||||
id: String, name: String, input: String = "", output: String = "",
|
||||
status: Status = .streamingInput
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.input = input
|
||||
self.output = output
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
|
||||
public var id: UUID
|
||||
public var role: Role
|
||||
public var text: String
|
||||
public var reasoning: String
|
||||
public var toolCalls: [ToolCall]
|
||||
/// True while this entry is still receiving streamed content.
|
||||
public var isStreaming: Bool
|
||||
/// True while this entry is a soft-interrupt waiting for the server to
|
||||
/// inject it at a safe point. Cleared by `soft_interrupt_injected`.
|
||||
public var isQueued: Bool
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
role: Role,
|
||||
text: String,
|
||||
reasoning: String = "",
|
||||
toolCalls: [ToolCall] = [],
|
||||
isStreaming: Bool = false,
|
||||
isQueued: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.text = text
|
||||
self.reasoning = reasoning
|
||||
self.toolCalls = toolCalls
|
||||
self.isStreaming = isStreaming
|
||||
self.isQueued = isQueued
|
||||
}
|
||||
}
|
||||
|
||||
/// A transient, user-dismissible notice surfaced to the UI.
|
||||
///
|
||||
/// Covers out-of-band server signals that must never be silently dropped:
|
||||
/// push notifications, interrupts, and context compaction events.
|
||||
public struct Notice: Equatable, Sendable, Identifiable {
|
||||
public enum Kind: Equatable, Sendable {
|
||||
case info
|
||||
case notification
|
||||
case compaction
|
||||
}
|
||||
|
||||
public var id: UUID
|
||||
public var kind: Kind
|
||||
public var message: String
|
||||
|
||||
public init(id: UUID = UUID(), kind: Kind = .info, message: String) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
/// Full client-side session state derived from server events.
|
||||
public struct SessionState: Equatable, Sendable {
|
||||
public var phase: ConnectionPhase
|
||||
public var transcript: [TranscriptEntry]
|
||||
public var sessionID: String?
|
||||
public var sessionTitle: String?
|
||||
public var allSessions: [String]
|
||||
/// Human titles for sessions in `allSessions`, keyed by session ID.
|
||||
/// Populated from `session_renamed` broadcasts and history payloads.
|
||||
public var sessionTitles: [String: String]
|
||||
public var isProcessing: Bool
|
||||
public var isReasoning: Bool
|
||||
public var modelName: String?
|
||||
public var providerName: String?
|
||||
public var availableModels: [String]
|
||||
public var reasoningEffort: String?
|
||||
public var serverVersion: String?
|
||||
public var tokenInput: UInt64
|
||||
public var tokenOutput: UInt64
|
||||
public var statusDetail: String?
|
||||
/// Live turn-level provider phase (e.g. "authenticating", "waiting").
|
||||
public var serverPhase: String?
|
||||
public var errorBanner: String?
|
||||
public var notices: [Notice]
|
||||
/// Soft-interrupt messages queued mid-run, in send order, until the
|
||||
/// server confirms injection.
|
||||
public var pendingInterrupts: [String]
|
||||
|
||||
public var hasPendingInterrupts: Bool { !pendingInterrupts.isEmpty }
|
||||
|
||||
/// Human title for a session in the list, falling back to nil when unknown.
|
||||
public func title(forSession sessionID: String) -> String? {
|
||||
sessionTitles[sessionID]
|
||||
}
|
||||
|
||||
public init() {
|
||||
phase = .disconnected
|
||||
transcript = []
|
||||
sessionID = nil
|
||||
sessionTitle = nil
|
||||
allSessions = []
|
||||
sessionTitles = [:]
|
||||
isProcessing = false
|
||||
isReasoning = false
|
||||
modelName = nil
|
||||
providerName = nil
|
||||
availableModels = []
|
||||
reasoningEffort = nil
|
||||
serverVersion = nil
|
||||
tokenInput = 0
|
||||
tokenOutput = 0
|
||||
statusDetail = nil
|
||||
serverPhase = nil
|
||||
errorBanner = nil
|
||||
notices = []
|
||||
pendingInterrupts = []
|
||||
}
|
||||
}
|
||||
|
||||
/// Local intents that mutate state without a server round-trip.
|
||||
public enum LocalIntent: Equatable, Sendable {
|
||||
/// User submitted a message; append it optimistically.
|
||||
case userSentMessage(String)
|
||||
/// User queued a soft-interrupt message mid-run.
|
||||
case userQueuedInterrupt(String)
|
||||
/// User cancelled all queued soft-interrupts before they injected; the
|
||||
/// optimistic bubbles are removed because they will never reach the agent.
|
||||
case cancelledQueuedInterrupts
|
||||
/// Dismiss the current error banner.
|
||||
case dismissError
|
||||
/// Dismiss a transient notice by id.
|
||||
case dismissNotice(UUID)
|
||||
/// User cleared the conversation; wipe the transcript optimistically while
|
||||
/// keeping the connection and session metadata.
|
||||
case clearedConversation
|
||||
/// Reset everything (switching servers/sessions).
|
||||
case reset
|
||||
}
|
||||
|
||||
/// Pure state machine turning connection output into session state.
|
||||
///
|
||||
/// All streaming, tool lifecycle, and history-sync behavior lives here so it
|
||||
/// can be exhaustively unit tested on macOS without UI or network.
|
||||
public enum SessionReducer {
|
||||
public static func reduce(_ state: SessionState, _ output: ConnectionOutput) -> SessionState {
|
||||
switch output {
|
||||
case .phase(let phase):
|
||||
return reducePhase(state, phase)
|
||||
case .event(let event):
|
||||
return reduceEvent(state, event)
|
||||
}
|
||||
}
|
||||
|
||||
public static func reduce(_ state: SessionState, intent: LocalIntent) -> SessionState {
|
||||
var state = state
|
||||
switch intent {
|
||||
case .userSentMessage(let text):
|
||||
state.transcript.append(TranscriptEntry(role: .user, text: text))
|
||||
state.isProcessing = true
|
||||
state.errorBanner = nil
|
||||
case .userQueuedInterrupt(let text):
|
||||
state.transcript.append(TranscriptEntry(role: .user, text: text, isQueued: true))
|
||||
state.pendingInterrupts.append(text)
|
||||
case .cancelledQueuedInterrupts:
|
||||
state.transcript.removeAll { $0.isQueued }
|
||||
state.pendingInterrupts = []
|
||||
case .dismissError:
|
||||
state.errorBanner = nil
|
||||
case .dismissNotice(let id):
|
||||
state.notices.removeAll { $0.id == id }
|
||||
case .clearedConversation:
|
||||
state.transcript = []
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.errorBanner = nil
|
||||
case .reset:
|
||||
state = SessionState()
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Phase
|
||||
|
||||
private static func reducePhase(_ state: SessionState, _ phase: ConnectionPhase)
|
||||
-> SessionState
|
||||
{
|
||||
var state = state
|
||||
state.phase = phase
|
||||
switch phase {
|
||||
case .connected:
|
||||
state.errorBanner = nil
|
||||
case .failed(let reason):
|
||||
state.errorBanner = reason
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
case .disconnected, .reconnecting:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
case .connecting:
|
||||
break
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
private static func reduceEvent(_ state: SessionState, _ event: ServerEvent) -> SessionState {
|
||||
var state = state
|
||||
switch event {
|
||||
case .textDelta(let text):
|
||||
withStreamingAssistant(&state) { $0.text += text }
|
||||
|
||||
case .reasoningDelta(let text):
|
||||
state.isReasoning = true
|
||||
withStreamingAssistant(&state) { $0.reasoning += text }
|
||||
|
||||
case .reasoningDone:
|
||||
state.isReasoning = false
|
||||
|
||||
case .textReplace(let text):
|
||||
withStreamingAssistant(&state) { $0.text = text }
|
||||
|
||||
case .toolStart(let id, let name):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
entry.toolCalls.append(.init(id: id, name: name))
|
||||
}
|
||||
|
||||
case .toolInput(let delta):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if !entry.toolCalls.isEmpty {
|
||||
entry.toolCalls[entry.toolCalls.count - 1].input += delta
|
||||
}
|
||||
}
|
||||
|
||||
case .toolExec(let id, let name):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if let index = entry.toolCalls.firstIndex(where: { $0.id == id }) {
|
||||
entry.toolCalls[index].status = .running
|
||||
} else {
|
||||
entry.toolCalls.append(.init(id: id, name: name, status: .running))
|
||||
}
|
||||
}
|
||||
|
||||
case .toolDone(let id, let name, let output, let error):
|
||||
withStreamingAssistant(&state) { entry in
|
||||
if let index = entry.toolCalls.firstIndex(where: { $0.id == id }) {
|
||||
entry.toolCalls[index].output = output
|
||||
entry.toolCalls[index].status = error.map { .failed($0) } ?? .succeeded
|
||||
} else {
|
||||
entry.toolCalls.append(
|
||||
.init(
|
||||
id: id, name: name, output: output,
|
||||
status: error.map { .failed($0) } ?? .succeeded
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
case .messageEnd:
|
||||
finishStreaming(&state)
|
||||
|
||||
case .connectionPhase(let phase):
|
||||
state.serverPhase = phase.isEmpty ? nil : phase
|
||||
|
||||
case .softInterruptInjected(let content, let displayRole, _, _):
|
||||
resolvePendingInterrupt(&state, content: content, displayRole: displayRole)
|
||||
|
||||
case .retryRollback(let attempt, let max):
|
||||
// The provider is replaying the response from the top: discard all
|
||||
// partial output from the current attempt so it does not duplicate.
|
||||
if let last = state.transcript.indices.last, state.transcript[last].isStreaming {
|
||||
state.transcript.removeLast()
|
||||
}
|
||||
state.isReasoning = false
|
||||
state.statusDetail = "Retrying (\(attempt)/\(max))"
|
||||
|
||||
case .done:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
drainPendingInterrupts(&state)
|
||||
|
||||
case .interrupted:
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
drainPendingInterrupts(&state)
|
||||
state.notices.append(Notice(message: "Interrupted"))
|
||||
|
||||
case .error(_, let message, let retryAfterSecs):
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
state.serverPhase = nil
|
||||
finishStreaming(&state)
|
||||
if let retry = retryAfterSecs {
|
||||
state.errorBanner = "\(message) (retry in \(retry)s)"
|
||||
} else {
|
||||
state.errorBanner = message
|
||||
}
|
||||
|
||||
case .tokenUsage(let input, let output):
|
||||
state.tokenInput = input
|
||||
state.tokenOutput = output
|
||||
|
||||
case .statusDetail(let detail):
|
||||
state.statusDetail = detail
|
||||
|
||||
case .state(_, let sessionID, _, let isProcessing):
|
||||
state.sessionID = sessionID
|
||||
state.isProcessing = isProcessing
|
||||
|
||||
case .sessionID(let sessionID):
|
||||
state.sessionID = sessionID
|
||||
|
||||
case .sessionRenamed(let sessionID, let displayTitle):
|
||||
state.sessionTitles[sessionID] = displayTitle
|
||||
if state.sessionID == nil || state.sessionID == sessionID {
|
||||
state.sessionTitle = displayTitle
|
||||
}
|
||||
|
||||
case .history(let payload):
|
||||
state = applyHistory(state, payload)
|
||||
|
||||
case .modelChanged(_, let model, let error):
|
||||
if let error {
|
||||
state.errorBanner = error
|
||||
} else {
|
||||
state.modelName = model
|
||||
}
|
||||
|
||||
case .reasoningEffortChanged(_, let effort, let error):
|
||||
if let error {
|
||||
state.errorBanner = error
|
||||
} else {
|
||||
state.reasoningEffort = effort
|
||||
}
|
||||
|
||||
case .compactResult(_, let message, let success):
|
||||
if success {
|
||||
state.notices.append(Notice(kind: .compaction, message: message))
|
||||
} else {
|
||||
state.errorBanner = message
|
||||
}
|
||||
|
||||
case .availableModelsUpdated(let models, let providerModel):
|
||||
state.availableModels = models
|
||||
if let providerModel {
|
||||
state.modelName = providerModel
|
||||
}
|
||||
|
||||
case .compaction(let trigger, let tokensSaved):
|
||||
if let saved = tokensSaved, trigger != "background" {
|
||||
state.notices.append(
|
||||
Notice(kind: .compaction, message: "Context compacted (\(saved) tokens saved)"))
|
||||
}
|
||||
|
||||
case .notification(let fromName, let message):
|
||||
let prefix = fromName.map { "\($0): " } ?? ""
|
||||
state.notices.append(Notice(kind: .notification, message: prefix + message))
|
||||
|
||||
case .reloading:
|
||||
state.notices.append(Notice(message: "Server is updating, reconnecting shortly"))
|
||||
|
||||
case .sessionCloseRequested(let reason):
|
||||
state.isProcessing = false
|
||||
state.isReasoning = false
|
||||
finishStreaming(&state)
|
||||
state.errorBanner = reason.isEmpty ? "Server closed this session" : reason
|
||||
|
||||
case .ack, .pong, .unknown:
|
||||
break
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private static func applyHistory(
|
||||
_ state: SessionState, _ payload: ServerEvent.HistoryPayload
|
||||
) -> SessionState {
|
||||
var state = state
|
||||
state.sessionID = payload.sessionID
|
||||
state.providerName = payload.providerName ?? state.providerName
|
||||
state.modelName = payload.providerModel ?? state.modelName
|
||||
if !payload.availableModels.isEmpty {
|
||||
state.availableModels = payload.availableModels
|
||||
}
|
||||
if !payload.allSessions.isEmpty {
|
||||
state.allSessions = payload.allSessions
|
||||
}
|
||||
state.serverVersion = payload.serverVersion ?? state.serverVersion
|
||||
state.sessionTitle = payload.displayTitle ?? state.sessionTitle
|
||||
state.reasoningEffort = payload.reasoningEffort ?? state.reasoningEffort
|
||||
if let title = payload.displayTitle {
|
||||
state.sessionTitles[payload.sessionID] = title
|
||||
}
|
||||
// History replaces the transcript wholesale, so optimistic queued
|
||||
// bubbles are rebuilt from the server's authoritative view.
|
||||
state.pendingInterrupts = []
|
||||
if let totals = payload.totalTokens {
|
||||
state.tokenInput = totals.input
|
||||
state.tokenOutput = totals.output
|
||||
}
|
||||
|
||||
// History replaces the transcript wholesale: it is the server's
|
||||
// authoritative view, used on connect and reconnect.
|
||||
state.transcript = payload.messages.compactMap { message in
|
||||
let role: TranscriptEntry.Role
|
||||
switch message.role {
|
||||
case "user": role = .user
|
||||
case "assistant": role = .assistant
|
||||
case "system": role = .system
|
||||
default: return nil
|
||||
}
|
||||
var toolCalls: [TranscriptEntry.ToolCall] = []
|
||||
if let data = message.toolData {
|
||||
toolCalls.append(
|
||||
.init(
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
input: data.input,
|
||||
output: data.output ?? "",
|
||||
status: data.error.map { .failed($0) }
|
||||
?? (data.output != nil ? .succeeded : .running)
|
||||
))
|
||||
} else {
|
||||
toolCalls = message.toolCalls.map { name in
|
||||
.init(id: name, name: name, status: .succeeded)
|
||||
}
|
||||
}
|
||||
// Skip empty assistant placeholders.
|
||||
if message.content.isEmpty && toolCalls.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return TranscriptEntry(role: role, text: message.content, toolCalls: toolCalls)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Marks the matching queued soft-interrupt as delivered. If the injection
|
||||
/// came from another client (no optimistic bubble), appends the content so
|
||||
/// the transcript still reflects what the agent saw.
|
||||
private static func resolvePendingInterrupt(
|
||||
_ state: inout SessionState, content: String, displayRole: String?
|
||||
) {
|
||||
if let index = state.pendingInterrupts.firstIndex(of: content) {
|
||||
state.pendingInterrupts.remove(at: index)
|
||||
}
|
||||
if let index = state.transcript.firstIndex(where: { $0.isQueued && $0.text == content })
|
||||
?? state.transcript.firstIndex(where: { $0.isQueued })
|
||||
{
|
||||
state.transcript[index].isQueued = false
|
||||
} else {
|
||||
let role: TranscriptEntry.Role = displayRole == "system" ? .system : .user
|
||||
state.transcript.append(TranscriptEntry(role: role, text: content))
|
||||
}
|
||||
}
|
||||
|
||||
/// The turn is over: anything still marked queued has either been consumed
|
||||
/// server-side or is moot, so stop showing it as pending.
|
||||
private static func drainPendingInterrupts(_ state: inout SessionState) {
|
||||
state.pendingInterrupts = []
|
||||
for index in state.transcript.indices where state.transcript[index].isQueued {
|
||||
state.transcript[index].isQueued = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutates the trailing streaming assistant entry, creating it if needed.
|
||||
private static func withStreamingAssistant(
|
||||
_ state: inout SessionState, _ mutate: (inout TranscriptEntry) -> Void
|
||||
) {
|
||||
if let last = state.transcript.indices.last,
|
||||
state.transcript[last].role == .assistant,
|
||||
state.transcript[last].isStreaming
|
||||
{
|
||||
mutate(&state.transcript[last])
|
||||
} else {
|
||||
var entry = TranscriptEntry(role: .assistant, text: "", isStreaming: true)
|
||||
mutate(&entry)
|
||||
state.transcript.append(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private static func finishStreaming(_ state: inout SessionState) {
|
||||
if let last = state.transcript.indices.last, state.transcript[last].isStreaming {
|
||||
state.transcript[last].isStreaming = false
|
||||
// Drop fully-empty assistant stubs (e.g. tool-only turns that
|
||||
// were replaced or cancelled before any text arrived).
|
||||
if state.transcript[last].text.isEmpty
|
||||
&& state.transcript[last].toolCalls.isEmpty
|
||||
&& state.transcript[last].reasoning.isEmpty
|
||||
{
|
||||
state.transcript.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
/// Abstraction over a WebSocket so `Connection` is testable with a fake.
|
||||
public protocol WebSocketTransport: Sendable {
|
||||
/// Opens the socket. Throws if the connection or auth fails.
|
||||
func connect(url: URL, authToken: String) async throws
|
||||
/// Sends one text frame.
|
||||
func send(text: String) async throws
|
||||
/// Receives the next text frame. Returns nil when the socket closes.
|
||||
func receiveText() async throws -> String?
|
||||
/// Closes the socket.
|
||||
func close() async
|
||||
}
|
||||
|
||||
/// URLSession-backed transport used in production.
|
||||
///
|
||||
/// Auth is sent via `Authorization: Bearer <token>` on the upgrade request,
|
||||
/// matching the gateway's preferred auth source.
|
||||
public actor URLSessionWebSocketTransport: WebSocketTransport {
|
||||
private var task: URLSessionWebSocketTask?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func connect(url: URL, authToken: String) async throws {
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
request.timeoutInterval = 10
|
||||
let task = URLSession.shared.webSocketTask(with: request)
|
||||
task.resume()
|
||||
self.task = task
|
||||
// Force the handshake to complete (and surface auth failures) by
|
||||
// sending a WebSocket-level ping before declaring success.
|
||||
do {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
task.sendPing { error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The gateway rejects unknown/revoked tokens at the upgrade with
|
||||
// 401. Surface that distinctly so the connection loop can stop
|
||||
// retrying and prompt a re-pair instead of backing off forever.
|
||||
if let http = task.response as? HTTPURLResponse, http.statusCode == 401 {
|
||||
throw TransportError.unauthorized
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func send(text: String) async throws {
|
||||
guard let task else { throw TransportError.notConnected }
|
||||
try await task.send(.string(text))
|
||||
}
|
||||
|
||||
public func receiveText() async throws -> String? {
|
||||
guard let task else { throw TransportError.notConnected }
|
||||
while true {
|
||||
let message: URLSessionWebSocketTask.Message
|
||||
do {
|
||||
message = try await task.receive()
|
||||
} catch {
|
||||
// Treat close as end-of-stream rather than error when the
|
||||
// task reports a normal closure.
|
||||
if task.closeCode != .invalid {
|
||||
return nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
switch message {
|
||||
case .string(let text):
|
||||
return text
|
||||
case .data(let data):
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
return text
|
||||
}
|
||||
@unknown default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func close() async {
|
||||
task?.cancel(with: .normalClosure, reason: nil)
|
||||
task = nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum TransportError: Error, Equatable {
|
||||
case notConnected
|
||||
/// The server rejected the WebSocket upgrade as unauthorized (401):
|
||||
/// the pairing token is unknown or was revoked. Reconnecting with the
|
||||
/// same token cannot succeed; the device must re-pair.
|
||||
case unauthorized
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import Foundation
|
||||
|
||||
/// Client request to a jcode server.
|
||||
///
|
||||
/// Wire format mirrors `crates/jcode-protocol/src/wire.rs` (`#[serde(tag = "type")]`,
|
||||
/// snake_case tags). Only the requests the iOS app uses are modeled; the server
|
||||
/// ignores fields it does not expect.
|
||||
public enum Request: Equatable, Sendable {
|
||||
case subscribe(id: UInt64, targetSessionID: String?)
|
||||
case message(id: UInt64, content: String)
|
||||
case cancel(id: UInt64)
|
||||
case softInterrupt(id: UInt64, content: String, urgent: Bool)
|
||||
case cancelSoftInterrupts(id: UInt64)
|
||||
case ping(id: UInt64)
|
||||
case getHistory(id: UInt64)
|
||||
case resumeSession(id: UInt64, sessionID: String)
|
||||
case setModel(id: UInt64, model: String)
|
||||
case setReasoningEffort(id: UInt64, effort: String)
|
||||
case compact(id: UInt64)
|
||||
case renameSession(id: UInt64, title: String?)
|
||||
case clear(id: UInt64)
|
||||
|
||||
public var id: UInt64 {
|
||||
switch self {
|
||||
case let .subscribe(id, _), let .message(id, _), let .cancel(id),
|
||||
let .softInterrupt(id, _, _), let .cancelSoftInterrupts(id),
|
||||
let .ping(id), let .getHistory(id), let .resumeSession(id, _),
|
||||
let .setModel(id, _), let .setReasoningEffort(id, _), let .compact(id),
|
||||
let .renameSession(id, _), let .clear(id):
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the request as a single JSON line (no trailing newline).
|
||||
public func encodedLine() throws -> String {
|
||||
var object: [String: Any] = ["id": id]
|
||||
switch self {
|
||||
case let .subscribe(_, targetSessionID):
|
||||
object["type"] = "subscribe"
|
||||
if let targetSessionID {
|
||||
object["target_session_id"] = targetSessionID
|
||||
}
|
||||
case let .message(_, content):
|
||||
object["type"] = "message"
|
||||
object["content"] = content
|
||||
case .cancel:
|
||||
object["type"] = "cancel"
|
||||
case let .softInterrupt(_, content, urgent):
|
||||
object["type"] = "soft_interrupt"
|
||||
object["content"] = content
|
||||
object["urgent"] = urgent
|
||||
case .cancelSoftInterrupts:
|
||||
object["type"] = "cancel_soft_interrupts"
|
||||
case .ping:
|
||||
object["type"] = "ping"
|
||||
case .getHistory:
|
||||
object["type"] = "get_history"
|
||||
case let .resumeSession(_, sessionID):
|
||||
object["type"] = "resume_session"
|
||||
object["session_id"] = sessionID
|
||||
case let .setModel(_, model):
|
||||
object["type"] = "set_model"
|
||||
object["model"] = model
|
||||
case let .setReasoningEffort(_, effort):
|
||||
object["type"] = "set_reasoning_effort"
|
||||
object["effort"] = effort
|
||||
case .compact:
|
||||
object["type"] = "compact"
|
||||
case let .renameSession(_, title):
|
||||
object["type"] = "rename_session"
|
||||
if let title {
|
||||
object["title"] = title
|
||||
}
|
||||
case .clear:
|
||||
object["type"] = "clear"
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
|
||||
guard let line = String(data: data, encoding: .utf8) else {
|
||||
throw WireError.encodingFailed
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool call as represented in history payloads.
|
||||
public struct ToolCallRecord: Equatable, Sendable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var input: String
|
||||
public var output: String?
|
||||
public var error: String?
|
||||
|
||||
public init(id: String, name: String, input: String, output: String?, error: String?) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.input = input
|
||||
self.output = output
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
/// A message in conversation history (response to `get_history`).
|
||||
public struct HistoryMessage: Equatable, Sendable {
|
||||
public var role: String
|
||||
public var content: String
|
||||
public var toolCalls: [String]
|
||||
public var toolData: ToolCallRecord?
|
||||
|
||||
public init(
|
||||
role: String, content: String, toolCalls: [String] = [], toolData: ToolCallRecord? = nil
|
||||
) {
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.toolCalls = toolCalls
|
||||
self.toolData = toolData
|
||||
}
|
||||
}
|
||||
|
||||
/// Server event from a jcode server. Unknown event types decode as `.unknown`
|
||||
/// so newer servers never break older apps.
|
||||
public enum ServerEvent: Equatable, Sendable {
|
||||
case ack(id: UInt64)
|
||||
case textDelta(text: String)
|
||||
case reasoningDelta(text: String)
|
||||
case reasoningDone(durationSecs: Double?)
|
||||
case textReplace(text: String)
|
||||
case toolStart(id: String, name: String)
|
||||
case toolInput(delta: String)
|
||||
case toolExec(id: String, name: String)
|
||||
case toolDone(id: String, name: String, output: String, error: String?)
|
||||
case tokenUsage(input: UInt64, output: UInt64)
|
||||
case statusDetail(detail: String)
|
||||
case connectionPhase(phase: String)
|
||||
case softInterruptInjected(content: String, displayRole: String?, point: String, toolsSkipped: Int?)
|
||||
case retryRollback(attempt: Int, max: Int)
|
||||
case messageEnd
|
||||
case interrupted
|
||||
case done(id: UInt64)
|
||||
case error(id: UInt64, message: String, retryAfterSecs: UInt64?)
|
||||
case pong(id: UInt64)
|
||||
case state(id: UInt64, sessionID: String, messageCount: Int, isProcessing: Bool)
|
||||
case sessionID(sessionID: String)
|
||||
case sessionRenamed(sessionID: String, displayTitle: String)
|
||||
case history(HistoryPayload)
|
||||
case modelChanged(id: UInt64, model: String, error: String?)
|
||||
case reasoningEffortChanged(id: UInt64, effort: String?, error: String?)
|
||||
case compactResult(id: UInt64, message: String, success: Bool)
|
||||
case availableModelsUpdated(models: [String], providerModel: String?)
|
||||
case compaction(trigger: String, tokensSaved: UInt64?)
|
||||
case notification(fromName: String?, message: String)
|
||||
case reloading(newSocket: String?)
|
||||
case sessionCloseRequested(reason: String)
|
||||
case unknown(type: String)
|
||||
|
||||
public struct HistoryPayload: Equatable, Sendable {
|
||||
public var id: UInt64
|
||||
public var sessionID: String
|
||||
public var messages: [HistoryMessage]
|
||||
public var providerName: String?
|
||||
public var providerModel: String?
|
||||
public var availableModels: [String]
|
||||
public var totalTokens: TokenTotals?
|
||||
public var allSessions: [String]
|
||||
public var serverVersion: String?
|
||||
public var displayTitle: String?
|
||||
public var reasoningEffort: String?
|
||||
|
||||
public struct TokenTotals: Equatable, Sendable {
|
||||
public var input: UInt64
|
||||
public var output: UInt64
|
||||
|
||||
public init(input: UInt64, output: UInt64) {
|
||||
self.input = input
|
||||
self.output = output
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
id: UInt64,
|
||||
sessionID: String,
|
||||
messages: [HistoryMessage],
|
||||
providerName: String? = nil,
|
||||
providerModel: String? = nil,
|
||||
availableModels: [String] = [],
|
||||
totalTokens: TokenTotals? = nil,
|
||||
allSessions: [String] = [],
|
||||
serverVersion: String? = nil,
|
||||
displayTitle: String? = nil,
|
||||
reasoningEffort: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.sessionID = sessionID
|
||||
self.messages = messages
|
||||
self.providerName = providerName
|
||||
self.providerModel = providerModel
|
||||
self.availableModels = availableModels
|
||||
self.totalTokens = totalTokens
|
||||
self.allSessions = allSessions
|
||||
self.serverVersion = serverVersion
|
||||
self.displayTitle = displayTitle
|
||||
self.reasoningEffort = reasoningEffort
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes one newline-delimited JSON event line.
|
||||
public static func decode(line: String) throws -> ServerEvent {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data),
|
||||
let object = parsed as? [String: Any]
|
||||
else {
|
||||
throw WireError.invalidJSON(line: line)
|
||||
}
|
||||
guard let type = object["type"] as? String else {
|
||||
throw WireError.missingType(line: line)
|
||||
}
|
||||
let json = JSONObject(object)
|
||||
switch type {
|
||||
case "ack":
|
||||
return .ack(id: json.uint64("id"))
|
||||
case "text_delta":
|
||||
return .textDelta(text: json.string("text"))
|
||||
case "reasoning_delta":
|
||||
return .reasoningDelta(text: json.string("text"))
|
||||
case "reasoning_done":
|
||||
return .reasoningDone(durationSecs: json.optionalDouble("duration_secs"))
|
||||
case "text_replace":
|
||||
return .textReplace(text: json.string("text"))
|
||||
case "tool_start":
|
||||
return .toolStart(id: json.string("id"), name: json.string("name"))
|
||||
case "tool_input":
|
||||
return .toolInput(delta: json.string("delta"))
|
||||
case "tool_exec":
|
||||
return .toolExec(id: json.string("id"), name: json.string("name"))
|
||||
case "tool_done":
|
||||
return .toolDone(
|
||||
id: json.string("id"),
|
||||
name: json.string("name"),
|
||||
output: json.string("output"),
|
||||
error: json.optionalString("error")
|
||||
)
|
||||
case "tokens":
|
||||
return .tokenUsage(input: json.uint64("input"), output: json.uint64("output"))
|
||||
case "status_detail":
|
||||
return .statusDetail(detail: json.string("detail"))
|
||||
case "connection_phase":
|
||||
return .connectionPhase(phase: json.string("phase"))
|
||||
case "soft_interrupt_injected":
|
||||
return .softInterruptInjected(
|
||||
content: json.string("content"),
|
||||
displayRole: json.optionalString("display_role"),
|
||||
point: json.string("point"),
|
||||
toolsSkipped: json.optionalInt("tools_skipped")
|
||||
)
|
||||
case "retry_rollback":
|
||||
return .retryRollback(attempt: json.int("attempt"), max: json.int("max"))
|
||||
case "message_end":
|
||||
return .messageEnd
|
||||
case "interrupted":
|
||||
return .interrupted
|
||||
case "done":
|
||||
return .done(id: json.uint64("id"))
|
||||
case "error":
|
||||
return .error(
|
||||
id: json.uint64("id"),
|
||||
message: json.string("message"),
|
||||
retryAfterSecs: json.optionalUInt64("retry_after_secs")
|
||||
)
|
||||
case "pong":
|
||||
return .pong(id: json.uint64("id"))
|
||||
case "state":
|
||||
return .state(
|
||||
id: json.uint64("id"),
|
||||
sessionID: json.string("session_id"),
|
||||
messageCount: json.int("message_count"),
|
||||
isProcessing: json.bool("is_processing")
|
||||
)
|
||||
case "session":
|
||||
return .sessionID(sessionID: json.string("session_id"))
|
||||
case "session_renamed":
|
||||
return .sessionRenamed(
|
||||
sessionID: json.string("session_id"),
|
||||
displayTitle: json.string("display_title")
|
||||
)
|
||||
case "history":
|
||||
return .history(decodeHistory(json))
|
||||
case "model_changed":
|
||||
return .modelChanged(
|
||||
id: json.uint64("id"),
|
||||
model: json.string("model"),
|
||||
error: json.optionalString("error")
|
||||
)
|
||||
case "reasoning_effort_changed":
|
||||
return .reasoningEffortChanged(
|
||||
id: json.uint64("id"),
|
||||
effort: json.optionalString("effort"),
|
||||
error: json.optionalString("error")
|
||||
)
|
||||
case "compact_result":
|
||||
return .compactResult(
|
||||
id: json.uint64("id"),
|
||||
message: json.string("message"),
|
||||
success: json.bool("success")
|
||||
)
|
||||
case "available_models_updated":
|
||||
return .availableModelsUpdated(
|
||||
models: json.stringArray("available_models"),
|
||||
providerModel: json.optionalString("provider_model")
|
||||
)
|
||||
case "compaction":
|
||||
return .compaction(
|
||||
trigger: json.string("trigger"),
|
||||
tokensSaved: json.optionalUInt64("tokens_saved")
|
||||
)
|
||||
case "notification":
|
||||
return .notification(
|
||||
fromName: json.optionalString("from_name"),
|
||||
message: json.string("message")
|
||||
)
|
||||
case "reloading":
|
||||
return .reloading(newSocket: json.optionalString("new_socket"))
|
||||
case "session_close_requested":
|
||||
return .sessionCloseRequested(reason: json.string("reason"))
|
||||
default:
|
||||
return .unknown(type: type)
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeHistory(_ json: JSONObject) -> HistoryPayload {
|
||||
let messages = json.objectArray("messages").map { msg -> HistoryMessage in
|
||||
var toolData: ToolCallRecord?
|
||||
if let td = msg.optionalObject("tool_data") {
|
||||
toolData = ToolCallRecord(
|
||||
id: td.string("id"),
|
||||
name: td.string("name"),
|
||||
input: td.string("input"),
|
||||
output: td.optionalString("output"),
|
||||
error: td.optionalString("error")
|
||||
)
|
||||
}
|
||||
return HistoryMessage(
|
||||
role: msg.string("role"),
|
||||
content: msg.string("content"),
|
||||
toolCalls: msg.stringArray("tool_calls"),
|
||||
toolData: toolData
|
||||
)
|
||||
}
|
||||
var totals: HistoryPayload.TokenTotals?
|
||||
if let pair = json.raw["total_tokens"] as? [Any], pair.count == 2,
|
||||
let input = JSONObject.coerceUInt64(pair[0]),
|
||||
let output = JSONObject.coerceUInt64(pair[1])
|
||||
{
|
||||
totals = HistoryPayload.TokenTotals(input: input, output: output)
|
||||
}
|
||||
return HistoryPayload(
|
||||
id: json.uint64("id"),
|
||||
sessionID: json.string("session_id"),
|
||||
messages: messages,
|
||||
providerName: json.optionalString("provider_name"),
|
||||
providerModel: json.optionalString("provider_model"),
|
||||
availableModels: json.stringArray("available_models"),
|
||||
totalTokens: totals,
|
||||
allSessions: json.stringArray("all_sessions"),
|
||||
serverVersion: json.optionalString("server_version"),
|
||||
displayTitle: json.optionalString("display_title"),
|
||||
reasoningEffort: json.optionalString("reasoning_effort")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum WireError: Error, Equatable {
|
||||
case encodingFailed
|
||||
case invalidJSON(line: String)
|
||||
case missingType(line: String)
|
||||
}
|
||||
|
||||
/// Lenient JSON accessor. The wire protocol omits absent optionals and the
|
||||
/// app must never crash on a server that is newer or older than itself.
|
||||
struct JSONObject {
|
||||
let raw: [String: Any]
|
||||
|
||||
init(_ raw: [String: Any]) {
|
||||
self.raw = raw
|
||||
}
|
||||
|
||||
func string(_ key: String) -> String {
|
||||
raw[key] as? String ?? ""
|
||||
}
|
||||
|
||||
func optionalString(_ key: String) -> String? {
|
||||
raw[key] as? String
|
||||
}
|
||||
|
||||
func bool(_ key: String) -> Bool {
|
||||
raw[key] as? Bool ?? false
|
||||
}
|
||||
|
||||
func int(_ key: String) -> Int {
|
||||
(raw[key] as? NSNumber)?.intValue ?? 0
|
||||
}
|
||||
|
||||
func optionalInt(_ key: String) -> Int? {
|
||||
(raw[key] as? NSNumber)?.intValue
|
||||
}
|
||||
|
||||
func uint64(_ key: String) -> UInt64 {
|
||||
Self.coerceUInt64(raw[key] ?? 0) ?? 0
|
||||
}
|
||||
|
||||
func optionalUInt64(_ key: String) -> UInt64? {
|
||||
raw[key].flatMap(Self.coerceUInt64)
|
||||
}
|
||||
|
||||
func optionalDouble(_ key: String) -> Double? {
|
||||
(raw[key] as? NSNumber)?.doubleValue
|
||||
}
|
||||
|
||||
func stringArray(_ key: String) -> [String] {
|
||||
raw[key] as? [String] ?? []
|
||||
}
|
||||
|
||||
func objectArray(_ key: String) -> [JSONObject] {
|
||||
(raw[key] as? [[String: Any]])?.map(JSONObject.init) ?? []
|
||||
}
|
||||
|
||||
func optionalObject(_ key: String) -> JSONObject? {
|
||||
(raw[key] as? [String: Any]).map(JSONObject.init)
|
||||
}
|
||||
|
||||
static func coerceUInt64(_ value: Any) -> UInt64? {
|
||||
if let number = value as? NSNumber {
|
||||
return number.uint64Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import JCodeKit
|
||||
import Observation
|
||||
|
||||
/// Observable glue between JCodeKit and the SwiftUI views.
|
||||
///
|
||||
/// Owns the credential store, the active `Connection`, and the derived
|
||||
/// `SessionState`. Contains no protocol or state-transition logic itself;
|
||||
/// everything flows through `SessionReducer`.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppModel {
|
||||
// MARK: - Published state
|
||||
|
||||
private(set) var session = SessionState()
|
||||
private(set) var servers: [ServerCredential] = []
|
||||
var activeServer: ServerCredential?
|
||||
|
||||
/// Composer draft.
|
||||
var draft = ""
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private let store: any CredentialStore
|
||||
private var connection: Connection?
|
||||
private var pumpTask: Task<Void, Never>?
|
||||
|
||||
init(store: any CredentialStore = KeychainCredentialStore()) {
|
||||
self.store = store
|
||||
servers = store.loadAll()
|
||||
activeServer = servers.last
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
session.phase == .connected
|
||||
}
|
||||
|
||||
// MARK: - Pairing
|
||||
|
||||
func pair(gateway: Gateway, code: String, deviceName: String) async throws {
|
||||
let client = PairingClient()
|
||||
let response = try await client.pair(
|
||||
gateway: gateway,
|
||||
code: code,
|
||||
deviceID: deviceID(),
|
||||
deviceName: deviceName
|
||||
)
|
||||
let credential = ServerCredential(
|
||||
host: gateway.host,
|
||||
port: gateway.port,
|
||||
token: response.token,
|
||||
serverName: response.serverName,
|
||||
serverVersion: response.serverVersion
|
||||
)
|
||||
store.save(credential)
|
||||
servers = store.loadAll()
|
||||
activeServer = credential
|
||||
connect(to: credential)
|
||||
}
|
||||
|
||||
func removeServer(_ credential: ServerCredential) {
|
||||
store.remove(id: credential.id)
|
||||
servers = store.loadAll()
|
||||
if activeServer?.id == credential.id {
|
||||
disconnect()
|
||||
activeServer = servers.last
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection lifecycle
|
||||
|
||||
func connect(to credential: ServerCredential, sessionID: String? = nil) {
|
||||
session = SessionState()
|
||||
open(credential, sessionID: sessionID)
|
||||
}
|
||||
|
||||
/// Reconnects to the active server without discarding the rendered
|
||||
/// transcript; the history resync replaces it once the socket is back.
|
||||
func retryConnection() {
|
||||
guard let activeServer else { return }
|
||||
open(activeServer, sessionID: session.sessionID)
|
||||
}
|
||||
|
||||
private func open(_ credential: ServerCredential, sessionID: String?) {
|
||||
disconnect()
|
||||
activeServer = credential
|
||||
let connection = Connection(
|
||||
configuration: .init(
|
||||
gateway: credential.gateway,
|
||||
authToken: credential.token
|
||||
)
|
||||
)
|
||||
self.connection = connection
|
||||
pumpTask = Task { [weak self] in
|
||||
let stream = await connection.start(resumeSessionID: sessionID)
|
||||
for await output in stream {
|
||||
guard let self else { return }
|
||||
self.session = SessionReducer.reduce(self.session, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
pumpTask?.cancel()
|
||||
pumpTask = nil
|
||||
let connection = connection
|
||||
self.connection = nil
|
||||
Task { await connection?.stop() }
|
||||
session = SessionReducer.reduce(session, .phase(.disconnected))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func sendDraft() {
|
||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
draft = ""
|
||||
if session.isProcessing {
|
||||
session = SessionReducer.reduce(session, intent: .userQueuedInterrupt(text))
|
||||
send { .softInterrupt(id: $0, content: text, urgent: false) }
|
||||
} else {
|
||||
session = SessionReducer.reduce(session, intent: .userSentMessage(text))
|
||||
send { .message(id: $0, content: text) }
|
||||
}
|
||||
}
|
||||
|
||||
func interrupt() {
|
||||
send { .cancel(id: $0) }
|
||||
}
|
||||
|
||||
func switchSession(_ sessionID: String) {
|
||||
guard let activeServer else { return }
|
||||
connect(to: activeServer, sessionID: sessionID)
|
||||
}
|
||||
|
||||
func setModel(_ model: String) {
|
||||
send { .setModel(id: $0, model: model) }
|
||||
}
|
||||
|
||||
func setReasoningEffort(_ effort: String) {
|
||||
send { .setReasoningEffort(id: $0, effort: effort) }
|
||||
}
|
||||
|
||||
/// Asks the server to compact the conversation context.
|
||||
func compactConversation() {
|
||||
send { .compact(id: $0) }
|
||||
}
|
||||
|
||||
func renameSession(_ title: String) {
|
||||
send { .renameSession(id: $0, title: title.isEmpty ? nil : title) }
|
||||
}
|
||||
|
||||
func dismissError() {
|
||||
session = SessionReducer.reduce(session, intent: .dismissError)
|
||||
}
|
||||
|
||||
func dismissNotice(_ id: UUID) {
|
||||
session = SessionReducer.reduce(session, intent: .dismissNotice(id))
|
||||
}
|
||||
|
||||
/// Clears the current conversation on the server and optimistically locally.
|
||||
func clearConversation() {
|
||||
session = SessionReducer.reduce(session, intent: .clearedConversation)
|
||||
send { .clear(id: $0) }
|
||||
}
|
||||
|
||||
/// Drops any soft-interrupt messages queued mid-run before they inject.
|
||||
func cancelQueuedInterrupts() {
|
||||
session = SessionReducer.reduce(session, intent: .cancelledQueuedInterrupts)
|
||||
send { .cancelSoftInterrupts(id: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func send(_ build: @escaping @Sendable (UInt64) -> Request) {
|
||||
guard let connection else { return }
|
||||
Task {
|
||||
do {
|
||||
try await connection.send(build)
|
||||
} catch {
|
||||
// Connection drops surface via phase changes; nothing to do here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deviceID() -> String {
|
||||
let key = "jcode.device.id"
|
||||
if let existing = UserDefaults.standard.string(forKey: key) {
|
||||
return existing
|
||||
}
|
||||
let fresh = UUID().uuidString
|
||||
UserDefaults.standard.set(fresh, forKey: key)
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "AppIcon.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0x14",
|
||||
"green": "0x0F",
|
||||
"red": "0x0F"
|
||||
}
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>jcode</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.jcode.mobile.pair</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>jcode</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan the QR code shown by `jcode pair` to connect to your server.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Connects to jcode servers on your local network or tailnet.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct JCodeMobileApp: App {
|
||||
@State private var model = AppModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(model)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Lightweight markdown renderer for assistant messages.
|
||||
///
|
||||
/// Handles fenced code blocks as monospaced cards and renders everything else
|
||||
/// through SwiftUI's native AttributedString markdown (bold, italics, inline
|
||||
/// code, links). Deliberately not a full CommonMark implementation.
|
||||
struct MarkdownText: View {
|
||||
let text: String
|
||||
|
||||
init(_ text: String) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in
|
||||
switch segment {
|
||||
case .prose(let prose):
|
||||
Text(attributed(prose))
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
case .code(let code, _):
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(code)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(12)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Segment {
|
||||
case prose(String)
|
||||
case code(String, language: String?)
|
||||
}
|
||||
|
||||
private var segments: [Segment] {
|
||||
var result: [Segment] = []
|
||||
var prose: [String] = []
|
||||
var code: [String] = []
|
||||
var language: String?
|
||||
var inCode = false
|
||||
|
||||
for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
if line.hasPrefix("```") {
|
||||
if inCode {
|
||||
result.append(.code(code.joined(separator: "\n"), language: language))
|
||||
code = []
|
||||
inCode = false
|
||||
} else {
|
||||
let joined = prose.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !joined.isEmpty {
|
||||
result.append(.prose(joined))
|
||||
}
|
||||
prose = []
|
||||
language = line.dropFirst(3).isEmpty ? nil : String(line.dropFirst(3))
|
||||
inCode = true
|
||||
}
|
||||
} else if inCode {
|
||||
code.append(String(line))
|
||||
} else {
|
||||
prose.append(String(line))
|
||||
}
|
||||
}
|
||||
if inCode {
|
||||
result.append(.code(code.joined(separator: "\n"), language: language))
|
||||
} else {
|
||||
let joined = prose.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !joined.isEmpty {
|
||||
result.append(.prose(joined))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func attributed(_ string: String) -> AttributedString {
|
||||
(try? AttributedString(
|
||||
markdown: string,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
)) ?? AttributedString(string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// Camera-based QR scanner for pairing codes.
|
||||
struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onScan: (String) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let controller = ScannerViewController()
|
||||
controller.onScan = onScan
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: ScannerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
final class ScannerViewController: UIViewController, @preconcurrency AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onScan: ((String) -> Void)?
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var didScan = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input)
|
||||
else {
|
||||
showUnavailableLabel()
|
||||
return
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
showUnavailableLabel()
|
||||
return
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.bounds
|
||||
view.layer.addSublayer(preview)
|
||||
previewLayer = preview
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.bounds
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
didScan = false
|
||||
let session = session
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if !session.isRunning {
|
||||
session.startRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
let session = session
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if session.isRunning {
|
||||
session.stopRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard !didScan,
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
object.type == .qr,
|
||||
let value = object.stringValue
|
||||
else { return }
|
||||
didScan = true
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
onScan?(value)
|
||||
}
|
||||
|
||||
private func showUnavailableLabel() {
|
||||
let label = UILabel()
|
||||
label.text = "Camera unavailable"
|
||||
label.textColor = .white
|
||||
label.textAlignment = .center
|
||||
label.frame = view.bounds
|
||||
label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Design tokens. Dark, calm, terminal-native; mint accent for live state.
|
||||
enum Theme {
|
||||
static let background = Color(hex: 0x0F0F14)
|
||||
static let surface = Color(hex: 0x1A1A1F)
|
||||
static let surfaceElevated = Color(hex: 0x242429)
|
||||
static let border = Color.white.opacity(0.08)
|
||||
static let mint = Color(hex: 0x4DD9A6)
|
||||
static let mintTint = Color(hex: 0x4DD9A6).opacity(0.15)
|
||||
static let textPrimary = Color.white.opacity(0.92)
|
||||
static let textSecondary = Color.white.opacity(0.55)
|
||||
static let textTertiary = Color.white.opacity(0.35)
|
||||
static let warning = Color(hex: 0xF59E0B)
|
||||
static let error = Color(hex: 0xD94D59)
|
||||
|
||||
static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
.system(size: size, weight: weight, design: .monospaced)
|
||||
}
|
||||
|
||||
/// Decorative icon font (SF Symbols) at a fixed point size.
|
||||
static func icon(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
.system(size: size, weight: weight)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32) {
|
||||
self.init(
|
||||
red: Double((hex >> 16) & 0xFF) / 255.0,
|
||||
green: Double((hex >> 8) & 0xFF) / 255.0,
|
||||
blue: Double(hex & 0xFF) / 255.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra edge padding for chrome pinned to an edge with no system inset.
|
||||
///
|
||||
/// Home-button devices (iPhone SE class) report a zero bottom safe-area inset,
|
||||
/// so edge-pinned chrome needs explicit breathing room there; Dynamic Island
|
||||
/// devices already get it from the system insets. Derived from the root
|
||||
/// GeometryReader in RootView and injected via the environment: reading
|
||||
/// UIKit window insets during a SwiftUI body evaluation creates an
|
||||
/// AttributeGraph cycle that corrupts view-hierarchy updates.
|
||||
struct CompactEdgePads: Equatable {
|
||||
var top: CGFloat = 0
|
||||
var bottom: CGFloat = 0
|
||||
|
||||
/// Derives the pads from the container's safe-area insets.
|
||||
init(safeArea: EdgeInsets) {
|
||||
top = safeArea.top < 24 ? 12 : 0
|
||||
bottom = safeArea.bottom > 0 ? 0 : 12
|
||||
}
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var compactEdgePads = CompactEdgePads()
|
||||
}
|
||||
|
||||
/// Card container used across screens.
|
||||
struct Card<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Main conversation screen.
|
||||
struct ChatView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.compactEdgePads) private var edgePads
|
||||
@State private var showSettings = false
|
||||
@State private var sendCount = 0
|
||||
|
||||
var body: some View {
|
||||
@Bindable var model = model
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
if showConnectionBanner {
|
||||
ConnectionBanner(phase: model.session.phase) {
|
||||
model.retryConnection()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
if let banner = model.session.errorBanner {
|
||||
ErrorBanner(message: banner) {
|
||||
model.dismissError()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
if !model.session.notices.isEmpty {
|
||||
NoticeStack(
|
||||
notices: model.session.notices,
|
||||
onDismiss: { model.dismissNotice($0) }
|
||||
)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
TranscriptView(
|
||||
entries: model.session.transcript,
|
||||
isReasoning: model.session.isReasoning
|
||||
)
|
||||
|
||||
if model.session.hasPendingInterrupts {
|
||||
QueuedInterruptChip(count: model.session.pendingInterrupts.count) {
|
||||
model.cancelQueuedInterrupts()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Composer(
|
||||
draft: $model.draft,
|
||||
isProcessing: model.session.isProcessing,
|
||||
isConnected: model.isConnected,
|
||||
onSend: {
|
||||
sendCount += 1
|
||||
model.sendDraft()
|
||||
},
|
||||
onInterrupt: { model.interrupt() }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: sendCount)
|
||||
.sensoryFeedback(.impact(flexibility: .soft), trigger: finishedToolCallCount) {
|
||||
$1 > $0
|
||||
}
|
||||
.sensoryFeedback(.error, trigger: model.session.errorBanner) {
|
||||
$1 != nil
|
||||
}
|
||||
}
|
||||
|
||||
private var showConnectionBanner: Bool {
|
||||
switch model.session.phase {
|
||||
case .reconnecting, .disconnected, .failed: true
|
||||
case .connected, .connecting: false
|
||||
}
|
||||
}
|
||||
|
||||
/// Finished tool calls on the streaming (last) entry; drives a subtle
|
||||
/// tick as tools complete without scanning the whole transcript.
|
||||
private var finishedToolCallCount: Int {
|
||||
model.session.transcript.last?.toolCalls.filter { call in
|
||||
switch call.status {
|
||||
case .succeeded, .failed: true
|
||||
case .streamingInput, .running: false
|
||||
}
|
||||
}.count ?? 0
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.session.sessionTitle ?? model.activeServer?.serverName ?? "jcode")
|
||||
.font(Theme.mono(16, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
if let modelName = model.session.modelName {
|
||||
Text(modelName)
|
||||
.font(Theme.mono(11))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
StatusPill(phase: model.session.phase)
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Theme.border, lineWidth: 1))
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
.accessibilityHint("Sessions, model, and servers")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.top, edgePads.top)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Message composer with send/interrupt.
|
||||
struct Composer: View {
|
||||
@Environment(\.compactEdgePads) private var edgePads
|
||||
@Binding var draft: String
|
||||
let isProcessing: Bool
|
||||
let isConnected: Bool
|
||||
let onSend: () -> Void
|
||||
let onInterrupt: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(
|
||||
isProcessing ? "Queue a message..." : "Message",
|
||||
text: $draft,
|
||||
axis: .vertical
|
||||
)
|
||||
.lineLimit(1...6)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
|
||||
if isProcessing {
|
||||
Button(action: onInterrupt) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(Theme.error)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.surface)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.accessibilityLabel("Stop")
|
||||
.accessibilityHint("Interrupt the current response")
|
||||
}
|
||||
|
||||
Button(action: onSend) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.body.weight(.bold))
|
||||
.foregroundStyle(isConnected ? .black : Theme.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(isConnected ? Theme.mint : Theme.surfaceElevated)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.disabled(!canSend)
|
||||
.accessibilityLabel(isProcessing ? "Queue message" : "Send message")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.bottom, edgePads.bottom)
|
||||
.background(Theme.background)
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
isConnected && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Inline banner shown while the connection is down, with a manual retry.
|
||||
///
|
||||
/// The automatic reconnect loop keeps running underneath; the button just
|
||||
/// short-circuits the backoff wait for impatient humans.
|
||||
struct ConnectionBanner: View {
|
||||
let phase: ConnectionPhase
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.warning)
|
||||
.accessibilityHidden(true)
|
||||
Text(label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: onRetry) {
|
||||
Text("Retry")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Retry connection")
|
||||
.accessibilityHint("Reconnects to the server now")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.background(Theme.warning.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.warning.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
switch phase {
|
||||
case .reconnecting(let attempt):
|
||||
"Connection lost, retrying (attempt \(attempt))"
|
||||
case .failed:
|
||||
"Connection failed"
|
||||
default:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chip shown above the composer while soft-interrupt messages wait to be
|
||||
/// injected into the running turn, with a cancel affordance.
|
||||
struct QueuedInterruptChip: View {
|
||||
let count: Int
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "clock")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.mint)
|
||||
.accessibilityHidden(true)
|
||||
Text(count == 1 ? "1 message queued" : "\(count) messages queued")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: onCancel) {
|
||||
Text("Cancel")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(Theme.error)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Cancel queued messages")
|
||||
.accessibilityHint("Removes messages waiting to interrupt the response")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.background(Theme.mintTint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.mint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Friendly placeholder for a fresh session, centered in the canvas.
|
||||
struct EmptyTranscript: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "terminal")
|
||||
.font(Theme.icon(40, weight: .light))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Text("Ready when you are")
|
||||
.font(Theme.mono(16, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Text("Send a message to start driving this session.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// One transcript entry: user bubble, assistant markdown, or system note.
|
||||
struct EntryView: View {
|
||||
let entry: TranscriptEntry
|
||||
|
||||
var body: some View {
|
||||
switch entry.role {
|
||||
case .user:
|
||||
HStack {
|
||||
Spacer(minLength: 48)
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(entry.text)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(12)
|
||||
.background(Theme.mintTint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.copyContextMenu(entry.text)
|
||||
if entry.isQueued {
|
||||
Label("queued", systemImage: "clock")
|
||||
.font(Theme.mono(11))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.accessibilityLabel("Queued")
|
||||
.accessibilityHint("Delivers after the current response")
|
||||
}
|
||||
}
|
||||
}
|
||||
case .assistant:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !entry.reasoning.isEmpty {
|
||||
Text(entry.reasoning)
|
||||
.font(Theme.mono(12))
|
||||
.italic()
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(4)
|
||||
.copyContextMenu(entry.reasoning)
|
||||
}
|
||||
ForEach(entry.toolCalls) { call in
|
||||
ToolCallCard(call: call)
|
||||
}
|
||||
if !entry.text.isEmpty {
|
||||
MarkdownText(entry.text)
|
||||
.copyContextMenu(entry.text)
|
||||
}
|
||||
}
|
||||
case .system:
|
||||
Text(entry.text)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.copyContextMenu(entry.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Long-press context menu offering to copy the given text.
|
||||
func copyContextMenu(_ text: String) -> some View {
|
||||
contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = text
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// First-run pairing: scan QR or type host/port/code.
|
||||
struct PairingView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@State private var host = ""
|
||||
@State private var port = String(Gateway.defaultPort)
|
||||
@State private var code = ""
|
||||
@State private var isPairing = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showScanner = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
|
||||
if let errorMessage {
|
||||
ErrorBanner(message: errorMessage) {
|
||||
self.errorMessage = nil
|
||||
}
|
||||
.padding(.horizontal, -16)
|
||||
}
|
||||
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
field("Host", text: $host, placeholder: "devbox.tailnet.ts.net")
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
field("Port", text: $port, placeholder: "7643")
|
||||
.keyboardType(.numberPad)
|
||||
field("Pairing code", text: $code, placeholder: "123456")
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: pair) {
|
||||
HStack {
|
||||
if isPairing && !reduceMotion {
|
||||
ProgressView().tint(.black)
|
||||
}
|
||||
Text(isPairing ? "Pairing..." : "Pair")
|
||||
.font(.headline)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(canPair ? Theme.mint : Theme.surfaceElevated)
|
||||
.foregroundStyle(canPair ? .black : Theme.textTertiary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(!canPair || isPairing)
|
||||
.accessibilityLabel("Pair")
|
||||
.accessibilityHint("Connects using the host, port, and code above")
|
||||
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("Scan QR from `jcode pair`", systemImage: "qrcode.viewfinder")
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.surface)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityLabel("Scan QR code")
|
||||
.accessibilityHint("Opens the camera to scan a pairing code")
|
||||
|
||||
Text("Run `jcode pair` on your machine, then scan the QR code or enter the code manually. Traffic stays on your tailnet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.dynamicTypeSize(.large ... .accessibility3)
|
||||
.sheet(isPresented: $showScanner) {
|
||||
QRScannerView { scanned in
|
||||
showScanner = false
|
||||
if let payload = PairURI.parse(scanned) {
|
||||
host = payload.gateway.host
|
||||
port = String(payload.gateway.port)
|
||||
code = payload.code
|
||||
pair()
|
||||
} else {
|
||||
errorMessage = "Not a jcode pairing QR code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("jcode")
|
||||
.font(Theme.mono(34, weight: .bold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Text("Pair with a server on your tailnet")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
}
|
||||
|
||||
private var canPair: Bool {
|
||||
!host.trimmingCharacters(in: .whitespaces).isEmpty && !code.isEmpty
|
||||
&& UInt16(port) != nil
|
||||
}
|
||||
|
||||
private func field(_ label: String, text: Binding<String>, placeholder: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
TextField(placeholder, text: text)
|
||||
.font(Theme.mono(16))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(12)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
}
|
||||
|
||||
private func pair() {
|
||||
guard let portValue = UInt16(port) else { return }
|
||||
let gateway = Gateway(host: host.trimmingCharacters(in: .whitespaces), port: portValue)
|
||||
let pairCode = code
|
||||
isPairing = true
|
||||
errorMessage = nil
|
||||
Task {
|
||||
defer { isPairing = false }
|
||||
do {
|
||||
try await model.pair(
|
||||
gateway: gateway,
|
||||
code: pairCode,
|
||||
deviceName: UIDevice.current.name
|
||||
)
|
||||
} catch let error as PairingClient.PairingError {
|
||||
switch error {
|
||||
case .invalidCode(let message):
|
||||
errorMessage = message
|
||||
case .serverError(_, let message):
|
||||
errorMessage = message
|
||||
case .invalidResponse:
|
||||
errorMessage = "Unexpected response from server"
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Could not reach \(gateway.host):\(gateway.port)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Top-level router: pairing when no server, chat otherwise.
|
||||
struct RootView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var deepLinkError: String?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
if model.activeServer == nil {
|
||||
PairingView()
|
||||
} else {
|
||||
ChatView()
|
||||
}
|
||||
}
|
||||
.environment(\.compactEdgePads, CompactEdgePads(safeArea: proxy.safeAreaInsets))
|
||||
}
|
||||
.task {
|
||||
// Auto-connect to the most recent server on launch.
|
||||
if let server = model.activeServer, !model.isConnected {
|
||||
model.connect(to: server)
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard let payload = PairURI.parse(url.absoluteString) else { return }
|
||||
Task {
|
||||
do {
|
||||
try await model.pair(
|
||||
gateway: payload.gateway,
|
||||
code: payload.code,
|
||||
deviceName: UIDevice.current.name
|
||||
)
|
||||
} catch {
|
||||
deepLinkError = "Pairing failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Pairing", isPresented: .init(
|
||||
get: { deepLinkError != nil },
|
||||
set: { if !$0 { deepLinkError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(deepLinkError ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection status pill shown in the chat header.
|
||||
struct StatusPill: View {
|
||||
let phase: ConnectionPhase
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
Text(label)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 4)
|
||||
.background(Theme.surface)
|
||||
.clipShape(Capsule())
|
||||
.overlay(Capsule().stroke(Theme.border, lineWidth: 1))
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Connection")
|
||||
.accessibilityValue(label)
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch phase {
|
||||
case .connected: Theme.mint
|
||||
case .connecting, .reconnecting: Theme.warning
|
||||
case .disconnected, .failed: Theme.error
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
switch phase {
|
||||
case .connected: "live"
|
||||
case .connecting: "connecting"
|
||||
case .reconnecting(let attempt): "retry \(attempt)"
|
||||
case .disconnected: "offline"
|
||||
case .failed: "failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismissible error banner.
|
||||
struct ErrorBanner: View {
|
||||
let message: String
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Theme.error)
|
||||
.accessibilityHidden(true)
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(3)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Dismiss error")
|
||||
.accessibilityHint("Hides this error message")
|
||||
}
|
||||
.padding(12)
|
||||
.background(Theme.error.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Theme.error.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack of dismissible notices for out-of-band server signals
|
||||
/// (push notifications, interrupts, context compaction).
|
||||
struct NoticeStack: View {
|
||||
let notices: [Notice]
|
||||
let onDismiss: (UUID) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
ForEach(notices) { notice in
|
||||
NoticeRow(notice: notice) { onDismiss(notice.id) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoticeRow: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
let notice: Notice
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(tint)
|
||||
.accessibilityHidden(true)
|
||||
Text(notice.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(3)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.accessibilityLabel("Dismiss notice")
|
||||
.accessibilityHint("Hides this notice")
|
||||
}
|
||||
.padding(12)
|
||||
.background(tint.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.accessibilityElement(children: .combine)
|
||||
// Honor Reduce Motion: skip the slide/fade for motion-sensitive users.
|
||||
.transition(reduceMotion
|
||||
? .opacity
|
||||
: .move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch notice.kind {
|
||||
case .info: "info.circle.fill"
|
||||
case .notification: "bell.fill"
|
||||
case .compaction: "arrow.down.right.and.arrow.up.left"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch notice.kind {
|
||||
case .info: Theme.textSecondary
|
||||
case .notification: Theme.mint
|
||||
case .compaction: Theme.warning
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Sessions, servers, and info sections, split out to keep view files small.
|
||||
struct SettingsSessionsSection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var renameDraft: String
|
||||
@Binding var showRename: Bool
|
||||
|
||||
var body: some View {
|
||||
Section("Sessions") {
|
||||
ForEach(model.session.allSessions, id: \.self) { sessionID in
|
||||
sessionRow(sessionID)
|
||||
}
|
||||
Button {
|
||||
renameDraft = model.session.sessionTitle ?? ""
|
||||
showRename = true
|
||||
} label: {
|
||||
Label("Rename current session", systemImage: "pencil")
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityHint("Opens a field to rename the active session")
|
||||
Button {
|
||||
model.compactConversation()
|
||||
} label: {
|
||||
Label("Compact conversation", systemImage: "arrow.down.right.and.arrow.up.left")
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityHint("Summarizes older messages to free context")
|
||||
Button {
|
||||
model.clearConversation()
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("New session (clear)", systemImage: "square.and.pencil")
|
||||
.foregroundStyle(Theme.mint)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityHint("Clears the conversation and starts fresh")
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionRow(_ sessionID: String) -> some View {
|
||||
let isActive = sessionID == model.session.sessionID
|
||||
let title = model.session.title(forSession: sessionID)
|
||||
return Button {
|
||||
model.switchSession(sessionID)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let title {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(shortSessionID(sessionID))
|
||||
.font(Theme.mono(title == nil ? 13 : 11))
|
||||
.foregroundStyle(title == nil ? Theme.textPrimary : Theme.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if isActive {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.mint)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityLabel("Session \(title ?? shortSessionID(sessionID))")
|
||||
.accessibilityValue(isActive ? "Current" : "")
|
||||
.accessibilityHint("Switches to this session")
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
}
|
||||
|
||||
private func shortSessionID(_ id: String) -> String {
|
||||
if id.count > 24 {
|
||||
return String(id.prefix(24)) + "…"
|
||||
}
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsServersSection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var showPairNew: Bool
|
||||
|
||||
var body: some View {
|
||||
Section("Servers") {
|
||||
ForEach(model.servers) { server in
|
||||
let isActive = server.id == model.activeServer?.id
|
||||
Button {
|
||||
model.connect(to: server)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(server.serverName)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Text("\(server.host):\(String(server.port))")
|
||||
.font(Theme.mono(11))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
Spacer()
|
||||
if isActive {
|
||||
Circle()
|
||||
.fill(Theme.mint)
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityLabel(server.serverName)
|
||||
.accessibilityValue(isActive ? "Connected" : "")
|
||||
.accessibilityHint("Connects to this server")
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
model.removeServer(server)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button {
|
||||
showPairNew = true
|
||||
} label: {
|
||||
Label("Pair new server", systemImage: "plus")
|
||||
.foregroundStyle(Theme.mint)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityHint("Opens pairing to add a server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsInfoSection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Section("Info") {
|
||||
row("Server version", model.session.serverVersion ?? "unknown")
|
||||
row("Provider", model.session.providerName ?? "unknown")
|
||||
row(
|
||||
"Tokens",
|
||||
"\(model.session.tokenInput) in / \(model.session.tokenOutput) out"
|
||||
)
|
||||
if let detail = model.session.statusDetail {
|
||||
row("Status", detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Settings sheet: model picker, reasoning effort, sessions, servers, info.
|
||||
struct SettingsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State var renameDraft = ""
|
||||
@State var showRename = false
|
||||
@State var showPairNew = false
|
||||
|
||||
/// Reasoning effort levels offered when the provider exposes the knob.
|
||||
static let reasoningEfforts = ["none", "low", "medium", "high", "xhigh"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
modelSection
|
||||
if model.session.reasoningEffort != nil {
|
||||
reasoningSection
|
||||
}
|
||||
SettingsSessionsSection(renameDraft: $renameDraft, showRename: $showRename)
|
||||
SettingsServersSection(showPairNew: $showPairNew)
|
||||
SettingsInfoSection()
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Theme.background)
|
||||
.dynamicTypeSize(.large ... .accessibility3)
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.alert("Rename session", isPresented: $showRename) {
|
||||
TextField("Title", text: $renameDraft)
|
||||
Button("Rename") {
|
||||
model.renameSession(renameDraft)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.sheet(isPresented: $showPairNew) {
|
||||
NavigationStack {
|
||||
PairingView()
|
||||
.background(Theme.background)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showPairNew = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.onChange(of: model.activeServer?.id) {
|
||||
showPairNew = false
|
||||
}
|
||||
}
|
||||
|
||||
private var modelSection: some View {
|
||||
Section("Model") {
|
||||
ForEach(model.session.availableModels, id: \.self) { name in
|
||||
let isActive = name == model.session.modelName
|
||||
Button {
|
||||
model.setModel(name)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(name)
|
||||
.font(Theme.mono(13))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if isActive {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.mint)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityLabel("Model \(name)")
|
||||
.accessibilityValue(isActive ? "Selected" : "")
|
||||
.accessibilityHint("Selects this model")
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reasoningSection: some View {
|
||||
Section("Reasoning effort") {
|
||||
ForEach(Self.reasoningEfforts, id: \.self) { effort in
|
||||
let isActive = effort == model.session.reasoningEffort
|
||||
Button {
|
||||
model.setReasoningEffort(effort)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(effort)
|
||||
.font(Theme.mono(13))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Spacer()
|
||||
if isActive {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.mint)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.surface)
|
||||
.accessibilityLabel("Reasoning effort \(effort)")
|
||||
.accessibilityValue(isActive ? "Selected" : "")
|
||||
.accessibilityHint("Sets how much the model reasons before answering")
|
||||
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Collapsible tool call card with live status.
|
||||
struct ToolCallCard: View {
|
||||
let call: TranscriptEntry.ToolCall
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
expanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
statusIcon
|
||||
Text(call.name)
|
||||
.font(Theme.mono(13, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Spacer()
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
}
|
||||
if expanded {
|
||||
if !call.input.isEmpty {
|
||||
codeBlock(call.input)
|
||||
}
|
||||
if !call.output.isEmpty {
|
||||
codeBlock(String(call.output.prefix(2000)))
|
||||
}
|
||||
if case let .failed(message) = call.status {
|
||||
Text(message)
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusIcon: some View {
|
||||
switch call.status {
|
||||
case .streamingInput, .running:
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.tint(Theme.mint)
|
||||
case .succeeded:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.mint)
|
||||
case .failed:
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.error)
|
||||
}
|
||||
}
|
||||
|
||||
private func codeBlock(_ text: String) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(text)
|
||||
.font(Theme.mono(11))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(8)
|
||||
}
|
||||
.background(Theme.background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import JCodeKit
|
||||
import SwiftUI
|
||||
|
||||
/// Scrolling transcript with pinned-to-bottom auto-follow.
|
||||
///
|
||||
/// Auto-scroll only engages while the user is at (or near) the bottom, so
|
||||
/// scrolling up to read history is never hijacked by streaming output. A
|
||||
/// jump-to-latest button appears whenever the view is unpinned. Short threads
|
||||
/// are anchored to the bottom (chat convention); once the content exceeds the
|
||||
/// viewport it scrolls normally. An empty session shows a centered placeholder.
|
||||
struct TranscriptView: View {
|
||||
let entries: [TranscriptEntry]
|
||||
let isReasoning: Bool
|
||||
|
||||
/// True while the viewport is at (or near) the bottom of the content.
|
||||
@State private var isPinnedToBottom = true
|
||||
|
||||
/// Distance from the bottom below which the view counts as pinned.
|
||||
private static let pinThreshold: CGFloat = 56
|
||||
|
||||
var body: some View {
|
||||
if entries.isEmpty && !isReasoning {
|
||||
EmptyTranscript()
|
||||
} else {
|
||||
GeometryReader { viewport in
|
||||
scroller(viewportHeight: viewport.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scroller(viewportHeight: CGFloat) -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
// A flexible top spacer pushes short content to the bottom of
|
||||
// the viewport; it collapses to zero once content overflows.
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
Spacer(minLength: 0)
|
||||
ForEach(entries) { entry in
|
||||
EntryView(entry: entry)
|
||||
.id(entry.id)
|
||||
}
|
||||
if isReasoning {
|
||||
thinkingRow
|
||||
}
|
||||
Color.clear.frame(height: 1).id("bottom")
|
||||
}
|
||||
.frame(minHeight: max(0, viewportHeight - 16), alignment: .bottom)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
GeometryReader { content in
|
||||
Color.clear.preference(
|
||||
key: BottomDistanceKey.self,
|
||||
value: content.frame(in: .named("transcript")).maxY
|
||||
- viewportHeight
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.coordinateSpace(name: "transcript")
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onPreferenceChange(BottomDistanceKey.self) { distance in
|
||||
MainActor.assumeIsolated {
|
||||
let pinned = distance < Self.pinThreshold
|
||||
if pinned != isPinnedToBottom {
|
||||
isPinnedToBottom = pinned
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: entries.last?.text) {
|
||||
guard isPinnedToBottom else { return }
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
.onChange(of: entries.count) {
|
||||
// Follow new entries when pinned; always follow the user's
|
||||
// own sends so their message never lands off-screen.
|
||||
if isPinnedToBottom || entries.last?.role == .user {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !isPinnedToBottom {
|
||||
ScrollToBottomButton {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var thinkingRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(Theme.textTertiary)
|
||||
Text("thinking")
|
||||
.font(Theme.mono(12))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the content's bottom edge sits below the viewport's bottom edge.
|
||||
private struct BottomDistanceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating jump-to-latest affordance shown while scrolled up.
|
||||
private struct ScrollToBottomButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Theme.surfaceElevated)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Theme.border, lineWidth: 1))
|
||||
}
|
||||
.accessibilityLabel("Scroll to bottom")
|
||||
.accessibilityHint("Jumps to the latest message")
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# iOS E2E Test Harness
|
||||
|
||||
A deterministic, no-LLM harness for developing and validating the jcode iOS
|
||||
client (`JCodeMobile`) end-to-end. It replaces the role of the old Rust
|
||||
simulator: one source of honest, repeatable server behavior the client can be
|
||||
built against on this machine, without a device, network, or provider cost.
|
||||
|
||||
## Pieces
|
||||
|
||||
- **`mock_gateway.py`** - a self-contained (stdlib-only) mock of the jcode
|
||||
server gateway. Speaks the exact wire protocol from
|
||||
`crates/jcode-base/src/gateway.rs` on one TCP port:
|
||||
- `GET /health` -> status/version
|
||||
- `POST /pair` -> token exchange (code `123456` by default)
|
||||
- `GET /ws` -> WebSocket upgrade carrying the newline-delimited JSON protocol
|
||||
A `message` request triggers a scripted assistant turn (reasoning, text
|
||||
deltas, a `bash` tool-call lifecycle, tokens, done). `--push-demo` also pushes
|
||||
an out-of-band notification + compaction notice after connect.
|
||||
|
||||
- **`protocol_smoke_test.py`** - a stdlib WebSocket/HTTP client that drives the
|
||||
mock and asserts the full happy-path event sequence (pair, subscribe,
|
||||
history, message stream, set_model). Run it against either the mock or a real
|
||||
`jcode` gateway.
|
||||
|
||||
- **`run_e2e.sh`** - the one-command pipeline: `swift test` -> build app ->
|
||||
start mock -> smoke test -> boot simulator -> seed a paired credential ->
|
||||
launch -> screenshot.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Full pipeline, screenshot lands in $TMPDIR/jcode-ios-e2e/chat.png
|
||||
./TestHarness/run_e2e.sh
|
||||
|
||||
# Also exercise the out-of-band notice toasts
|
||||
./TestHarness/run_e2e.sh --push-demo
|
||||
|
||||
# Just the protocol assertions against a running gateway (mock or real)
|
||||
python3 TestHarness/mock_gateway.py & # or run a real `jcode` gateway
|
||||
python3 TestHarness/protocol_smoke_test.py --port 7643
|
||||
```
|
||||
|
||||
## How auto-connect is seeded
|
||||
|
||||
The app stores paired servers in the Keychain, falling back to
|
||||
`Library/Application Support/jcode-servers.json` when the Keychain is
|
||||
unavailable (unsigned simulator builds). The harness writes that JSON directly
|
||||
into the app's data container so the app auto-connects on launch, bypassing the
|
||||
SpringBoard "Open in app?" deep-link confirmation that can't be scripted.
|
||||
|
||||
## Why this exists
|
||||
|
||||
`JCodeKit` (the platform-free client core) is fully unit-tested with `swift
|
||||
test`. This harness adds the layer above that: it proves the real SwiftUI app,
|
||||
running in a simulator, connects over a real WebSocket and renders a real
|
||||
transcript. Together they make client behavior hill-climbable without a device.
|
||||
|
||||
## Measuring + improving the UI (efficiency reward)
|
||||
|
||||
"This looks ugly" is turned into a single hill-climbable number.
|
||||
|
||||
- **`ui_metrics.py`** - pixel-level scorer for one screenshot (space,
|
||||
consistency, legibility, rhythm) with `--annotate` overlays.
|
||||
- **`ui_lint.py`** - source-level design-token discipline (hardcoded colors /
|
||||
fonts / off-grid spacing that bypass `Theme`).
|
||||
- **`ui_matrix.py`** - renders the app across content scenarios
|
||||
(`empty,short,tool,long,code`) x devices x Dynamic Type sizes, scores each
|
||||
cell, reports a mean + worst cell. The mean is the hill to climb.
|
||||
- **Devices**: defaults to `iPhone 17` (large, 3x) plus
|
||||
`iPhone SE (3rd generation)` (small, 2x), so layout robustness is measured
|
||||
against real width/height pressure. Override with `--devices`.
|
||||
- **Dynamic Type**: the primary device is re-run at `accessibility-large`
|
||||
(via `simctl ui <dev> content_size`) so text-scaling breakage shows up in
|
||||
the matrix. Tune or disable with `--a11y-size ""`.
|
||||
- **Runtime perf**: each cell records best-effort runtime metrics in the
|
||||
schema `reward/scorers/perf.py` consumes: `cold_launch_ms` (wall time of
|
||||
`simctl launch` on a fresh install, i.e. a true cold launch) and
|
||||
`first_frame_ms` (screenshot polling until the app's background dominates
|
||||
the screen). Measurements include harness overhead, so treat them as
|
||||
consistent relative signals, not absolute truth. If measurement fails the
|
||||
cell omits `runtime` and the perf scorer degrades to unavailable
|
||||
(weights renormalize; the reward is never tanked by missing data).
|
||||
Skip with `--no-perf`. Scroll-jank capture is not implemented yet;
|
||||
`scroll_jank_frac` stays absent.
|
||||
- **`reward/`** - the full UX reward framework. 13 scorers across 5 weighted
|
||||
categories (A space .30, B ergonomics .25, C clarity .20, D legibility/a11y
|
||||
.15, E responsiveness .10) aggregate into one 0-100 reward with a
|
||||
worst-category callout. See `reward/REWARD_SPEC.md`.
|
||||
|
||||
Typical loop:
|
||||
|
||||
```bash
|
||||
# 1. capture a screenshot matrix + score it
|
||||
python3 ui_matrix.py --json > /tmp/before.json
|
||||
python3 -m reward.aggregate --matrix-json /tmp/before.json --out-json /tmp/before_reward.json
|
||||
|
||||
# 2. make a UI change, rebuild, re-measure
|
||||
python3 ui_matrix.py --json > /tmp/after.json
|
||||
python3 -m reward.aggregate --matrix-json /tmp/after.json --out-json /tmp/after_reward.json
|
||||
|
||||
# 3. gate: only keep the change if reward did not regress
|
||||
python3 -m reward.aggregate --baseline /tmp/before_reward.json --candidate /tmp/after_reward.json
|
||||
|
||||
# scorers must stay pure/deterministic:
|
||||
python3 -m reward.test_determinism
|
||||
```
|
||||
|
||||
Adding a category is a one-file drop-in under `reward/scorers/` that satisfies
|
||||
the contract (`NAME`, `CATEGORY`, `WEIGHT`, `score(ctx) -> CategoryScore`); the
|
||||
aggregator discovers it automatically.
|
||||
Executable
+542
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic mock jcode gateway for end-to-end iOS app testing.
|
||||
|
||||
Speaks the exact wire protocol from `crates/jcode-base/src/gateway.rs` on a
|
||||
single TCP port, peeking the request line to route like the real gateway:
|
||||
- GET /health -> {status, version, gateway}
|
||||
- POST /pair -> {token, server_name, server_version}
|
||||
- GET /ws -> WebSocket upgrade; newline-delimited JSON event protocol
|
||||
|
||||
It does NOT call an LLM. A `message` request triggers a scripted, deterministic
|
||||
stream (reasoning, text deltas, a tool-call lifecycle, tokens, done) so the app
|
||||
can be exercised and visually validated without network or provider cost. This
|
||||
is the iOS equivalent of the removed Rust simulator: one source of honest,
|
||||
repeatable behavior to develop the client against.
|
||||
|
||||
Self-contained: no third-party deps. Minimal hand-rolled WebSocket framing.
|
||||
|
||||
Run: python3 mock_gateway.py [--port 7643] [--code 123456]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
|
||||
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
SERVER_VERSION = "mock-0.32.0"
|
||||
SERVER_NAME = "mock-jcode"
|
||||
DEFAULT_MODELS = [
|
||||
"claude-api:claude-fable-5",
|
||||
"claude-api:claude-sonnet-4",
|
||||
"openai:gpt-5",
|
||||
"gemini:gemini-2.5-pro",
|
||||
]
|
||||
|
||||
|
||||
class GatewayState:
|
||||
def __init__(self, code, token):
|
||||
self.code = code
|
||||
self.token = token
|
||||
self.session_id = "mock-session-0001"
|
||||
self.title = "Mock session"
|
||||
self.model = DEFAULT_MODELS[0]
|
||||
self.messages = []
|
||||
self.token_input = 0
|
||||
self.token_output = 0
|
||||
self.reasoning_effort = "high"
|
||||
self.push_demo = False
|
||||
|
||||
|
||||
def scenario_messages(name):
|
||||
"""Pre-seeded transcripts for the layout matrix. Each is a deterministic
|
||||
content state so UI efficiency can be measured across the real range."""
|
||||
bash_tool = {
|
||||
"id": "t1", "name": "bash",
|
||||
"input": '{"command": "echo hello"}',
|
||||
"output": "hello\n", "error": None,
|
||||
}
|
||||
if name == "empty":
|
||||
return []
|
||||
if name == "short":
|
||||
return [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "Hello! How can I help?"},
|
||||
]
|
||||
if name == "tool":
|
||||
return [
|
||||
{"role": "user", "content": "run echo hello"},
|
||||
{"role": "assistant", "content": "Done. Output above.", "tool_data": bash_tool},
|
||||
]
|
||||
if name == "long":
|
||||
turns = []
|
||||
for i in range(6):
|
||||
turns.append({"role": "user", "content": f"Question number {i + 1} about the codebase?"})
|
||||
turns.append({
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
f"Answer {i + 1}: here is a reasonably detailed paragraph that "
|
||||
"wraps across multiple lines to simulate a real assistant reply "
|
||||
"with enough text to fill vertical space and exercise scrolling."
|
||||
),
|
||||
"tool_data": bash_tool if i % 2 == 0 else None,
|
||||
})
|
||||
return turns
|
||||
if name == "code":
|
||||
return [
|
||||
{"role": "user", "content": "show me a python snippet"},
|
||||
{"role": "assistant", "content": (
|
||||
"Sure:\n\n```python\ndef fib(n):\n a, b = 0, 1\n for _ in range(n):\n"
|
||||
" a, b = b, a + b\n return a\n```\n\nThat is iterative and O(n)."
|
||||
)},
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket framing (server side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ws_accept_key(key: str) -> str:
|
||||
digest = hashlib.sha1((key + WS_GUID).encode()).digest()
|
||||
return base64.b64encode(digest).decode()
|
||||
|
||||
|
||||
def encode_text_frame(text: str) -> bytes:
|
||||
payload = text.encode("utf-8")
|
||||
header = bytearray([0x81]) # FIN + text opcode
|
||||
length = len(payload)
|
||||
if length < 126:
|
||||
header.append(length)
|
||||
elif length < 65536:
|
||||
header.append(126)
|
||||
header += struct.pack(">H", length)
|
||||
else:
|
||||
header.append(127)
|
||||
header += struct.pack(">Q", length)
|
||||
return bytes(header) + payload
|
||||
|
||||
|
||||
def encode_control_frame(opcode: int, payload: bytes = b"") -> bytes:
|
||||
header = bytearray([0x80 | opcode, len(payload)])
|
||||
return bytes(header) + payload
|
||||
|
||||
|
||||
async def read_frame(reader: asyncio.StreamReader):
|
||||
"""Returns (opcode, payload_bytes) or None on EOF/close."""
|
||||
try:
|
||||
b = await reader.readexactly(2)
|
||||
except asyncio.IncompleteReadError:
|
||||
return None
|
||||
opcode = b[0] & 0x0F
|
||||
masked = (b[1] & 0x80) != 0
|
||||
length = b[1] & 0x7F
|
||||
if length == 126:
|
||||
ext = await reader.readexactly(2)
|
||||
length = struct.unpack(">H", ext)[0]
|
||||
elif length == 127:
|
||||
ext = await reader.readexactly(8)
|
||||
length = struct.unpack(">Q", ext)[0]
|
||||
mask = await reader.readexactly(4) if masked else b"\x00\x00\x00\x00"
|
||||
data = await reader.readexactly(length) if length else b""
|
||||
if masked:
|
||||
data = bytes(data[i] ^ mask[i % 4] for i in range(len(data)))
|
||||
return opcode, data
|
||||
|
||||
|
||||
class WSConn:
|
||||
"""Minimal server-side WebSocket connection wrapper."""
|
||||
|
||||
def __init__(self, writer: asyncio.StreamWriter):
|
||||
self.writer = writer
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def send(self, text: str):
|
||||
async with self._lock:
|
||||
self.writer.write(encode_text_frame(text))
|
||||
await self.writer.drain()
|
||||
|
||||
async def pong(self, payload: bytes):
|
||||
async with self._lock:
|
||||
self.writer.write(encode_control_frame(0xA, payload))
|
||||
await self.writer.drain()
|
||||
|
||||
async def close(self):
|
||||
try:
|
||||
async with self._lock:
|
||||
self.writer.write(encode_control_frame(0x8))
|
||||
await self.writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def jline(obj):
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
def chunk_text(text, size):
|
||||
for i in range(0, len(text), size):
|
||||
yield text[i : i + size]
|
||||
|
||||
|
||||
async def send_event(ws: WSConn, obj):
|
||||
await ws.send(jline(obj))
|
||||
|
||||
|
||||
async def stream_response(ws, state, user_text, req_id):
|
||||
await send_event(ws, {"type": "ack", "id": req_id})
|
||||
|
||||
for chunk in ["Looking at ", "the request", "..."]:
|
||||
await send_event(ws, {"type": "reasoning_delta", "text": chunk})
|
||||
await asyncio.sleep(0.05)
|
||||
await send_event(ws, {"type": "reasoning_done", "duration_secs": 0.4})
|
||||
|
||||
intro = f"You said: {user_text}\n\nRunning a quick tool to demonstrate.\n\n"
|
||||
for ch in chunk_text(intro, 6):
|
||||
await send_event(ws, {"type": "text_delta", "text": ch})
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
tool_id = f"tool-{req_id}"
|
||||
await send_event(ws, {"type": "tool_start", "id": tool_id, "name": "bash"})
|
||||
for piece in ['{"command":', ' "echo ', 'hello"}']:
|
||||
await send_event(ws, {"type": "tool_input", "delta": piece})
|
||||
await asyncio.sleep(0.03)
|
||||
await send_event(ws, {"type": "tool_exec", "id": tool_id, "name": "bash"})
|
||||
await asyncio.sleep(0.2)
|
||||
await send_event(ws, {
|
||||
"type": "tool_done", "id": tool_id, "name": "bash",
|
||||
"output": "hello\n", "error": None,
|
||||
})
|
||||
|
||||
answer = (
|
||||
"Done. Here is a code block:\n\n"
|
||||
"```python\nprint('hello from mock gateway')\n```\n\n"
|
||||
"And a **bold** word plus `inline code`."
|
||||
)
|
||||
for ch in chunk_text(answer, 8):
|
||||
await send_event(ws, {"type": "text_delta", "text": ch})
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
await send_event(ws, {"type": "message_end"})
|
||||
|
||||
state.token_input += 120 + len(user_text)
|
||||
state.token_output += 240
|
||||
await send_event(ws, {"type": "tokens", "input": state.token_input, "output": state.token_output})
|
||||
|
||||
state.messages.append({"role": "user", "content": user_text})
|
||||
state.messages.append({
|
||||
"role": "assistant", "content": answer,
|
||||
"tool_data": {
|
||||
"id": tool_id, "name": "bash",
|
||||
"input": '{"command": "echo hello"}',
|
||||
"output": "hello\n", "error": None,
|
||||
},
|
||||
})
|
||||
|
||||
await send_event(ws, {"type": "done", "id": req_id})
|
||||
|
||||
|
||||
def history_payload(state, req_id):
|
||||
return {
|
||||
"type": "history",
|
||||
"id": req_id,
|
||||
"session_id": state.session_id,
|
||||
"messages": state.messages,
|
||||
"provider_name": "anthropic-api",
|
||||
"provider_model": state.model,
|
||||
"available_models": DEFAULT_MODELS,
|
||||
"total_tokens": [state.token_input, state.token_output],
|
||||
"all_sessions": [state.session_id, "mock-session-0002"],
|
||||
"server_version": SERVER_VERSION,
|
||||
"display_title": state.title,
|
||||
"reasoning_effort": state.reasoning_effort,
|
||||
}
|
||||
|
||||
|
||||
async def handle_request(ws, state, raw):
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
req_type = msg.get("type")
|
||||
req_id = int(msg.get("id", 0))
|
||||
print(f"[ws] <- {req_type} id={req_id}", file=sys.stderr)
|
||||
|
||||
if req_type == "subscribe":
|
||||
await send_event(ws, {"type": "ack", "id": req_id})
|
||||
await send_event(ws, {"type": "session", "session_id": state.session_id})
|
||||
await send_event(ws, {
|
||||
"type": "state", "id": req_id, "session_id": state.session_id,
|
||||
"message_count": len(state.messages), "is_processing": False,
|
||||
})
|
||||
elif req_type == "get_history":
|
||||
await send_event(ws, history_payload(state, req_id))
|
||||
elif req_type == "message":
|
||||
await stream_response(ws, state, msg.get("content", ""), req_id)
|
||||
elif req_type == "soft_interrupt":
|
||||
await send_event(ws, {"type": "ack", "id": req_id})
|
||||
# Mirror the real server: confirm the queued message was injected
|
||||
# before streaming the (echoed) response it participates in.
|
||||
await send_event(ws, {
|
||||
"type": "soft_interrupt_injected",
|
||||
"content": msg.get("content", ""),
|
||||
"display_role": "user",
|
||||
"point": "immediate",
|
||||
"tools_skipped": 0,
|
||||
})
|
||||
await stream_response(ws, state, msg.get("content", ""), req_id)
|
||||
elif req_type == "cancel":
|
||||
await send_event(ws, {"type": "interrupted"})
|
||||
await send_event(ws, {"type": "done", "id": req_id})
|
||||
elif req_type == "ping":
|
||||
await send_event(ws, {"type": "pong", "id": req_id})
|
||||
elif req_type == "set_model":
|
||||
state.model = msg.get("model", state.model)
|
||||
await send_event(ws, {"type": "model_changed", "id": req_id, "model": state.model, "error": None})
|
||||
await send_event(ws, {"type": "available_models_updated", "available_models": DEFAULT_MODELS, "provider_model": state.model})
|
||||
elif req_type == "set_reasoning_effort":
|
||||
state.reasoning_effort = msg.get("effort", state.reasoning_effort)
|
||||
await send_event(ws, {"type": "reasoning_effort_changed", "id": req_id, "effort": state.reasoning_effort, "error": None})
|
||||
elif req_type == "compact":
|
||||
await send_event(ws, {"type": "compact_result", "id": req_id, "message": "Compacted context (2048 tokens saved)", "success": True})
|
||||
elif req_type == "rename_session":
|
||||
state.title = msg.get("title") or "Untitled"
|
||||
await send_event(ws, {"type": "session_renamed", "session_id": state.session_id, "display_title": state.title})
|
||||
elif req_type == "resume_session":
|
||||
sid = msg.get("session_id", state.session_id)
|
||||
state.session_id = sid
|
||||
state.messages = []
|
||||
await send_event(ws, {"type": "session", "session_id": sid})
|
||||
await send_event(ws, history_payload(state, req_id))
|
||||
elif req_type == "clear":
|
||||
state.messages = []
|
||||
await send_event(ws, history_payload(state, req_id))
|
||||
elif req_type == "cancel_soft_interrupts":
|
||||
await send_event(ws, {"type": "ack", "id": req_id})
|
||||
elif req_type == "_notify":
|
||||
# Test-only: synthesize a push notification + a compaction notice.
|
||||
await send_event(ws, {"type": "notification", "from_name": "swarm", "message": "build finished"})
|
||||
await send_event(ws, {"type": "compaction", "trigger": "manual", "tokens_saved": 4096})
|
||||
else:
|
||||
print(f"[ws] (ignored unknown request {req_type})", file=sys.stderr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP + routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def http_response(status_line, body):
|
||||
body_bytes = body.encode()
|
||||
head = (
|
||||
f"HTTP/1.1 {status_line}\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
f"Content-Length: {len(body_bytes)}\r\n"
|
||||
"Connection: close\r\n"
|
||||
"Access-Control-Allow-Origin: *\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
return head.encode() + body_bytes
|
||||
|
||||
|
||||
async def read_http_request(reader):
|
||||
"""Reads headers (and body per Content-Length). Returns (method, path, headers, body)."""
|
||||
header_data = b""
|
||||
while b"\r\n\r\n" not in header_data:
|
||||
chunk = await reader.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
header_data += chunk
|
||||
if len(header_data) > 65536:
|
||||
break
|
||||
if b"\r\n\r\n" not in header_data:
|
||||
return None
|
||||
head, _, rest = header_data.partition(b"\r\n\r\n")
|
||||
lines = head.decode("latin1").split("\r\n")
|
||||
request_line = lines[0]
|
||||
parts = request_line.split()
|
||||
method, path = (parts[0], parts[1]) if len(parts) >= 2 else ("", "")
|
||||
headers = {}
|
||||
for line in lines[1:]:
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
body = rest
|
||||
content_length = int(headers.get("content-length", "0") or "0")
|
||||
while len(body) < content_length:
|
||||
chunk = await reader.read(content_length - len(body))
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
return method, path, headers, body
|
||||
|
||||
|
||||
async def handle_connection(reader, writer, state):
|
||||
parsed = await read_http_request(reader)
|
||||
if parsed is None:
|
||||
writer.close()
|
||||
return
|
||||
method, path, headers, body = parsed
|
||||
path_base = path.split("?")[0]
|
||||
print(f"[http] {method} {path_base}", file=sys.stderr)
|
||||
|
||||
if headers.get("upgrade", "").lower() == "websocket" and path_base == "/ws":
|
||||
await serve_websocket(reader, writer, headers, state)
|
||||
return
|
||||
|
||||
if method == "GET" and path_base == "/health":
|
||||
body_str = jline({"status": "ok", "version": SERVER_VERSION, "gateway": True})
|
||||
writer.write(http_response("200 OK", body_str))
|
||||
elif method == "OPTIONS":
|
||||
writer.write(
|
||||
b"HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\n"
|
||||
b"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
|
||||
b"Access-Control-Allow-Headers: Content-Type, Authorization\r\n"
|
||||
b"Content-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
elif method == "POST" and path_base == "/pair":
|
||||
try:
|
||||
payload = json.loads(body.decode() or "{}")
|
||||
except Exception:
|
||||
payload = {}
|
||||
if payload.get("code", "") == state.code:
|
||||
resp = jline({"token": state.token, "server_name": SERVER_NAME, "server_version": SERVER_VERSION})
|
||||
writer.write(http_response("200 OK", resp))
|
||||
else:
|
||||
resp = jline({"error": "Invalid or expired pairing code"})
|
||||
writer.write(http_response("401 Unauthorized", resp))
|
||||
else:
|
||||
writer.write(http_response("404 Not Found", jline({"error": "Not found"})))
|
||||
|
||||
try:
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
writer.close()
|
||||
|
||||
|
||||
async def serve_websocket(reader, writer, headers, state):
|
||||
key = headers.get("sec-websocket-key")
|
||||
auth = headers.get("authorization", "")
|
||||
if not key:
|
||||
writer.close()
|
||||
return
|
||||
accept = ws_accept_key(key)
|
||||
handshake = (
|
||||
"HTTP/1.1 101 Switching Protocols\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Accept: {accept}\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
writer.write(handshake.encode())
|
||||
await writer.drain()
|
||||
ws = WSConn(writer)
|
||||
ok = auth == f"Bearer {state.token}"
|
||||
print(f"[ws] client connected (auth_ok={ok})", file=sys.stderr)
|
||||
|
||||
keepalive = asyncio.create_task(keepalive_loop(ws))
|
||||
push_demo = None
|
||||
if getattr(state, "push_demo", False):
|
||||
push_demo = asyncio.create_task(push_demo_loop(ws))
|
||||
try:
|
||||
buffered = ""
|
||||
while True:
|
||||
frame = await read_frame(reader)
|
||||
if frame is None:
|
||||
break
|
||||
opcode, data = frame
|
||||
if opcode == 0x8: # close
|
||||
break
|
||||
if opcode == 0x9: # ping
|
||||
await ws.pong(data)
|
||||
continue
|
||||
if opcode in (0x1, 0x2):
|
||||
buffered += data.decode("utf-8", errors="replace")
|
||||
while "\n" in buffered:
|
||||
line, buffered = buffered.split("\n", 1)
|
||||
line = line.strip()
|
||||
if line:
|
||||
await handle_request(ws, state, line)
|
||||
if buffered.strip():
|
||||
await handle_request(ws, state, buffered.strip())
|
||||
buffered = ""
|
||||
except (asyncio.IncompleteReadError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
keepalive.cancel()
|
||||
if push_demo:
|
||||
push_demo.cancel()
|
||||
await ws.close()
|
||||
writer.close()
|
||||
print("[ws] client disconnected", file=sys.stderr)
|
||||
|
||||
|
||||
async def push_demo_loop(ws):
|
||||
"""Spontaneously push out-of-band notices to validate the toast UI."""
|
||||
try:
|
||||
await asyncio.sleep(2.5)
|
||||
await send_event(ws, {"type": "notification", "from_name": "swarm", "message": "build finished"})
|
||||
await asyncio.sleep(1.5)
|
||||
await send_event(ws, {"type": "compaction", "trigger": "manual", "tokens_saved": 4096})
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def keepalive_loop(ws):
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(20)
|
||||
async with ws._lock:
|
||||
ws.writer.write(encode_control_frame(0x9)) # ping
|
||||
await ws.writer.drain()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=7643)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--code", default="123456")
|
||||
parser.add_argument("--token", default="mocktoken0123456789abcdef")
|
||||
parser.add_argument("--push-demo", action="store_true",
|
||||
help="spontaneously push notification + compaction notices after connect")
|
||||
parser.add_argument("--scenario", default="",
|
||||
help="pre-seed transcript: empty|short|tool|long|code")
|
||||
args = parser.parse_args()
|
||||
|
||||
state = GatewayState(args.code, args.token)
|
||||
state.push_demo = args.push_demo
|
||||
if args.scenario:
|
||||
state.messages = scenario_messages(args.scenario)
|
||||
|
||||
server = await asyncio.start_server(
|
||||
lambda r, w: handle_connection(r, w, state),
|
||||
args.host,
|
||||
args.port,
|
||||
)
|
||||
print(
|
||||
f"mock gateway: http+ws on {args.host}:{args.port} "
|
||||
f"(code={args.code}, token={args.token})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end protocol smoke test against the mock gateway.
|
||||
|
||||
Exercises: /health, /pair, ws upgrade, subscribe, get_history, message stream.
|
||||
Pure stdlib so it runs anywhere. Asserts the full happy-path event sequence.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def http_get(host, port, path):
|
||||
conn = http.client.HTTPConnection(host, port, timeout=5)
|
||||
conn.request("GET", path)
|
||||
resp = conn.getresponse()
|
||||
data = resp.read().decode()
|
||||
conn.close()
|
||||
return resp.status, data
|
||||
|
||||
|
||||
def http_post(host, port, path, body):
|
||||
conn = http.client.HTTPConnection(host, port, timeout=5)
|
||||
conn.request("POST", path, json.dumps(body), {"Content-Type": "application/json"})
|
||||
resp = conn.getresponse()
|
||||
data = resp.read().decode()
|
||||
conn.close()
|
||||
return resp.status, data
|
||||
|
||||
|
||||
def ws_connect(host, port, token):
|
||||
s = socket.create_connection((host, port), timeout=5)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
req = (
|
||||
f"GET /ws HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
f"Authorization: Bearer {token}\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
s.sendall(req.encode())
|
||||
# Read handshake response headers.
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
buf += s.recv(1024)
|
||||
assert b"101" in buf.split(b"\r\n")[0], buf
|
||||
return s
|
||||
|
||||
|
||||
def ws_send(s, text):
|
||||
payload = text.encode()
|
||||
mask = os.urandom(4)
|
||||
masked = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload)))
|
||||
header = bytearray([0x81])
|
||||
length = len(payload)
|
||||
if length < 126:
|
||||
header.append(0x80 | length)
|
||||
elif length < 65536:
|
||||
header.append(0x80 | 126)
|
||||
header += struct.pack(">H", length)
|
||||
else:
|
||||
header.append(0x80 | 127)
|
||||
header += struct.pack(">Q", length)
|
||||
s.sendall(bytes(header) + mask + masked)
|
||||
|
||||
|
||||
def ws_recv(s):
|
||||
def recv_exact(n):
|
||||
data = b""
|
||||
while len(data) < n:
|
||||
chunk = s.recv(n - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("closed")
|
||||
data += chunk
|
||||
return data
|
||||
|
||||
b = recv_exact(2)
|
||||
opcode = b[0] & 0x0F
|
||||
length = b[1] & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack(">H", recv_exact(2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack(">Q", recv_exact(8))[0]
|
||||
data = recv_exact(length) if length else b""
|
||||
return opcode, data
|
||||
|
||||
|
||||
def collect_events(s, until_type, max_events=200):
|
||||
events = []
|
||||
for _ in range(max_events):
|
||||
opcode, data = ws_recv(s)
|
||||
if opcode == 0x9: # ping -> ignore (client would pong)
|
||||
continue
|
||||
if opcode == 0x8:
|
||||
break
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
obj = json.loads(line)
|
||||
events.append(obj)
|
||||
if obj.get("type") == until_type:
|
||||
return events
|
||||
return events
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
ap.add_argument("--port", type=int, default=7643)
|
||||
ap.add_argument("--code", default="123456")
|
||||
args = ap.parse_args()
|
||||
|
||||
failures = []
|
||||
|
||||
def check(name, cond):
|
||||
status = "PASS" if cond else "FAIL"
|
||||
print(f" [{status}] {name}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
# 1. health
|
||||
st, body = http_get(args.host, args.port, "/health")
|
||||
h = json.loads(body)
|
||||
check("health 200", st == 200)
|
||||
check("health status ok", h.get("status") == "ok")
|
||||
check("health gateway flag", h.get("gateway") is True)
|
||||
|
||||
# 2. pair bad code
|
||||
st, body = http_post(args.host, args.port, "/pair", {"code": "000000", "device_id": "d", "device_name": "Test"})
|
||||
check("pair bad code 401", st == 401)
|
||||
|
||||
# 3. pair good code
|
||||
st, body = http_post(args.host, args.port, "/pair", {"code": args.code, "device_id": "d", "device_name": "Test"})
|
||||
p = json.loads(body)
|
||||
check("pair 200", st == 200)
|
||||
token = p.get("token", "")
|
||||
check("pair returns token", bool(token))
|
||||
check("pair server_name", p.get("server_name") == "mock-jcode")
|
||||
|
||||
# 4. ws connect + subscribe + history
|
||||
s = ws_connect(args.host, args.port, token)
|
||||
ws_send(s, json.dumps({"id": 1, "type": "subscribe"}))
|
||||
evs = collect_events(s, until_type="state")
|
||||
types = [e["type"] for e in evs]
|
||||
check("subscribe -> ack", "ack" in types)
|
||||
check("subscribe -> session", "session" in types)
|
||||
check("subscribe -> state", "state" in types)
|
||||
|
||||
ws_send(s, json.dumps({"id": 2, "type": "get_history"}))
|
||||
evs = collect_events(s, until_type="history")
|
||||
hist = next((e for e in evs if e["type"] == "history"), None)
|
||||
check("get_history -> history", hist is not None)
|
||||
if hist:
|
||||
check("history available_models", len(hist.get("available_models", [])) >= 1)
|
||||
check("history all_sessions", len(hist.get("all_sessions", [])) >= 1)
|
||||
|
||||
# 5. message -> full stream
|
||||
ws_send(s, json.dumps({"id": 3, "type": "message", "content": "hi there"}))
|
||||
evs = collect_events(s, until_type="done")
|
||||
types = [e["type"] for e in evs]
|
||||
check("message -> ack", "ack" in types)
|
||||
check("message -> reasoning_delta", "reasoning_delta" in types)
|
||||
check("message -> text_delta", "text_delta" in types)
|
||||
check("message -> tool_start", "tool_start" in types)
|
||||
check("message -> tool_exec", "tool_exec" in types)
|
||||
check("message -> tool_done", "tool_done" in types)
|
||||
check("message -> message_end", "message_end" in types)
|
||||
check("message -> tokens", "tokens" in types)
|
||||
check("message -> done", "done" in types)
|
||||
|
||||
# reconstruct streamed text
|
||||
streamed = "".join(e.get("text", "") for e in evs if e["type"] == "text_delta")
|
||||
check("streamed text contains echo of input", "hi there" in streamed)
|
||||
check("streamed text has code fence", "```" in streamed)
|
||||
|
||||
# 6. set_model
|
||||
ws_send(s, json.dumps({"id": 4, "type": "set_model", "model": "openai:gpt-5"}))
|
||||
evs = collect_events(s, until_type="available_models_updated")
|
||||
mc = next((e for e in evs if e["type"] == "model_changed"), None)
|
||||
check("set_model -> model_changed", mc is not None and mc.get("model") == "openai:gpt-5")
|
||||
|
||||
# 7. history carries display_title + reasoning_effort (session titles UI)
|
||||
if hist:
|
||||
check("history display_title", bool(hist.get("display_title")))
|
||||
check("history reasoning_effort", bool(hist.get("reasoning_effort")))
|
||||
|
||||
# 8. soft_interrupt -> injection ack precedes the streamed response
|
||||
ws_send(s, json.dumps({"id": 5, "type": "soft_interrupt",
|
||||
"content": "queued mid-run", "urgent": False}))
|
||||
evs = collect_events(s, until_type="done")
|
||||
types = [e["type"] for e in evs]
|
||||
check("soft_interrupt -> soft_interrupt_injected", "soft_interrupt_injected" in types)
|
||||
inj = next((e for e in evs if e["type"] == "soft_interrupt_injected"), None)
|
||||
check("injected content echoes request",
|
||||
inj is not None and inj.get("content") == "queued mid-run")
|
||||
if "soft_interrupt_injected" in types and "text_delta" in types:
|
||||
check("injected before stream",
|
||||
types.index("soft_interrupt_injected") < types.index("text_delta"))
|
||||
|
||||
# 9. set_reasoning_effort
|
||||
ws_send(s, json.dumps({"id": 6, "type": "set_reasoning_effort", "effort": "low"}))
|
||||
evs = collect_events(s, until_type="reasoning_effort_changed")
|
||||
rc = next((e for e in evs if e["type"] == "reasoning_effort_changed"), None)
|
||||
check("set_reasoning_effort -> reasoning_effort_changed",
|
||||
rc is not None and rc.get("effort") == "low" and rc.get("error") is None)
|
||||
|
||||
# 10. compact
|
||||
ws_send(s, json.dumps({"id": 7, "type": "compact"}))
|
||||
evs = collect_events(s, until_type="compact_result")
|
||||
cr = next((e for e in evs if e["type"] == "compact_result"), None)
|
||||
check("compact -> compact_result success",
|
||||
cr is not None and cr.get("success") is True and bool(cr.get("message")))
|
||||
|
||||
# 11. rename_session -> session_renamed broadcast (titles map)
|
||||
ws_send(s, json.dumps({"id": 8, "type": "rename_session", "title": "Renamed by smoke"}))
|
||||
evs = collect_events(s, until_type="session_renamed")
|
||||
rn = next((e for e in evs if e["type"] == "session_renamed"), None)
|
||||
check("rename_session -> session_renamed",
|
||||
rn is not None and rn.get("display_title") == "Renamed by smoke"
|
||||
and bool(rn.get("session_id")))
|
||||
|
||||
s.close()
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"FAILED ({len(failures)}): {failures}")
|
||||
sys.exit(1)
|
||||
print("ALL CHECKS PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
# AI-Slop / Design-Authenticity Research (benchmark source)
|
||||
|
||||
Source material (fetched 2026-06-30) for the category F scorers
|
||||
(`styling`, `simplicity`, `ai_patterns`). These are the documented, repeatable
|
||||
tells of AI-generated UI we want to score AGAINST (higher reward = less slop).
|
||||
|
||||
References:
|
||||
- prg.sh "Why Your AI Keeps Building the Same Purple Gradient Website"
|
||||
- docs.bswen.com "How to Fix AI-Generated UI Designs: The Anti-Patterns Guide"
|
||||
- noqta.tn "Escaping AI Slop: Fix the 4 Overused AI UI Patterns"
|
||||
- Anthropic cookbook `frontend_aesthetics` prompt (quoted in prg.sh)
|
||||
|
||||
## The AI-slop tells (what to penalize)
|
||||
|
||||
Color / theme:
|
||||
- Purple/indigo accents and purple->blue gradients. Canonical hexes:
|
||||
`#667eea`, `#764ba2`, `#8b5cf6`, `#A855F7`, `#6366F1` (indigo-500),
|
||||
`#7C3AED`, `#818CF8`. "Tailwind indigo-500" is the origin.
|
||||
- Cyan-on-dark accents: `#38BDF8`, `#22D3EE`, neon accents on dark bg.
|
||||
- Timid, evenly-distributed palettes (no dominant color + sharp accent).
|
||||
- Gradient text for "impact" (gradient fills on numbers/headings).
|
||||
|
||||
Material / depth:
|
||||
- Glassmorphism everywhere: blur / translucent material used decoratively
|
||||
rather than purposefully (SwiftUI: `.ultraThinMaterial`, `.regularMaterial`,
|
||||
`.blur(...)` sprinkled on many surfaces).
|
||||
- Subtle shadows at exactly 0.1 opacity, applied uniformly.
|
||||
- Giant/!uniform border radius on everything ("rounded-2xl on all").
|
||||
|
||||
Layout / structure:
|
||||
- Card nesting: cards inside cards inside cards; everything wrapped in a
|
||||
container regardless of need.
|
||||
- Three-features-in-boxes-with-icons grid (the SaaS cliche).
|
||||
- Hero-metric layout: big number + small label + accent dot/line on the left.
|
||||
- "Live" badges / status pills as decoration.
|
||||
|
||||
Typography:
|
||||
- Generic fonts: Inter, Roboto, Arial, Open Sans, Lato, system defaults,
|
||||
Space Grotesk (the "even AI's escape hatch is now a tell").
|
||||
- No real hierarchy beyond "bigger text = header"; single weight everywhere.
|
||||
|
||||
Content:
|
||||
- Emoji used as UI iconography / bullets.
|
||||
- Filler copy with no required-field indicators, no error/empty states.
|
||||
|
||||
## What good (non-slop) looks like (what to reward)
|
||||
|
||||
- A committed, cohesive aesthetic: one dominant color + sharp, sparing accent
|
||||
(NOT a timid rainbow). Our app: dark terminal-native + single mint accent
|
||||
`#4DD9A6`. That is intentional and NOT a purple default -> should score well.
|
||||
- Distinctive typographic intent: deliberate mono vs. proportional pairing,
|
||||
real weight/size contrast (extremes, 3x size jumps), not one-size-fits-all.
|
||||
- Depth from restraint: solid tokenized surfaces, hairline borders, blur used
|
||||
only where it earns its place (e.g. a single overlay), not everywhere.
|
||||
- Flat hierarchy: avoid card-in-card nesting; use spacing + type as structure.
|
||||
- Simplicity: few distinct UI primitives, low nesting depth, low element
|
||||
count per screen, generous negative space used on purpose.
|
||||
|
||||
## Mapping to OUR SwiftUI app (how to measure)
|
||||
|
||||
`styling` (F): aesthetic coherence & intent.
|
||||
- SOURCE: count distinct accent colors actually used (palette cohesion: 1
|
||||
dominant accent good, many competing accents bad); presence of a real type
|
||||
scale (multiple deliberate sizes/weights via Theme.mono) vs. one size; radius
|
||||
drawn from a small consistent scale vs. arbitrary values.
|
||||
- PIXEL: dominant-color cohesion, accent sparingness (accent should be a small
|
||||
% of pixels, used for emphasis, not everywhere).
|
||||
|
||||
`simplicity` (F): anti-complexity.
|
||||
- SOURCE: view-nesting depth (max indent / brace depth of the densest view),
|
||||
count of distinct view primitives per screen, total modifier count per view,
|
||||
number of nested container shapes (RoundedRectangle/Card) stacked.
|
||||
- PIXEL: number of distinct rectangular "card" regions; visual element count;
|
||||
reward fewer, larger, well-spaced regions over many small competing ones.
|
||||
|
||||
`ai_patterns` (F): anti-slop (higher = less AI-slop).
|
||||
- SOURCE (primary): scan Theme.swift + views for slop hexes (purple/indigo/cyan
|
||||
list above), gradient usage (LinearGradient/.gradient) especially on text,
|
||||
`.ultraThinMaterial`/`.regularMaterial`/`.blur` overuse count, generic font
|
||||
names ("Inter"/"Roboto"/"Arial"/"Space Grotesk"), emoji in UI string
|
||||
literals, uniform 0.1-opacity shadows, oversized uniform corner radius,
|
||||
decorative "Live" badge. Each occurrence is a penalty; 0 occurrences = 100.
|
||||
- PIXEL (corroboration): fraction of content pixels in the purple/indigo/cyan
|
||||
hue range; presence of large gradient regions.
|
||||
|
||||
Scoring note: our app currently uses mint (not purple), solid tokenized
|
||||
surfaces, and a mono type system, so it should score HIGH on ai_patterns - the
|
||||
scorer must reward that, and would only drop if someone introduced slop. The
|
||||
one nuance: the app does have a "live" status pill; treat a SINGLE small status
|
||||
pill as acceptable (it is functional state, not decoration), but flag if badges
|
||||
proliferate.
|
||||
@@ -0,0 +1,138 @@
|
||||
# jcode iOS UX Reward Framework
|
||||
|
||||
> Goal: turn "this looks ugly / inefficient" into a single, hill-climbable
|
||||
> reward in [0, 100], decomposed into weighted categories, each backed by an
|
||||
> objective scorer. A UI change is an improvement **only if it raises the
|
||||
> weighted reward across the device x content matrix**, not one lucky screen.
|
||||
|
||||
## How reward is produced
|
||||
|
||||
```
|
||||
screenshot(s) + source tree + (optional) AX tree / runtime traces
|
||||
|
|
||||
v per category, independent scorer -> CategoryScore(0..100, evidence)
|
||||
[ scorers ]
|
||||
|
|
||||
v weighted sum, normalized
|
||||
overall reward (0..100) + per-category breakdown + worst-cell callout
|
||||
```
|
||||
|
||||
Everything runs headless on this machine via the existing harness
|
||||
(`mock_gateway.py` scenarios + `xcrun simctl` screenshots). No LLM, no device,
|
||||
no network.
|
||||
|
||||
## Category taxonomy (what matters, perceived UX first)
|
||||
|
||||
Weights sum to 1.0. The rebalance principle: what a real user of a mobile
|
||||
coding-agent chat app *feels* every minute dominates. That is (1) reading
|
||||
streaming text and tool output for long stretches -> legibility first,
|
||||
(2) the tap-cost of frequent flows (send, interrupt, switch session) ->
|
||||
ergonomics second. Abstract pixel-geometry aesthetics (fill ratios, grid snap,
|
||||
focal-point math) matter but cannot outvote "can I read it, can I reach it".
|
||||
|
||||
### A. Space & density (weight 0.15) - "no wasted pixels"
|
||||
- `space_efficiency` (0.05) canvas fill ratio, vertical balance, largest dead
|
||||
zone. Scenario-aware: the `empty` scenario is graded as
|
||||
an empty STATE (calm canvas + visible start affordance),
|
||||
never against the 30-60% transcript fill band.
|
||||
- `information_density` (0.05) useful content vs chrome. Scenario-aware: on
|
||||
`empty` it grades chrome leanness instead of transcript
|
||||
ink share (an empty transcript is not "low density").
|
||||
- `content_safety` (0.05) no clipping/overflow/truncation; nothing under chrome.
|
||||
|
||||
### B. Ergonomics & interaction (weight 0.30) - "cheap to use"
|
||||
The dominant category: users touch this app dozens of times per session.
|
||||
- `touch_targets` (0.10) interactive elements >= 44x44pt, adequate spacing.
|
||||
- `reachability` (0.08) primary actions in the comfortable thumb zone.
|
||||
- `interaction_cost` (0.12) expected seconds per action for the key flows
|
||||
(send, interrupt, switch session, change model, pair),
|
||||
KLM/Fitts model grounded in real usage logs.
|
||||
|
||||
### C. Visual clarity (weight 0.12) - "easy to parse"
|
||||
- `visual_hierarchy` (0.04) one clear focal point / salient primary action.
|
||||
Scenario-aware: on `empty`, a single obvious start
|
||||
affordance is the whole job, so the concentration
|
||||
target is relaxed (0.3 vs 0.5) and the one-focal-point
|
||||
term dominates.
|
||||
- `consistency` (0.04) design-token discipline (source) + palette discipline
|
||||
(pixel): few dominant colors, aligned margins.
|
||||
- `rhythm` (0.04) spacing snaps to the 8pt grid (source + pixel).
|
||||
|
||||
### D. Legibility & accessibility (weight 0.22) - "everyone can read it"
|
||||
Highest-leverage for this product: sessions are spent READING streaming agent
|
||||
text and tool output on a dark screen, often in bad light.
|
||||
- `contrast` (0.14) WCAG text/background contrast on real rendered
|
||||
content, including the dim secondary/tool-output tier.
|
||||
- `accessibility` (0.08) VoiceOver labels, Dynamic Type, reduce-motion,
|
||||
semantic roles present in source / AX tree.
|
||||
|
||||
### E. Responsiveness (weight 0.09) - "feels instant"
|
||||
- `layout_robustness` (0.05) stable across the device x content matrix (variance
|
||||
of per-cell scores; penalize fragile layouts).
|
||||
- `perf` (0.04) cold-launch-to-first-frame + scroll smoothness signals
|
||||
(best-effort from simctl/Instruments; degrade to N/A).
|
||||
|
||||
### F. Design authenticity & craft (weight 0.12) - "designed, not generated"
|
||||
Higher score = MORE crafted / LESS generic. Grounded in
|
||||
`reward/AI_SLOP_RESEARCH.md` (documented AI-slop tells).
|
||||
- `styling` (0.04) aesthetic coherence & intent: one dominant color + sparing
|
||||
sharp accent (not a timid rainbow), a real type scale, a
|
||||
consistent radius/elevation system. Source + pixel.
|
||||
- `simplicity` (0.04) anti-complexity: shallow view-nesting depth, few distinct UI
|
||||
primitives per screen, minimal card-in-card nesting, generous
|
||||
purposeful negative space. Source + pixel.
|
||||
- `ai_patterns` (0.04) anti-slop (higher = less slop): penalize purple/indigo/cyan
|
||||
slop palettes, gradient text, glassmorphism/blur overuse,
|
||||
generic fonts (Inter/Roboto/Arial/Space Grotesk), emoji-as-UI,
|
||||
uniform 0.1 shadows, oversized uniform radius, decorative
|
||||
badges. 0 tells = 100. Source primary, pixel corroboration.
|
||||
|
||||
## Scorer contract
|
||||
|
||||
Every scorer is a Python module under `TestHarness/reward/scorers/<name>.py`
|
||||
exposing:
|
||||
|
||||
```python
|
||||
NAME = "space_efficiency" # unique id, matches taxonomy
|
||||
CATEGORY = "A" # taxonomy group letter
|
||||
WEIGHT = 0.12 # relative weight within the framework
|
||||
|
||||
def score(ctx: "Context") -> "CategoryScore":
|
||||
"Pure function: read ctx, return a CategoryScore. No global mutation."
|
||||
```
|
||||
|
||||
- `Context` (provided by `reward/context.py`) gives a scorer everything it may
|
||||
need: the screenshot path + decoded numpy array, the device + scenario, the
|
||||
px-per-point scale, the source root, an optional AX-tree JSON, and an
|
||||
optional runtime-metrics dict. Scorers use only what they need.
|
||||
- Scorers SHOULD be scenario-aware via `ctx.scenario` when the scenario changes
|
||||
what a good screen looks like (e.g. `empty` is a deliberate empty state).
|
||||
Scenario-aware scorers report a `mode` key in evidence so per-cell reports
|
||||
stay auditable.
|
||||
- `CategoryScore` (in `reward/types.py`): `{name, category, weight, value:
|
||||
0..100, evidence: dict, available: bool}`. `available=False` means "could not
|
||||
measure here" (e.g. perf without Instruments); the aggregator drops it and
|
||||
renormalizes weights so missing data never silently tanks the reward.
|
||||
- Scorers must be deterministic and side-effect free. Determinism is enforced
|
||||
by `reward/test_determinism.py` (same input -> same output).
|
||||
|
||||
## Aggregation
|
||||
|
||||
`reward/aggregate.py`:
|
||||
- discovers all scorer modules,
|
||||
- runs each over every matrix cell (device x scenario),
|
||||
- per cell: weighted mean of available categories (weights renormalized),
|
||||
- overall: mean across cells, plus the single worst cell and worst category,
|
||||
- emits a JSON report and a human table; supports
|
||||
`--baseline-json A --candidate-json B` for regression gating in CI.
|
||||
|
||||
## Why this design
|
||||
|
||||
- **Parallel-safe:** one file per scorer means swarm workers never edit the
|
||||
same file. The contract + `types.py`/`context.py` are the only shared API.
|
||||
- **Honest:** scorers read rendered pixels / real source, not the view model,
|
||||
so they can't be gamed by lying state.
|
||||
- **Hill-climbable:** the matrix mean is the objective; `--baseline/--candidate`
|
||||
makes every change a measurable +/- delta.
|
||||
- **Extensible:** add a category by dropping in a module; the aggregator finds
|
||||
it automatically.
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Scorer discovery + reward aggregation.
|
||||
|
||||
Finds every module under reward/scorers/ that exposes the scorer contract
|
||||
(NAME, CATEGORY, WEIGHT, score(ctx) -> CategoryScore), runs them over a matrix
|
||||
of (device x scenario) cells, and produces a single hill-climbable reward.
|
||||
|
||||
Per cell: weighted mean of *available* category scores (weights renormalized so
|
||||
unavailable scorers never tank the reward). Overall: mean across cells, plus
|
||||
the worst cell and worst category so you know exactly what to fix next.
|
||||
|
||||
Usage:
|
||||
python3 -m reward.aggregate --matrix-json matrix.json # score cells
|
||||
python3 -m reward.aggregate --shot a.png --device "iPhone 17" --scenario short
|
||||
python3 -m reward.aggregate --baseline before.json --candidate after.json
|
||||
|
||||
`--matrix-json` consumes the output of ui_matrix.py --json (which lists shots
|
||||
per device/scenario); without it, score a single screenshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import pkgutil
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
IOS = HERE.parent.parent
|
||||
sys.path.insert(0, str(HERE.parent)) # so `import reward...` works
|
||||
|
||||
from reward.context import Context # noqa: E402
|
||||
from reward.types import CategoryScore # noqa: E402
|
||||
|
||||
DEFAULT_SOURCE_ROOT = str(IOS / "Sources" / "JCodeMobile")
|
||||
|
||||
|
||||
def discover_scorers():
|
||||
"""Import every scorer module and return [(module)] that satisfy the contract."""
|
||||
scorers_pkg = HERE / "scorers"
|
||||
found = []
|
||||
for info in pkgutil.iter_modules([str(scorers_pkg)]):
|
||||
if info.name.startswith("_"):
|
||||
continue
|
||||
mod = importlib.import_module(f"reward.scorers.{info.name}")
|
||||
if all(hasattr(mod, a) for a in ("NAME", "CATEGORY", "WEIGHT", "score")):
|
||||
found.append(mod)
|
||||
else:
|
||||
print(f"warning: {info.name} missing contract attrs, skipped", file=sys.stderr)
|
||||
return sorted(found, key=lambda m: (m.CATEGORY, m.NAME))
|
||||
|
||||
|
||||
def score_cell(ctx: Context, scorers) -> dict:
|
||||
cats = []
|
||||
for mod in scorers:
|
||||
try:
|
||||
cs = mod.score(ctx)
|
||||
except Exception as e: # a broken scorer must not kill the run
|
||||
cs = CategoryScore(mod.NAME, mod.CATEGORY, mod.WEIGHT,
|
||||
value=0.0, evidence={"error": str(e)}, available=False)
|
||||
cats.append(cs)
|
||||
|
||||
available = [c for c in cats if c.available]
|
||||
wsum = sum(c.weight for c in available)
|
||||
if wsum > 0:
|
||||
cell_reward = sum(c.clamped() * c.weight for c in available) / wsum
|
||||
else:
|
||||
cell_reward = 0.0
|
||||
|
||||
return {
|
||||
"device": ctx.device,
|
||||
"scenario": ctx.scenario,
|
||||
"content_size": ctx.meta.get("content_size", "large"),
|
||||
"shot": ctx.screenshot,
|
||||
"reward": round(cell_reward, 2),
|
||||
"categories": [asdict(c) for c in cats],
|
||||
}
|
||||
|
||||
|
||||
def aggregate(cells: list[dict]) -> dict:
|
||||
if not cells:
|
||||
return {"reward": 0.0, "cells": [], "by_category": {}, "worst_cell": None}
|
||||
|
||||
overall = sum(c["reward"] for c in cells) / len(cells)
|
||||
|
||||
# Mean per category id across cells (available only).
|
||||
by_cat: dict[str, list[float]] = {}
|
||||
cat_meta: dict[str, tuple[str, float]] = {}
|
||||
for cell in cells:
|
||||
for c in cell["categories"]:
|
||||
if c["available"]:
|
||||
by_cat.setdefault(c["name"], []).append(c["value"])
|
||||
cat_meta[c["name"]] = (c["category"], c["weight"])
|
||||
cat_means = {
|
||||
name: {
|
||||
"category": cat_meta[name][0],
|
||||
"weight": cat_meta[name][1],
|
||||
"mean": round(sum(v) / len(v), 2),
|
||||
"n": len(v),
|
||||
}
|
||||
for name, v in by_cat.items()
|
||||
}
|
||||
|
||||
worst_cell = min(cells, key=lambda c: c["reward"])
|
||||
worst_cat = min(cat_means.items(), key=lambda kv: kv[1]["mean"]) if cat_means else None
|
||||
|
||||
return {
|
||||
"reward": round(overall, 2),
|
||||
"cells": cells,
|
||||
"by_category": cat_means,
|
||||
"worst_cell": {"device": worst_cell["device"],
|
||||
"scenario": worst_cell["scenario"],
|
||||
"content_size": worst_cell.get("content_size", "large"),
|
||||
"reward": worst_cell["reward"]},
|
||||
"worst_category": ({"name": worst_cat[0], **worst_cat[1]} if worst_cat else None),
|
||||
}
|
||||
|
||||
|
||||
def render(report: dict) -> str:
|
||||
out = ["UX reward", "=" * 52]
|
||||
out.append(f" OVERALL {report['reward']:5.1f}/100 "
|
||||
f"({len(report['cells'])} cells)")
|
||||
out.append("")
|
||||
out.append(" by category (mean across cells):")
|
||||
for name, d in sorted(report["by_category"].items(),
|
||||
key=lambda kv: (kv[1]["category"], kv[0])):
|
||||
out.append(f" [{d['category']}] {name:20} {d['mean']:5.1f} (w={d['weight']:.2f})")
|
||||
out.append("")
|
||||
out.append(" per cell:")
|
||||
out.append(f" {'device':22} {'size':14} {'scenario':9} {'reward':>6}")
|
||||
for c in report["cells"]:
|
||||
size = c.get("content_size", "large").replace("accessibility", "a11y")
|
||||
out.append(f" {c['device'][:22]:22} {size[:14]:14} "
|
||||
f"{c['scenario']:9} {c['reward']:6.1f}")
|
||||
out.append("-" * 52)
|
||||
if report.get("worst_cell"):
|
||||
w = report["worst_cell"]
|
||||
out.append(f" worst cell: {w['reward']:.1f} "
|
||||
f"({w['device']} / {w.get('content_size', 'large')} / "
|
||||
f"{w['scenario']})")
|
||||
if report.get("worst_category"):
|
||||
w = report["worst_category"]
|
||||
out.append(f" worst category: {w['mean']:.1f} ({w['name']})")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def build_cells_from_matrix(matrix_json: str, source_root: str) -> list[Context]:
|
||||
data = json.loads(Path(matrix_json).read_text())
|
||||
ctxs = []
|
||||
for row in data:
|
||||
runtime = row.get("runtime")
|
||||
if not isinstance(runtime, dict) or not runtime:
|
||||
runtime = None
|
||||
meta = {}
|
||||
if row.get("content_size"):
|
||||
meta["content_size"] = row["content_size"]
|
||||
ctxs.append(Context(
|
||||
screenshot=row.get("shot"),
|
||||
device=row.get("device", "iPhone 17"),
|
||||
scenario=row.get("scenario", "short"),
|
||||
scale=int(row.get("scale", 3)),
|
||||
source_root=source_root,
|
||||
runtime=runtime,
|
||||
meta=meta,
|
||||
))
|
||||
return ctxs
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--matrix-json", help="output of ui_matrix.py --json")
|
||||
ap.add_argument("--shot", help="single screenshot path")
|
||||
ap.add_argument("--device", default="iPhone 17")
|
||||
ap.add_argument("--scenario", default="short")
|
||||
ap.add_argument("--scale", type=int, default=3)
|
||||
ap.add_argument("--source-root", default=DEFAULT_SOURCE_ROOT)
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--out-json", help="write the full report JSON here")
|
||||
ap.add_argument("--baseline", help="baseline report JSON for regression gate")
|
||||
ap.add_argument("--candidate", help="candidate report JSON for regression gate")
|
||||
ap.add_argument("--min", type=float, default=0.0, help="fail if overall < this")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.baseline and args.candidate:
|
||||
a = json.loads(Path(args.baseline).read_text())
|
||||
b = json.loads(Path(args.candidate).read_text())
|
||||
delta = b["reward"] - a["reward"]
|
||||
print(f"baseline {a['reward']:5.1f}")
|
||||
print(f"candidate {b['reward']:5.1f}")
|
||||
print(f"delta {'+' if delta >= 0 else ''}{delta:.1f}")
|
||||
sys.exit(0 if delta >= -0.5 else 1)
|
||||
|
||||
scorers = discover_scorers()
|
||||
if not scorers:
|
||||
print("no scorers found under reward/scorers/", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
if args.matrix_json:
|
||||
ctxs = build_cells_from_matrix(args.matrix_json, args.source_root)
|
||||
elif args.shot:
|
||||
ctxs = [Context(screenshot=args.shot, device=args.device,
|
||||
scenario=args.scenario, scale=args.scale,
|
||||
source_root=args.source_root)]
|
||||
else:
|
||||
ap.error("provide --matrix-json or --shot")
|
||||
|
||||
cells = [score_cell(ctx, scorers) for ctx in ctxs]
|
||||
report = aggregate(cells)
|
||||
|
||||
if args.out_json:
|
||||
Path(args.out_json).write_text(json.dumps(report, indent=2))
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2))
|
||||
else:
|
||||
print(render(report))
|
||||
|
||||
sys.exit(1 if report["reward"] < args.min else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Context passed to every reward scorer.
|
||||
|
||||
A scorer reads only what it needs from this object. The aggregator builds one
|
||||
Context per matrix cell (device x scenario) and reuses it across all scorers,
|
||||
so expensive work (decoding the screenshot, parsing the source tree) happens
|
||||
once.
|
||||
|
||||
Heavy fields are lazy: the numpy image and source-file map are only loaded on
|
||||
first access. Optional fields (ax_tree, runtime) are None when not collected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# iPhone OS chrome fractions (status bar / home indicator). Scorers that grade
|
||||
# the app's own content area should trim these.
|
||||
STATUS_BAR_FRAC = 0.055
|
||||
HOME_INDICATOR_FRAC = 0.025
|
||||
|
||||
# Design tokens, mirrored from Sources/JCodeMobile/Theme.swift. Shared so every
|
||||
# scorer agrees on "the background" / "the accent".
|
||||
TOKENS = {
|
||||
"background": 0x0F0F14,
|
||||
"surface": 0x1A1A1F,
|
||||
"surfaceElevated": 0x242429,
|
||||
"mint": 0x4DD9A6,
|
||||
"warning": 0xF59E0B,
|
||||
"error": 0xD94D59,
|
||||
}
|
||||
|
||||
|
||||
def hex_to_rgb(h: int) -> np.ndarray:
|
||||
return np.array([(h >> 16) & 0xFF, (h >> 8) & 0xFF, h & 0xFF], dtype=np.float64)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
screenshot: Optional[str] = None # path to a PNG
|
||||
device: str = "iPhone 17"
|
||||
scenario: str = "short"
|
||||
scale: int = 3 # device px per point
|
||||
source_root: Optional[str] = None # e.g. Sources/JCodeMobile
|
||||
ax_tree_path: Optional[str] = None # optional accessibility tree JSON
|
||||
runtime: Optional[dict] = None # optional perf metrics dict
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# --- lazy, cached derivations ----------------------------------------
|
||||
@cached_property
|
||||
def image(self) -> Optional[Image.Image]:
|
||||
if not self.screenshot:
|
||||
return None
|
||||
return Image.open(self.screenshot).convert("RGB")
|
||||
|
||||
@cached_property
|
||||
def pixels(self) -> Optional[np.ndarray]:
|
||||
img = self.image
|
||||
return None if img is None else np.asarray(img, dtype=np.float64)
|
||||
|
||||
@cached_property
|
||||
def content_pixels(self) -> Optional[np.ndarray]:
|
||||
"""The app content region with OS chrome trimmed top/bottom."""
|
||||
arr = self.pixels
|
||||
if arr is None:
|
||||
return None
|
||||
h = arr.shape[0]
|
||||
top = int(h * STATUS_BAR_FRAC)
|
||||
bot = int(h * (1 - HOME_INDICATOR_FRAC))
|
||||
return arr[top:bot]
|
||||
|
||||
@cached_property
|
||||
def content_mask(self) -> Optional[np.ndarray]:
|
||||
"""Boolean mask of pixels that differ from the app background."""
|
||||
arr = self.content_pixels
|
||||
if arr is None:
|
||||
return None
|
||||
bg = hex_to_rgb(TOKENS["background"])
|
||||
dist = np.linalg.norm(arr - bg, axis=2)
|
||||
return dist > 18.0
|
||||
|
||||
@cached_property
|
||||
def source_files(self) -> dict[str, str]:
|
||||
"""Map of relative path -> file text for every .swift under source_root."""
|
||||
if not self.source_root:
|
||||
return {}
|
||||
root = Path(self.source_root)
|
||||
if not root.exists():
|
||||
return {}
|
||||
out = {}
|
||||
for f in sorted(root.rglob("*.swift")):
|
||||
out[str(f.relative_to(root))] = f.read_text(encoding="utf-8", errors="replace")
|
||||
return out
|
||||
|
||||
@cached_property
|
||||
def ax_tree(self) -> Optional[dict]:
|
||||
if not self.ax_tree_path or not Path(self.ax_tree_path).exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(Path(self.ax_tree_path).read_text())
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,59 @@
|
||||
# jcode TUI log schema (for grounding the user model in real usage)
|
||||
|
||||
Logs live in `~/.jcode/logs/jcode-YYYY-MM-DD.log`. Lines look like:
|
||||
|
||||
[2026-06-28 00:15:41.814] [INFO] <message>
|
||||
|
||||
`<message>` is sometimes a structured event:
|
||||
|
||||
EVENT event=<TYPE> key=value key=value ...
|
||||
|
||||
## Structured EVENT types observed (3-day sample, by volume)
|
||||
|
||||
| event= | vol | meaning / useful fields |
|
||||
|--------|-----|-------------------------|
|
||||
| AGENT_PROVIDER_STREAM_LIFECYCLE | 12303 | model streaming; not a user action |
|
||||
| SESSION_PERSISTENCE | 11935 | session saved; `append_ms`, `chars` |
|
||||
| TOOL_LIFECYCLE | 9907 | a tool ran. `resolved_tool_name=`, `phase=start|end`, `execution_mode=AgentTurn`, `cwd=` |
|
||||
| model_routes_summary | 4235 | routing; not a user action |
|
||||
| SERVER_REQUEST_LIFECYCLE | 2222 | a client request hit the server (proxy for a user message / command) |
|
||||
| SESSION_LIFECYCLE | 1750 | `phase=`, `client_connection_id=`, `allow_takeover=`, `client_has_local_history=` |
|
||||
| SWARM_LIFECYCLE | 963 | swarm member status; `phase=member_status_updated`, `new_status=` |
|
||||
|
||||
## User-action verbs (grep counts, 3-day sample)
|
||||
|
||||
These are the closest proxies to "what the user does", and the actions the iOS
|
||||
app must also support, so they should weight the mobile user graph:
|
||||
|
||||
| verb | count | iOS equivalent action |
|
||||
|------|-------|-----------------------|
|
||||
| compact | 8399 | context compaction notice (passive) |
|
||||
| diff_mode | 1951 | (TUI-only; n/a on mobile) |
|
||||
| interrupt | 1845 | cancel / stop button |
|
||||
| soft_interrupt | 1164 | queue-a-message-mid-run |
|
||||
| cancel | 262 | cancel |
|
||||
| scroll_up/down/page | ~ | transcript scrolling |
|
||||
| resume | 251 (today) | resume_session / switch session |
|
||||
| side_panel | 39 | (n/a on mobile) |
|
||||
|
||||
## How to mine it (for log_mining.py)
|
||||
|
||||
1. Read the last N daily logs (default 7) under `~/.jcode/logs/`.
|
||||
2. Count: user messages (`SERVER_REQUEST_LIFECYCLE` start, or `Assistant:` turns
|
||||
as a proxy for turns), interrupts, soft_interrupts, cancels, resumes/session
|
||||
switches, model switches, scrolls, tool runs (`TOOL_LIFECYCLE phase=start`).
|
||||
3. Emit a normalized frequency profile dict, e.g.:
|
||||
`{"send_message": 0.55, "scroll": 0.20, "soft_interrupt": 0.08,
|
||||
"interrupt": 0.06, "switch_session": 0.05, "change_model": 0.02, ...}`
|
||||
These become the relative edge `weight`s in the mobile ActionGraph.
|
||||
4. Be robust: logs are huge (100k+ lines/day) and noisy; stream line-by-line,
|
||||
tolerate missing files, and DEGRADE GRACEFULLY to literature-default weights
|
||||
if no logs are found (so the engine still runs in CI / on a fresh machine).
|
||||
|
||||
## Caveats (be honest in evidence)
|
||||
|
||||
- TUI usage is a *proxy* for mobile usage, not identical (no diff_mode/side_panel
|
||||
on mobile; mobile likely has relatively MORE scroll + read, fewer power-tools).
|
||||
log_mining.py should expose the raw TUI counts AND the mobile-mapped weights so
|
||||
the mapping assumptions are auditable.
|
||||
- These logs are this user's personal data; keep mining read-only and local.
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Interaction-cost engine: a graph-based, HCI-grounded model of jcode-mobile use.
|
||||
|
||||
See model.py for the shared data model and the design rationale. Workers build
|
||||
disjoint modules in this package against that contract:
|
||||
|
||||
model.py shared types (DONE; do not edit destructively)
|
||||
log_mining.py mine ~/.jcode/logs to ground edge weights in REAL TUI usage
|
||||
ui_map.py map the SwiftUI source -> UITarget geometry per screen
|
||||
cost_model.py price one action in seconds (KLM/TLM operators + Fitts)
|
||||
user_model.py build the weighted ActionGraph (states/actions/tasks)
|
||||
engine.py stationary distribution + expected cost + task times
|
||||
"""
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Price a single user Action in SECONDS (KLM/TLM operators + Fitts' law).
|
||||
|
||||
This module is the `cost_model` worker described in the package docstring. It is
|
||||
pure and deterministic and depends only on the shared contract in `model.py`
|
||||
plus the standard library.
|
||||
|
||||
Pricing model
|
||||
-------------
|
||||
A user Action carries a list of KLM/Touch-Level-Model operator letters
|
||||
(``Action.operators``) plus an optional tapped ``target_id`` and a system
|
||||
``response_s`` wait. We price it as::
|
||||
|
||||
seconds = sum(operator_time(letter) for letter in operators)
|
||||
+ fitts_movement_time(for each TAP that acquires a target)
|
||||
+ response_s
|
||||
+ (unreachable penalty if the target does not exist)
|
||||
|
||||
Operator letters map to the literature-grounded times in ``Operators``:
|
||||
``M -> ops.M``, ``TAP -> ops.TAP``, ``H -> ops.H``, ``K -> ops.K``. Any other
|
||||
letter is skipped (contributes 0 s) but counted in ``detail['unknown_ops']``.
|
||||
|
||||
Fitts' law (touch form): ``MT = a + b * log2(D / W + 1)`` seconds, with
|
||||
``a = ops.FITTS_A`` and ``b = ops.FITTS_B``.
|
||||
|
||||
Choice of W (effective target width along the movement axis)
|
||||
------------------------------------------------------------
|
||||
Fitts' W is the tolerance of the target measured *along the direction of
|
||||
motion*. For an axis-aligned rectangular control the truly axis-projected width
|
||||
depends on the (sometimes undefined, e.g. zero-distance) movement direction, so
|
||||
we instead use ``W = min(width_pt, height_pt)`` as a deterministic, direction-
|
||||
independent, conservative proxy: the narrowest dimension is the worst case the
|
||||
thumb must hit, which upper-bounds difficulty and never over-credits an easy
|
||||
diagonal approach. This also stays well-defined when D == 0 (a repeat tap on an
|
||||
already-acquired target). See ``EFFECTIVE_WIDTH_CHOICE`` below.
|
||||
|
||||
Movement-time accounting
|
||||
------------------------
|
||||
A Fitts movement is charged once per *target acquisition*. We track a running
|
||||
cursor/thumb position: it starts at ``prev_target_id``'s center (or a neutral
|
||||
bottom-center thumb-reach anchor when ``prev`` is None/unknown). The first TAP
|
||||
pays the full move from there to the target center; any subsequent TAP on the
|
||||
same target has D == 0 and therefore pays only the Fitts intercept ``a`` (0 s by
|
||||
default), matching the "movement added once per acquisition" rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .model import Action, CostBreakdown, Operators, UITarget
|
||||
|
||||
# --- Tunables (documented, deterministic) -----------------------------------
|
||||
|
||||
# Neutral thumb-rest anchor used when there is no known previous target. iPhone
|
||||
# portrait logical canvas ~393 x 852 pt (iPhone 14/15); a relaxed thumb sits
|
||||
# near the bottom-center, so default reaches start there.
|
||||
SCREEN_W_PT: float = 393.0
|
||||
SCREEN_H_PT: float = 852.0
|
||||
NEUTRAL_POINT: tuple[float, float] = (SCREEN_W_PT / 2.0, SCREEN_H_PT * 0.92)
|
||||
|
||||
# Cost charged when an action targets a control with exists=False. Chosen large
|
||||
# enough to dominate any realistic flow so optimizers strongly avoid it.
|
||||
UNREACHABLE_PENALTY_S: float = 1_000_000.0
|
||||
|
||||
# Human-readable note of the W convention (see module docstring).
|
||||
EFFECTIVE_WIDTH_CHOICE: str = "min(width_pt, height_pt) # conservative, direction-independent"
|
||||
|
||||
# Operator-letter -> attribute on Operators. Letters absent here are "unknown".
|
||||
_OPERATOR_ATTR: dict[str, str] = {"M": "M", "TAP": "TAP", "H": "H", "K": "K"}
|
||||
|
||||
|
||||
def fitts_time(distance_pt: float, target_w_pt: float, ops: Operators = Operators()) -> float:
|
||||
"""Fitts' law touch movement time in seconds: ``a + b * log2(D/W + 1)``.
|
||||
|
||||
``distance_pt`` is the travel distance D (pt); ``target_w_pt`` is the
|
||||
effective target width W (pt). W is guarded to be > 0; non-positive widths
|
||||
are clamped to a tiny epsilon so the log stays finite (treated as a very hard
|
||||
target). Negative distances are clamped to 0.
|
||||
"""
|
||||
d = max(0.0, float(distance_pt))
|
||||
w = float(target_w_pt)
|
||||
if w <= 0.0:
|
||||
w = 1e-9 # guard W > 0; degenerate target -> maximally hard
|
||||
return ops.FITTS_A + ops.FITTS_B * math.log2(d / w + 1.0)
|
||||
|
||||
|
||||
def _effective_width(target: UITarget) -> float:
|
||||
"""W along the movement axis (see module docstring: conservative proxy)."""
|
||||
return min(target.width_pt, target.height_pt)
|
||||
|
||||
|
||||
def _euclidean(a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
|
||||
def action_cost(
|
||||
action: Action,
|
||||
targets: dict[str, UITarget],
|
||||
prev_target_id: str | None = None,
|
||||
ops: Operators = Operators(),
|
||||
) -> CostBreakdown:
|
||||
"""Price one ``Action`` in seconds with a per-operator + Fitts + response breakdown.
|
||||
|
||||
Sums operator times for ``action.operators``, adds a Fitts movement time for
|
||||
each TAP that acquires ``action.target_id`` (from the previous thumb position),
|
||||
adds ``action.response_s``, and applies a large penalty if the action's target
|
||||
has ``exists=False``. Returns a ``CostBreakdown`` whose ``detail`` aggregates
|
||||
per-operator seconds plus ``'fitts'`` and ``'response'`` (and ``'unknown_ops'``
|
||||
/ ``'unreachable'`` when relevant).
|
||||
"""
|
||||
detail: dict[str, float] = {}
|
||||
seconds = 0.0
|
||||
|
||||
cur_target: UITarget | None = (
|
||||
targets.get(action.target_id) if action.target_id is not None else None
|
||||
)
|
||||
target_unreachable = cur_target is not None and not cur_target.exists
|
||||
|
||||
# Running thumb position: start at the previous target's center, else neutral.
|
||||
prev_target = targets.get(prev_target_id) if prev_target_id is not None else None
|
||||
cursor: tuple[float, float] = (
|
||||
(prev_target.x_pt, prev_target.y_pt) if prev_target is not None else NEUTRAL_POINT
|
||||
)
|
||||
|
||||
# 1) KLM/TLM operator times + Fitts movement charged per TAP acquisition.
|
||||
fitts_total = 0.0
|
||||
unknown_ops = 0.0
|
||||
for letter in action.operators:
|
||||
attr = _OPERATOR_ATTR.get(letter)
|
||||
if attr is None:
|
||||
unknown_ops += 1.0
|
||||
continue
|
||||
op_time = float(getattr(ops, attr))
|
||||
seconds += op_time
|
||||
detail[letter] = detail.get(letter, 0.0) + op_time
|
||||
|
||||
if letter == "TAP" and cur_target is not None and not target_unreachable:
|
||||
w = _effective_width(cur_target)
|
||||
target_center = (cur_target.x_pt, cur_target.y_pt)
|
||||
d = _euclidean(cursor, target_center)
|
||||
mt = fitts_time(d, w, ops)
|
||||
fitts_total += mt
|
||||
seconds += mt
|
||||
cursor = target_center # target now acquired; repeat taps cost only `a`
|
||||
|
||||
detail["fitts"] = fitts_total
|
||||
if unknown_ops:
|
||||
detail["unknown_ops"] = unknown_ops
|
||||
|
||||
# 2) System / network response wait.
|
||||
detail["response"] = float(action.response_s)
|
||||
seconds += float(action.response_s)
|
||||
|
||||
# 3) Reachability penalty (control absent in this build).
|
||||
if target_unreachable:
|
||||
detail["unreachable"] = UNREACHABLE_PENALTY_S
|
||||
seconds += UNREACHABLE_PENALTY_S
|
||||
|
||||
return CostBreakdown(action_id=action.id, seconds=seconds, detail=detail)
|
||||
|
||||
|
||||
# --- Literature self-test ----------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
ops = Operators()
|
||||
|
||||
def show(label: str, bd: CostBreakdown) -> None:
|
||||
parts = ", ".join(f"{k}={v:.3f}" for k, v in bd.detail.items())
|
||||
print(f" {label}: {bd.seconds:.3f} s [{parts}]")
|
||||
|
||||
print("Operators (literature defaults):", ops)
|
||||
print(f"Effective W choice: {EFFECTIVE_WIDTH_CHOICE}")
|
||||
print(f"Neutral thumb anchor: {NEUTRAL_POINT}")
|
||||
|
||||
# --- Fitts monotonicity sanity ------------------------------------------
|
||||
far_small = fitts_time(300, 44, ops) # far + small target
|
||||
near_big = fitts_time(50, 88, ops) # near + big target
|
||||
print("\nFitts' law sanity:")
|
||||
print(f" fitts_time(300, 44) = {far_small:.3f} s")
|
||||
print(f" fitts_time(50, 88) = {near_big:.3f} s")
|
||||
assert far_small > near_big, "farther+smaller must cost more than near+big"
|
||||
# D == W => log2(2) = 1 bit => MT == a + b.
|
||||
one_bit = fitts_time(44, 44, ops)
|
||||
assert math.isclose(one_bit, ops.FITTS_A + ops.FITTS_B, rel_tol=1e-9), one_bit
|
||||
print(f" fitts_time(W, W) = a + b = {one_bit:.3f} s (1 bit of difficulty)")
|
||||
|
||||
# --- Wikipedia KLM example sanity ---------------------------------------
|
||||
# Classic KLM "point to a button and press it" reduces, in the touch model,
|
||||
# to a mental decision M, the discrete TAP, and the Fitts point. We verify a
|
||||
# one-tap button-press flow equals M + TAP + fitts (+ response).
|
||||
send_btn = UITarget(id="send", width_pt=44, height_pt=44, x_pt=360, y_pt=300)
|
||||
targets = {send_btn.id: send_btn}
|
||||
tap_send = Action(
|
||||
id="tap_send", label="Tap Send", src="chat", dst="chat",
|
||||
weight=1.0, target_id="send", operators=["M", "TAP"], response_s=0.0,
|
||||
)
|
||||
bd = action_cost(tap_send, targets, prev_target_id=None, ops=ops)
|
||||
expected = ops.M + ops.TAP + bd.detail["fitts"]
|
||||
assert math.isclose(bd.seconds, expected, rel_tol=1e-9), (bd.seconds, expected)
|
||||
assert math.isclose(bd.detail["M"], 1.35) and math.isclose(bd.detail["TAP"], 0.20)
|
||||
print("\nKLM 1-tap button-press flow == M + TAP + fitts (+response):")
|
||||
show("tap send", bd)
|
||||
print(f" check: M(1.35)+TAP(0.20)+fitts({bd.detail['fitts']:.3f}) "
|
||||
f"= {expected:.3f} s")
|
||||
|
||||
# --- A couple more priced sample actions --------------------------------
|
||||
print("\nSample priced actions:")
|
||||
|
||||
# Type a short message: homing to keyboard + 5 keystrokes, no Fitts target.
|
||||
type_msg = Action(
|
||||
id="type_hi", label="Type 'hello'", src="chat", dst="chat",
|
||||
weight=1.0, target_id=None, operators=["H", "M", "K", "K", "K", "K", "K"],
|
||||
response_s=0.0,
|
||||
)
|
||||
show("type 'hello'", action_cost(type_msg, targets, ops=ops))
|
||||
|
||||
# Tap send right after typing (thumb already near keyboard area): moving from
|
||||
# a known previous target shortens the Fitts move vs. the neutral reach.
|
||||
kbd_key = UITarget(id="kbd_o", width_pt=30, height_pt=42, x_pt=300, y_pt=720)
|
||||
targets2 = {**targets, kbd_key.id: kbd_key}
|
||||
show("tap send (prev=kbd_o)",
|
||||
action_cost(tap_send, targets2, prev_target_id="kbd_o", ops=ops))
|
||||
|
||||
# Open a sheet that must present (network/animation response) with M+TAP.
|
||||
settings_btn = UITarget(id="settings", width_pt=44, height_pt=44, x_pt=30, y_pt=60)
|
||||
open_settings = Action(
|
||||
id="open_settings", label="Open Settings", src="chat", dst="settings_sheet",
|
||||
weight=1.0, target_id="settings", operators=["M", "TAP"], response_s=0.35,
|
||||
)
|
||||
show("open settings (+0.35 s present)",
|
||||
action_cost(open_settings, {**targets, settings_btn.id: settings_btn}, ops=ops))
|
||||
|
||||
# Unreachable control (exists=False) => dominated by the penalty + noted.
|
||||
ghost = UITarget(id="ghost", width_pt=44, height_pt=44, x_pt=200, y_pt=200, exists=False)
|
||||
tap_ghost = Action(
|
||||
id="tap_ghost", label="Tap missing control", src="chat", dst="chat",
|
||||
weight=1.0, target_id="ghost", operators=["M", "TAP"], response_s=0.0,
|
||||
)
|
||||
gbd = action_cost(tap_ghost, {ghost.id: ghost}, ops=ops)
|
||||
assert gbd.seconds >= UNREACHABLE_PENALTY_S and "unreachable" in gbd.detail
|
||||
show("tap missing control (exists=False)", gbd)
|
||||
|
||||
# Unknown operator letters are skipped but counted.
|
||||
weird = Action(
|
||||
id="weird", label="Unknown ops", src="chat", dst="chat",
|
||||
weight=1.0, target_id=None, operators=["M", "Z", "TAP", "Q"], response_s=0.0,
|
||||
)
|
||||
wbd = action_cost(weird, targets, ops=ops)
|
||||
assert wbd.detail.get("unknown_ops") == 2.0
|
||||
show("unknown ops [M,Z,TAP,Q]", wbd)
|
||||
|
||||
print("\nAll self-test assertions passed.")
|
||||
@@ -0,0 +1,202 @@
|
||||
"""engine.py - price the user-behavior graph into interaction-cost metrics.
|
||||
|
||||
Given the weighted ActionGraph (user_model) and target geometry (ui_map), this:
|
||||
|
||||
1. Normalizes each state's out-edge weights into transition probabilities,
|
||||
forming a Markov chain over UI states.
|
||||
2. Solves for the stationary distribution pi (how often a user occupies each
|
||||
state over a long session) via power iteration.
|
||||
3. Prices every action in SECONDS with the KLM/TLM + Fitts cost_model, tracking
|
||||
the previous target so repeat-tap Fitts movement is charged correctly.
|
||||
4. Produces:
|
||||
- expected_action_cost_s: the expected seconds per action, weighting each
|
||||
action by pi[state] * P(action | state). This is the headline number:
|
||||
the average cost of a thing the user does, in real seconds.
|
||||
- task_times: completion time per canonical Task (sum of its action costs),
|
||||
and a frequency-weighted mean task time.
|
||||
- per_state and per_action detail for debugging / optimization targeting.
|
||||
|
||||
Lower seconds = better. To feed the reward (higher=better), call
|
||||
reward_score(), which maps expected cost through a calibrated curve to 0..100.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
|
||||
from reward.interaction.cost_model import action_cost
|
||||
from reward.interaction.model import Action, ActionGraph, Operators, UITarget
|
||||
from reward.interaction.user_model import build_user_model
|
||||
|
||||
|
||||
def _targets_for_action(a: Action, targets: dict[str, UITarget]) -> dict[str, UITarget]:
|
||||
"""Return targets with the action's OWN target forced present.
|
||||
|
||||
Some controls are context-conditional (e.g. the composer 'stop' button only
|
||||
appears while a turn is running). The ui_map marks those exists=False, but
|
||||
when the user performs the action that uses them, they are by definition on
|
||||
screen. Forcing just this action's target present avoids a spurious
|
||||
'unreachable' penalty while still flagging genuinely missing controls.
|
||||
"""
|
||||
if not a.target_id or a.target_id not in targets:
|
||||
return targets
|
||||
t = targets[a.target_id]
|
||||
if t.exists:
|
||||
return targets
|
||||
patched = dict(targets)
|
||||
patched[a.target_id] = replace(t, exists=True)
|
||||
return patched
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineResult:
|
||||
expected_action_cost_s: float
|
||||
mean_task_time_s: float
|
||||
stationary: dict[str, float]
|
||||
action_costs_s: dict[str, float]
|
||||
action_probability: dict[str, float] # pi[src] * P(action|src)
|
||||
task_times_s: dict[str, float]
|
||||
meta: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _stationary_distribution(graph: ActionGraph, iters: int = 500, tol: float = 1e-9) -> dict[str, float]:
|
||||
"""Power-iterate the state-transition matrix to its stationary distribution.
|
||||
|
||||
Transition prob from s = sum over out-edges to dst of normalized weight.
|
||||
Self-loops (chat->chat actions like send/scroll) keep mass in that state,
|
||||
correctly making 'chat' dominant since that is where most actions happen.
|
||||
"""
|
||||
states = list(graph.states)
|
||||
idx = {s: i for i, s in enumerate(states)}
|
||||
n = len(states)
|
||||
|
||||
# Build row-stochastic matrix P[s][dst].
|
||||
P = [[0.0] * n for _ in range(n)]
|
||||
for s in states:
|
||||
edges = graph.out_edges(s)
|
||||
total = sum(max(0.0, a.weight) for a in edges)
|
||||
if total <= 0:
|
||||
P[idx[s]][idx[s]] = 1.0 # absorbing if no edges
|
||||
continue
|
||||
for a in edges:
|
||||
P[idx[s]][idx[a.dst]] += max(0.0, a.weight) / total
|
||||
|
||||
# Power iteration from uniform.
|
||||
pi = [1.0 / n] * n
|
||||
for _ in range(iters):
|
||||
nxt = [0.0] * n
|
||||
for i in range(n):
|
||||
pij = P[i]
|
||||
pii = pi[i]
|
||||
if pii == 0.0:
|
||||
continue
|
||||
for j in range(n):
|
||||
nxt[j] += pii * pij[j]
|
||||
# normalize + check convergence
|
||||
s = sum(nxt) or 1.0
|
||||
nxt = [v / s for v in nxt]
|
||||
if max(abs(nxt[k] - pi[k]) for k in range(n)) < tol:
|
||||
pi = nxt
|
||||
break
|
||||
pi = nxt
|
||||
return {states[i]: pi[i] for i in range(n)}
|
||||
|
||||
|
||||
def run_engine(
|
||||
*,
|
||||
days: int = 7,
|
||||
log_dir: str = "~/.jcode/logs",
|
||||
source_root: str | None = None,
|
||||
ops: Operators = Operators(),
|
||||
) -> EngineResult:
|
||||
graph, targets, meta = build_user_model(days=days, log_dir=log_dir, source_root=source_root)
|
||||
|
||||
pi = _stationary_distribution(graph)
|
||||
|
||||
# Price each action. Track previous target per source state so repeat taps
|
||||
# in the same state pay a realistic (small) movement cost.
|
||||
action_costs: dict[str, float] = {}
|
||||
prev_by_state: dict[str, str | None] = {s: None for s in graph.states}
|
||||
for a in graph.actions.values():
|
||||
eff = _targets_for_action(a, targets)
|
||||
bd = action_cost(a, eff, prev_target_id=prev_by_state.get(a.src), ops=ops)
|
||||
action_costs[a.id] = bd.seconds
|
||||
if a.target_id:
|
||||
prev_by_state[a.src] = a.target_id
|
||||
|
||||
# Probability of each action = pi[src] * P(action | src).
|
||||
action_prob: dict[str, float] = {}
|
||||
for s in graph.states:
|
||||
edges = graph.out_edges(s)
|
||||
total = sum(max(0.0, e.weight) for e in edges) or 1.0
|
||||
for e in edges:
|
||||
action_prob[e.id] = pi.get(s, 0.0) * (max(0.0, e.weight) / total)
|
||||
|
||||
# Expected seconds per action (the headline metric).
|
||||
psum = sum(action_prob.values()) or 1.0
|
||||
expected = sum(action_costs[aid] * p for aid, p in action_prob.items()) / psum
|
||||
|
||||
# Task completion times (sum of action costs in the task's sequence).
|
||||
task_times: dict[str, float] = {}
|
||||
freq_weighted_num = 0.0
|
||||
freq_weighted_den = 0.0
|
||||
for t in graph.tasks:
|
||||
secs = 0.0
|
||||
prev: str | None = None
|
||||
for aid in t.action_ids:
|
||||
a = graph.actions[aid]
|
||||
eff = _targets_for_action(a, targets)
|
||||
bd = action_cost(a, eff, prev_target_id=prev, ops=ops)
|
||||
secs += bd.seconds
|
||||
if a.target_id:
|
||||
prev = a.target_id
|
||||
task_times[t.id] = round(secs, 3)
|
||||
freq_weighted_num += secs * max(0.0, t.frequency)
|
||||
freq_weighted_den += max(0.0, t.frequency)
|
||||
mean_task = (freq_weighted_num / freq_weighted_den) if freq_weighted_den else 0.0
|
||||
|
||||
return EngineResult(
|
||||
expected_action_cost_s=round(expected, 4),
|
||||
mean_task_time_s=round(mean_task, 4),
|
||||
stationary={k: round(v, 4) for k, v in pi.items()},
|
||||
action_costs_s={k: round(v, 4) for k, v in action_costs.items()},
|
||||
action_probability={k: round(v, 5) for k, v in action_prob.items()},
|
||||
task_times_s=task_times,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
# Calibration for mapping seconds -> 0..100 reward (higher = cheaper to use).
|
||||
# Anchors chosen from the model's own range: an expected per-action cost of
|
||||
# ~1.5s (mostly cheap taps/reads) is excellent (->100); ~6s (deep, slow flows)
|
||||
# is poor (->0). Linear in between, clamped.
|
||||
_GOOD_S = 1.5
|
||||
_BAD_S = 6.0
|
||||
|
||||
|
||||
def reward_score(result: EngineResult | None = None, **kwargs) -> float:
|
||||
"""Map the engine's expected per-action cost to a 0..100 reward."""
|
||||
if result is None:
|
||||
result = run_engine(**kwargs)
|
||||
s = result.expected_action_cost_s
|
||||
if s <= _GOOD_S:
|
||||
return 100.0
|
||||
if s >= _BAD_S:
|
||||
return 0.0
|
||||
return round(100.0 * (_BAD_S - s) / (_BAD_S - _GOOD_S), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
r = run_engine()
|
||||
print("usage source:", r.meta.get("usage_source"), "lines:", r.meta.get("lines_scanned"))
|
||||
print(f"expected per-action cost: {r.expected_action_cost_s:.3f} s")
|
||||
print(f"freq-weighted mean task time: {r.mean_task_time_s:.3f} s")
|
||||
print(f"reward score: {reward_score(r):.1f}/100")
|
||||
print("stationary distribution:", json.dumps(r.stationary))
|
||||
print("task times (s):", json.dumps(r.task_times_s))
|
||||
print("action costs (s):")
|
||||
for aid, c in sorted(r.action_costs_s.items(), key=lambda kv: -kv[1]):
|
||||
print(f" {aid:16} {c:6.3f} (p={r.action_probability.get(aid,0):.4f})")
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Ground the jcode-mobile user model in the user's REAL TUI usage logs.
|
||||
|
||||
This module streams the last N daily logs under ``~/.jcode/logs`` and mines
|
||||
proxies for "what the user actually does", then maps those TUI actions onto the
|
||||
set of actions the iOS app must also support. The output (:class:`UsageProfile`)
|
||||
feeds the mobile ``ActionGraph`` as relative edge weights (see ``model.py``).
|
||||
|
||||
Design / honesty notes
|
||||
----------------------
|
||||
TUI logs are a *proxy* for mobile usage, not identical. Concretely:
|
||||
|
||||
* Some TUI actions have no mobile analogue (``diff_mode``, ``side_panel``) and
|
||||
are dropped.
|
||||
* Some mobile actions leave **no trace** in TUI logs at all -- there is no
|
||||
scroll event, no "read/idle" event, and no pair-server event in the TUI log
|
||||
stream (we verified ``scroll_delta`` is always null and there is no
|
||||
``scroll_up/down`` verb). Mobile is *more* scroll/read heavy than the TUI,
|
||||
so we **impute** those weights from HCI/mobile literature priors rather than
|
||||
pretend we measured them. Those imputed weights are fixed module constants
|
||||
and are clearly listed in ``notes`` so the assumption is auditable.
|
||||
|
||||
What we CAN mine (clean, structured, low-noise markers):
|
||||
|
||||
============================ ========================================= ==================
|
||||
raw_count key log marker (one event per line) mobile action
|
||||
============================ ========================================= ==================
|
||||
req_message SERVER_REQUEST_LIFECYCLE phase=received send_message
|
||||
request_kind=message
|
||||
req_soft_interrupt ... request_kind=soft_interrupt soft_interrupt
|
||||
req_cancel ... request_kind=cancel interrupt (hard stop)
|
||||
req_cancel_soft_interrupts ... request_kind=cancel_soft_interrupts cancel (drop queued msg)
|
||||
req_set_reasoning_effort ... request_kind=set_reasoning_effort open_settings
|
||||
req_subscribe ... request_kind=subscribe (dropped: connect noise)
|
||||
req_reload ... request_kind=reload (dropped: reconnect)
|
||||
session_resume_start SESSION_LIFECYCLE phase=resume_start switch_session
|
||||
env_set_model ENV_SNAPSHOT ... "reason":"set_model" change_model
|
||||
tool_start TOOL_LIFECYCLE phase=start (dropped: agent-driven)
|
||||
assistant_turns "Assistant:" (cross-check only)
|
||||
remote_interrupt_cancel REMOTE_INTERRUPT_SEND_START kind=cancel (cross-check only)
|
||||
remote_interrupt_soft REMOTE_INTERRUPT_SEND_START kind=soft_... (cross-check only)
|
||||
============================ ========================================= ==================
|
||||
|
||||
We dedupe each request to a single line by counting only ``phase=received``
|
||||
(every request also logs ``handled`` and ``acked``).
|
||||
|
||||
Determinism / purity: pure aside from reading files; deterministic for a fixed
|
||||
log set; stdlib only; tolerant of missing/locked files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mobile action vocabulary (must stay in sync with the mobile ActionGraph).
|
||||
# --------------------------------------------------------------------------- #
|
||||
MOBILE_ACTIONS: tuple[str, ...] = (
|
||||
"send_message",
|
||||
"scroll",
|
||||
"soft_interrupt",
|
||||
"interrupt",
|
||||
"cancel",
|
||||
"switch_session",
|
||||
"change_model",
|
||||
"open_settings",
|
||||
"pair_server",
|
||||
"read_idle", # the deliverable's "read/idle"
|
||||
)
|
||||
|
||||
# Mobile actions that leave NO trace in TUI logs and must be imputed from
|
||||
# literature priors. Mobile is more scroll/read heavy than the TUI; pairing is a
|
||||
# rare one-time onboarding step. These are absolute weight reservations.
|
||||
IMPUTED_WEIGHTS: dict[str, float] = {
|
||||
"scroll": 0.25,
|
||||
"read_idle": 0.10,
|
||||
"pair_server": 0.005,
|
||||
}
|
||||
|
||||
# Observed (mineable) mobile actions, distributed over the remaining mass
|
||||
# proportional to their real mined counts.
|
||||
OBSERVED_ACTIONS: tuple[str, ...] = (
|
||||
"send_message",
|
||||
"soft_interrupt",
|
||||
"interrupt",
|
||||
"cancel",
|
||||
"switch_session",
|
||||
"change_model",
|
||||
"open_settings",
|
||||
)
|
||||
|
||||
# Literature/default profile used when there are no logs (fresh machine / CI),
|
||||
# or when logs exist but contain zero user-action markers. Pre-normalized to ~1.
|
||||
DEFAULT_WEIGHTS: dict[str, float] = {
|
||||
"send_message": 0.45,
|
||||
"scroll": 0.25,
|
||||
"read_idle": 0.10,
|
||||
"soft_interrupt": 0.06,
|
||||
"interrupt": 0.05,
|
||||
"switch_session": 0.04,
|
||||
"change_model": 0.03,
|
||||
"open_settings": 0.02,
|
||||
"cancel": 0.005,
|
||||
"pair_server": 0.005,
|
||||
}
|
||||
|
||||
# TUI-only verbs we explicitly acknowledge and drop (no mobile analogue).
|
||||
TUI_ONLY_DROPPED: tuple[str, ...] = ("diff_mode", "side_panel")
|
||||
|
||||
_DATE_RE = re.compile(r"jcode-(\d{4}-\d{2}-\d{2})\.log$")
|
||||
_REQUEST_KIND_RE = re.compile(r"request_kind=([a-z_]+)")
|
||||
_REMOTE_KIND_RE = re.compile(r"REMOTE_INTERRUPT_SEND_START kind=([a-z_]+)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageProfile:
|
||||
"""Mined (or defaulted) user-behavior profile for the mobile ActionGraph.
|
||||
|
||||
raw_counts raw TUI event/verb counts actually mined (audit trail).
|
||||
mobile_weights normalized relative weights over MOBILE_ACTIONS (sum ~= 1.0);
|
||||
used directly as edge weights in the mobile user graph.
|
||||
days_seen number of daily log files actually read (with content).
|
||||
lines_scanned total lines streamed across those files.
|
||||
source "logs" if real logs drove the weights, else "defaults".
|
||||
notes auditable assumptions about the TUI -> mobile mapping.
|
||||
"""
|
||||
|
||||
raw_counts: dict[str, int]
|
||||
mobile_weights: dict[str, float]
|
||||
days_seen: int
|
||||
lines_scanned: int
|
||||
source: str # "logs" | "defaults"
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _default_profile(extra_note: str, raw_counts: dict[str, int] | None = None,
|
||||
days_seen: int = 0, lines_scanned: int = 0,
|
||||
source: str = "defaults") -> UsageProfile:
|
||||
"""Build a literature-default profile so the engine runs on a fresh machine."""
|
||||
weights = _normalize(dict(DEFAULT_WEIGHTS))
|
||||
notes = [
|
||||
extra_note,
|
||||
"mobile_weights are literature/HCI defaults (Card/Moran/Newell + mobile "
|
||||
"usage priors), not mined from this machine.",
|
||||
f"TUI-only verbs dropped (no mobile analogue): {', '.join(TUI_ONLY_DROPPED)}.",
|
||||
"scroll, read_idle, pair_server have no TUI log proxy and are imputed.",
|
||||
]
|
||||
return UsageProfile(
|
||||
raw_counts=raw_counts if raw_counts is not None else {},
|
||||
mobile_weights=weights,
|
||||
days_seen=days_seen,
|
||||
lines_scanned=lines_scanned,
|
||||
source=source,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def _normalize(weights: dict[str, float]) -> dict[str, float]:
|
||||
"""Return weights normalized to sum 1.0 (stable order = MOBILE_ACTIONS)."""
|
||||
total = sum(weights.get(k, 0.0) for k in MOBILE_ACTIONS)
|
||||
if total <= 0:
|
||||
# Degenerate; fall back to uniform over the action set.
|
||||
n = len(MOBILE_ACTIONS)
|
||||
return {k: 1.0 / n for k in MOBILE_ACTIONS}
|
||||
return {k: weights.get(k, 0.0) / total for k in MOBILE_ACTIONS}
|
||||
|
||||
|
||||
def _select_log_files(log_dir: str, days: int) -> list[tuple[str, str]]:
|
||||
"""Return up to `days` (date, path) pairs, most recent first, by filename date."""
|
||||
pattern = os.path.join(log_dir, "jcode-*.log")
|
||||
dated: list[tuple[str, str]] = []
|
||||
for path in glob.glob(pattern):
|
||||
m = _DATE_RE.search(os.path.basename(path))
|
||||
if m:
|
||||
dated.append((m.group(1), path))
|
||||
# Sort by ISO date descending (lexicographic works for YYYY-MM-DD), keep last N.
|
||||
dated.sort(key=lambda t: t[0], reverse=True)
|
||||
return dated[: max(0, days)]
|
||||
|
||||
|
||||
def _scan_file(path: str, counts: dict[str, int]) -> int:
|
||||
"""Stream one log file line-by-line, accumulating into `counts`.
|
||||
|
||||
Returns the number of lines scanned. Cheap substring guards keep the hot
|
||||
loop fast on 100k+ line files; only candidate lines are parsed further.
|
||||
"""
|
||||
lines = 0
|
||||
# errors="replace" so a corrupt byte never aborts a multi-MB scan.
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
lines += 1
|
||||
|
||||
if "EVENT event=" in line:
|
||||
if "SERVER_REQUEST_LIFECYCLE" in line:
|
||||
# Dedupe to one event per request via phase=received.
|
||||
if "phase=received" in line:
|
||||
m = _REQUEST_KIND_RE.search(line)
|
||||
if m:
|
||||
counts[f"req_{m.group(1)}"] = counts.get(f"req_{m.group(1)}", 0) + 1
|
||||
elif "SESSION_LIFECYCLE" in line:
|
||||
if "phase=resume_start" in line:
|
||||
counts["session_resume_start"] += 1
|
||||
elif "TOOL_LIFECYCLE" in line:
|
||||
if "phase=start" in line:
|
||||
counts["tool_start"] += 1
|
||||
continue
|
||||
|
||||
if "REMOTE_INTERRUPT_SEND_START" in line:
|
||||
m = _REMOTE_KIND_RE.search(line)
|
||||
if m:
|
||||
kind = m.group(1)
|
||||
if kind == "cancel":
|
||||
counts["remote_interrupt_cancel"] += 1
|
||||
elif kind == "soft_interrupt":
|
||||
counts["remote_interrupt_soft"] += 1
|
||||
continue
|
||||
|
||||
if "ENV_SNAPSHOT" in line and '"reason":"set_model"' in line:
|
||||
counts["env_set_model"] += 1
|
||||
continue
|
||||
|
||||
# Cheap cross-check proxy for conversation turns.
|
||||
if "Assistant:" in line:
|
||||
counts["assistant_turns"] += 1
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _map_to_mobile(counts: dict[str, int]) -> dict[str, int]:
|
||||
"""Map mined raw TUI counts onto the OBSERVED mobile actions."""
|
||||
return {
|
||||
"send_message": counts.get("req_message", 0),
|
||||
"soft_interrupt": counts.get("req_soft_interrupt", 0),
|
||||
"interrupt": counts.get("req_cancel", 0), # hard stop a run
|
||||
"cancel": counts.get("req_cancel_soft_interrupts", 0), # drop a queued msg
|
||||
"switch_session": counts.get("session_resume_start", 0),
|
||||
"change_model": counts.get("env_set_model", 0),
|
||||
"open_settings": counts.get("req_set_reasoning_effort", 0),
|
||||
}
|
||||
|
||||
|
||||
def mine_usage(log_dir: str = "~/.jcode/logs", days: int = 7) -> UsageProfile:
|
||||
"""Mine the last `days` daily logs under `log_dir` into a :class:`UsageProfile`.
|
||||
|
||||
Degrades gracefully: if the directory is absent, empty, or contains no
|
||||
user-action markers, returns a literature-default profile so the engine
|
||||
still runs on a fresh machine / in CI.
|
||||
"""
|
||||
resolved = os.path.expanduser(log_dir)
|
||||
|
||||
if not os.path.isdir(resolved):
|
||||
return _default_profile(
|
||||
f"No log directory at {resolved!r}; using literature defaults."
|
||||
)
|
||||
|
||||
files = _select_log_files(resolved, days)
|
||||
if not files:
|
||||
return _default_profile(
|
||||
f"No jcode-YYYY-MM-DD.log files under {resolved!r}; using defaults."
|
||||
)
|
||||
|
||||
counts: dict[str, int] = {
|
||||
"session_resume_start": 0,
|
||||
"tool_start": 0,
|
||||
"env_set_model": 0,
|
||||
"assistant_turns": 0,
|
||||
"remote_interrupt_cancel": 0,
|
||||
"remote_interrupt_soft": 0,
|
||||
}
|
||||
lines_scanned = 0
|
||||
days_seen = 0
|
||||
skipped: list[str] = []
|
||||
|
||||
for date_str, path in files:
|
||||
try:
|
||||
n = _scan_file(path, counts)
|
||||
except OSError as exc: # missing/locked/permission -> tolerate
|
||||
skipped.append(f"{os.path.basename(path)} ({exc.__class__.__name__})")
|
||||
continue
|
||||
if n > 0:
|
||||
days_seen += 1
|
||||
lines_scanned += n
|
||||
|
||||
observed = _map_to_mobile(counts)
|
||||
observed_total = sum(observed.values())
|
||||
|
||||
notes: list[str] = []
|
||||
notes.append(
|
||||
f"Scanned {days_seen} daily log(s), {lines_scanned} lines, from "
|
||||
f"{os.path.basename(files[-1][1])}..{os.path.basename(files[0][1])}."
|
||||
)
|
||||
|
||||
if days_seen == 0:
|
||||
return _default_profile(
|
||||
f"All candidate logs under {resolved!r} were unreadable; using defaults.",
|
||||
raw_counts=dict(counts),
|
||||
days_seen=0,
|
||||
lines_scanned=lines_scanned,
|
||||
)
|
||||
|
||||
if observed_total == 0:
|
||||
prof = _default_profile(
|
||||
"Logs read but zero user-action markers matched; using default weights.",
|
||||
raw_counts=dict(counts),
|
||||
days_seen=days_seen,
|
||||
lines_scanned=lines_scanned,
|
||||
source="logs",
|
||||
)
|
||||
prof.notes = notes + prof.notes
|
||||
if skipped:
|
||||
prof.notes.append(f"Skipped unreadable files: {', '.join(skipped)}.")
|
||||
return prof
|
||||
|
||||
# ---- Build weights: imputed reserve + observed mass distributed by count -- #
|
||||
imputed_total = sum(IMPUTED_WEIGHTS.values())
|
||||
observed_mass = max(0.0, 1.0 - imputed_total)
|
||||
|
||||
weights: dict[str, float] = dict(IMPUTED_WEIGHTS)
|
||||
for action in OBSERVED_ACTIONS:
|
||||
weights[action] = observed_mass * (observed[action] / observed_total)
|
||||
# Ensure every mobile action key is present.
|
||||
for action in MOBILE_ACTIONS:
|
||||
weights.setdefault(action, 0.0)
|
||||
|
||||
mobile_weights = _normalize(weights)
|
||||
|
||||
# ---- Auditable mapping notes -------------------------------------------- #
|
||||
notes.append(
|
||||
"Observed counts -> mobile: send_message=req_message, "
|
||||
"soft_interrupt=req_soft_interrupt, interrupt=req_cancel (hard stop), "
|
||||
"cancel=req_cancel_soft_interrupts (drop queued msg), "
|
||||
"switch_session=resume_start, change_model=ENV_SNAPSHOT set_model, "
|
||||
"open_settings=req_set_reasoning_effort."
|
||||
)
|
||||
notes.append(
|
||||
"Dedupe: SERVER_REQUEST_LIFECYCLE counted only at phase=received "
|
||||
"(each request also logs handled+acked)."
|
||||
)
|
||||
notes.append(
|
||||
f"Imputed (no TUI log proxy) at fixed literature weights: "
|
||||
f"scroll={IMPUTED_WEIGHTS['scroll']}, read_idle={IMPUTED_WEIGHTS['read_idle']}, "
|
||||
f"pair_server={IMPUTED_WEIGHTS['pair_server']}; remaining "
|
||||
f"{observed_mass:.3f} split across observed actions by real counts."
|
||||
)
|
||||
notes.append(
|
||||
f"Dropped (not user-driven / TUI-only): tool_start (agent turns, "
|
||||
f"{counts.get('tool_start', 0)}), req_subscribe ("
|
||||
f"{counts.get('req_subscribe', 0)}, connection noise), req_reload ("
|
||||
f"{counts.get('req_reload', 0)}, reconnect), and verbs "
|
||||
f"{', '.join(TUI_ONLY_DROPPED)}."
|
||||
)
|
||||
notes.append(
|
||||
"CAVEAT: TUI usage is a proxy for mobile, not identical; switch_session "
|
||||
"(resume_start) may include automatic swarm reconnects; assistant_turns "
|
||||
f"({counts.get('assistant_turns', 0)}) and remote_interrupt_* are kept as "
|
||||
"cross-checks only, not folded into weights."
|
||||
)
|
||||
if skipped:
|
||||
notes.append(f"Skipped unreadable files: {', '.join(skipped)}.")
|
||||
|
||||
return UsageProfile(
|
||||
raw_counts=dict(counts),
|
||||
mobile_weights=mobile_weights,
|
||||
days_seen=days_seen,
|
||||
lines_scanned=lines_scanned,
|
||||
source="logs",
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
profile = mine_usage()
|
||||
print(json.dumps(profile.to_dict(), indent=2, sort_keys=False))
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Shared data model for the interaction-cost engine.
|
||||
|
||||
This is the ONLY module the interaction-engine workers share. It defines the
|
||||
user-behavior graph, the UI target geometry, and the HCI operator constants.
|
||||
Everything else (cost_model, user_model, ui_map, engine) imports from here so
|
||||
the pieces can be built and tested independently and in parallel.
|
||||
|
||||
Concept
|
||||
-------
|
||||
We model a jcode-mobile user as a weighted directed graph (a Markov chain over
|
||||
UI states). Nodes are UI states the user can be in; edges are actions with a
|
||||
relative `weight` = how likely a user in that state is to take that action.
|
||||
Normalizing the out-edges of a state gives transition probabilities. The
|
||||
stationary distribution of that chain tells us how often each state is visited;
|
||||
multiplying by the per-action cost (predicted in SECONDS from KLM/TLM + Fitts)
|
||||
gives an expected interaction cost we can optimize against.
|
||||
|
||||
HCI grounding (seconds)
|
||||
-----------------------
|
||||
KLM (Card, Moran & Newell 1983) + Touch-Level Model (Rice & Lartigue 2014):
|
||||
M mental act / decision .......... 1.35 s
|
||||
TAP discrete touch/button press ... 0.20 s (KLM K for avg typist ~0.20)
|
||||
H homing / reposition hand ....... 0.40 s
|
||||
K keystroke (avg non-secretary) .. 0.28 s
|
||||
R system response (set per action; e.g. sheet present, network round-trip)
|
||||
Fitts' law (touch): MT = a + b * log2(D/W + 1), a~0.0 s, b~0.20 s/bit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Operators:
|
||||
"""KLM/TLM operator times in seconds + Fitts constants. Literature-grounded
|
||||
defaults; a benchmarking harness may override after empirical calibration."""
|
||||
M: float = 1.35 # mental act / decision
|
||||
TAP: float = 0.20 # discrete tap / button press
|
||||
H: float = 0.40 # homing / hand reposition
|
||||
K: float = 0.28 # single keystroke (avg non-secretary typist)
|
||||
FITTS_A: float = 0.0 # Fitts intercept (s)
|
||||
FITTS_B: float = 0.20 # Fitts slope (s/bit)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UITarget:
|
||||
"""Geometry of a tappable control, in points (44pt is Apple's HIG minimum).
|
||||
x_pt/y_pt are the target CENTER; used for Fitts movement-time + reachability."""
|
||||
id: str
|
||||
width_pt: float
|
||||
height_pt: float
|
||||
x_pt: float
|
||||
y_pt: float
|
||||
exists: bool = True # False => the control is absent in the current build
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserState:
|
||||
"""A node: a UI context the user can be in (e.g. 'chat', 'settings_sheet')."""
|
||||
id: str
|
||||
label: str
|
||||
screen: str # which SwiftUI view renders this state
|
||||
|
||||
|
||||
@dataclass
|
||||
class Action:
|
||||
"""An edge: a single user action moving from `src` state to `dst` state.
|
||||
|
||||
weight relative likelihood a user in `src` takes this action (unnormalized;
|
||||
engine normalizes out-edges per state into probabilities).
|
||||
target_id the UITarget tapped, if any (drives Fitts movement time).
|
||||
operators KLM/TLM operator letters performed, e.g. ["M","TAP"] or ["K"]*n.
|
||||
response_s extra system/network wait in seconds (sheet present, reconnect).
|
||||
"""
|
||||
id: str
|
||||
label: str
|
||||
src: str
|
||||
dst: str
|
||||
weight: float
|
||||
target_id: str | None = None
|
||||
operators: list[str] = field(default_factory=list)
|
||||
response_s: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""A canonical user goal expressed as an ordered list of action ids. The
|
||||
engine sums per-action times to get a task-completion time in seconds, and
|
||||
weights tasks by `frequency` (relative how-often-per-session)."""
|
||||
id: str
|
||||
label: str
|
||||
action_ids: list[str]
|
||||
frequency: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionGraph:
|
||||
"""The full user-behavior model."""
|
||||
states: dict[str, UserState]
|
||||
actions: dict[str, Action]
|
||||
tasks: list[Task]
|
||||
start: str
|
||||
|
||||
def out_edges(self, state_id: str) -> list[Action]:
|
||||
return [a for a in self.actions.values() if a.src == state_id]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostBreakdown:
|
||||
"""Result of pricing one action: total seconds + per-operator detail."""
|
||||
action_id: str
|
||||
seconds: float
|
||||
detail: dict[str, float] = field(default_factory=dict)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Determinism + sanity checks for the interaction-cost engine.
|
||||
|
||||
The engine grounds a reward category, so it must be deterministic (same logs +
|
||||
source -> same numbers) and internally consistent. Run:
|
||||
|
||||
python3 -m reward.interaction.test_engine
|
||||
|
||||
Exits non-zero on any failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from reward.interaction.cost_model import action_cost, fitts_time
|
||||
from reward.interaction.engine import run_engine, reward_score
|
||||
from reward.interaction.model import Action, Operators, UITarget
|
||||
|
||||
|
||||
def main() -> None:
|
||||
failures: list[str] = []
|
||||
|
||||
def check(name: str, cond: bool) -> None:
|
||||
print(f" [{'PASS' if cond else 'FAIL'}] {name}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
# 1) Fitts monotonicity (farther + smaller costs more).
|
||||
check("fitts farther/smaller costs more",
|
||||
fitts_time(300, 44) > fitts_time(50, 88))
|
||||
check("fitts non-negative", fitts_time(0, 44) >= 0.0)
|
||||
|
||||
# 2) cost_model: a simple tap action ~ M + TAP + small fitts.
|
||||
tgt = {"b": UITarget(id="b", width_pt=44, height_pt=44, x_pt=200, y_pt=820)}
|
||||
bd = action_cost(Action(id="a", label="", src="s", dst="s", weight=1.0,
|
||||
target_id="b", operators=["M", "TAP"]), tgt)
|
||||
check("tap action priced > M+TAP", bd.seconds > Operators().M + Operators().TAP)
|
||||
|
||||
# 3) Engine determinism: two runs identical.
|
||||
r1 = run_engine()
|
||||
r2 = run_engine()
|
||||
check("expected cost deterministic",
|
||||
r1.expected_action_cost_s == r2.expected_action_cost_s)
|
||||
check("stationary deterministic", r1.stationary == r2.stationary)
|
||||
|
||||
# 4) Engine sanity.
|
||||
check("expected cost positive + finite",
|
||||
0.0 < r1.expected_action_cost_s < 100.0)
|
||||
check("no action priced as unreachable (>1000s)",
|
||||
all(c < 1000.0 for c in r1.action_costs_s.values()))
|
||||
check("stationary sums to 1", abs(sum(r1.stationary.values()) - 1.0) < 5e-3)
|
||||
check("chat is the dominant state", r1.stationary.get("chat", 0) > 0.5)
|
||||
check("reward in [0,100]", 0.0 <= reward_score(r1) <= 100.0)
|
||||
|
||||
# 5) Off-machine fallback must still work (no logs).
|
||||
rf = run_engine(log_dir="/tmp/definitely-no-logs-here")
|
||||
check("fallback runs without logs", rf.meta.get("usage_source") == "defaults")
|
||||
check("fallback reward in [0,100]", 0.0 <= reward_score(rf) <= 100.0)
|
||||
|
||||
print(f"\nengine checks: {'OK' if not failures else str(len(failures)) + ' FAILURES'}")
|
||||
print(f" expected per-action cost: {r1.expected_action_cost_s:.3f}s "
|
||||
f"-> reward {reward_score(r1):.1f}")
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,565 @@
|
||||
"""Map the SwiftUI source -> tappable-control GEOMETRY per screen (`ui_map` worker).
|
||||
|
||||
This module extracts the geometry (size + on-screen center, in points) of every
|
||||
interactive control on each jcode-mobile screen so the interaction-cost model can
|
||||
apply Fitts' law to *real* targets. It is the `ui_map` piece described in the
|
||||
package docstring and depends only on the shared contract in ``model.py`` plus the
|
||||
standard library (NumPy/PIL are imported lazily, only by the optional
|
||||
screenshot-based variant).
|
||||
|
||||
How geometry is derived
|
||||
-----------------------
|
||||
1. SIZE. We parse the Swift views for explicit ``.frame(width: W, height: H)``
|
||||
declarations attached to ``Image``/``Button`` controls (e.g. the 44x44 send,
|
||||
stop, and settings buttons in ``ChatView.swift``). These are read straight
|
||||
from source, so when someone re-sizes the send button the map follows. For
|
||||
controls without an explicit frame (the composer text field, list rows, the
|
||||
Pair / Scan-QR buttons) we INFER the size from the control's parsed
|
||||
``.padding(...)`` plus the line height of its SwiftUI font:
|
||||
control_height ~= font_line_height + 2 * vertical_padding
|
||||
Font line heights use a documented Apple-text-style table (see ``_FONT_LINE``)
|
||||
and ``Theme.mono(n)`` ~= round(n * 1.30).
|
||||
|
||||
2. POSITION. We place controls using known SwiftUI layout rules:
|
||||
- The chat HEADER is pinned to the top, just under the device safe-area inset;
|
||||
its trailing settings button sits ``H_MARGIN`` from the right edge.
|
||||
- The chat COMPOSER is pinned to the bottom, just above the home-indicator
|
||||
safe-area inset; the send button is the trailing control, the (conditional)
|
||||
stop button sits one ``ICON_GAP`` to its left, and the text field fills the
|
||||
remaining width.
|
||||
- The SETTINGS sheet is a grouped ``List`` inside a ``NavigationStack``; rows
|
||||
are stacked top-down (section header + N rows + inter-section gap) below the
|
||||
inline nav bar (which carries the trailing "Done" button).
|
||||
- The PAIRING screen is a ``ScrollView`` ``VStack(spacing: 20)``; controls are
|
||||
stacked top-down from the content's at-rest (scroll offset 0) origin.
|
||||
|
||||
Every non-obvious number is a named, documented constant, and the assumptions are
|
||||
also returned programmatically by :func:`assumptions` / :data:`LAYOUT_ASSUMPTIONS`
|
||||
so a reader (or the cost model) can audit exactly what was measured vs assumed.
|
||||
|
||||
Determinism
|
||||
-----------
|
||||
``build_ui_map`` is pure aside from reading the Swift source files; given the same
|
||||
sources + screen size it always returns identical geometry. The optional
|
||||
``build_ui_map_from_screenshot`` is likewise deterministic (fixed color/threshold
|
||||
math over the pixels).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .model import UITarget
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device + iOS layout constants (documented; override screen size via args).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# iPhone 17 logical portrait canvas (the harness's reference device).
|
||||
DEFAULT_SCREEN_W_PT: float = 402.0
|
||||
DEFAULT_SCREEN_H_PT: float = 874.0
|
||||
|
||||
# Safe-area insets for a Dynamic-Island-class device in portrait (iPhone 14
|
||||
# Pro/15/16/17 share ~59pt top / ~34pt bottom). The header is pinned below the
|
||||
# top inset; the composer is pinned above the bottom (home-indicator) inset.
|
||||
TOP_SAFE_INSET_PT: float = 59.0
|
||||
BOTTOM_SAFE_INSET_PT: float = 34.0
|
||||
|
||||
# Apple HIG minimum hit target; used as a fallback when source has no frame.
|
||||
HIG_MIN_PT: float = 44.0
|
||||
|
||||
# Standard iOS metrics (documented defaults).
|
||||
NAV_BAR_H_PT: float = 44.0 # inline navigation bar height
|
||||
LIST_ROW_H_PT: float = 44.0 # single-line grouped list row
|
||||
LIST_ROW_2LINE_H_PT: float = 60.0 # two-line row (server name + host:port)
|
||||
SECTION_HEADER_H_PT: float = 28.0 # grouped section header ("Model", ...)
|
||||
SECTION_GAP_PT: float = 20.0 # vertical gap between grouped sections
|
||||
LIST_TOP_PAD_PT: float = 16.0 # inset before the first section header
|
||||
LIST_H_MARGIN_PT: float = 16.0 # grouped list horizontal margin
|
||||
# A default large-detent sheet leaves a small gap at the very top (the parent
|
||||
# peeks behind the rounded card); the sheet's nav bar begins about here.
|
||||
SHEET_TOP_PT: float = 24.0
|
||||
|
||||
# Representative counts for source-driven *dynamic* lists (models/sessions/
|
||||
# servers come from runtime state, not source). Chosen small + fixed so the map
|
||||
# stays deterministic; the assumption is recorded in LAYOUT_ASSUMPTIONS.
|
||||
N_MODEL_ROWS: int = 3
|
||||
N_SESSION_ROWS: int = 3
|
||||
N_SERVER_ROWS: int = 2
|
||||
|
||||
# Approximate "Done" nav-button text width (pt) for its trailing hit box.
|
||||
NAV_DONE_W_PT: float = 54.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font line heights (pt). Apple text-style ~line-heights + a mono multiplier.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FONT_LINE: dict[str, float] = {
|
||||
"caption2": 13.0,
|
||||
"caption": 16.0,
|
||||
"footnote": 18.0,
|
||||
"subheadline": 20.0,
|
||||
"callout": 21.0,
|
||||
"body": 22.0,
|
||||
"headline": 22.0,
|
||||
"title3": 25.0,
|
||||
"title": 41.0,
|
||||
}
|
||||
_MONO_LINE_MULT: float = 1.30 # Theme.mono(n) line height ~= round(n * 1.30)
|
||||
|
||||
|
||||
def _mono_line(size: float) -> float:
|
||||
return round(size * _MONO_LINE_MULT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source parsing helpers (deterministic regex over the Swift view files).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FILES = ("ChatView.swift", "PairingView.swift", "SettingsView.swift", "RootView.swift")
|
||||
|
||||
# Image(systemName: "x") ... .frame(width: W, height: H) (W/H may be ints/floats)
|
||||
_IMG_RE = re.compile(r'Image\(systemName:\s*"([^"]+)"\)')
|
||||
_FRAME_RE = re.compile(r"\.frame\(\s*width:\s*([0-9.]+)\s*,\s*height:\s*([0-9.]+)\s*\)")
|
||||
_PAD_AXIS_RE = r"\.padding\(\.{axis},\s*([0-9.]+)\)"
|
||||
_PAD_ALL_RE = re.compile(r"\.padding\(\s*([0-9.]+)\s*\)")
|
||||
|
||||
|
||||
def _read_sources(source_root: str) -> dict[str, str]:
|
||||
"""Read the Swift view files; missing files map to '' (defaults kick in)."""
|
||||
root = Path(source_root) / "Views"
|
||||
out: dict[str, str] = {}
|
||||
for name in _FILES:
|
||||
p = root / name
|
||||
try:
|
||||
out[name] = p.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
out[name] = ""
|
||||
return out
|
||||
|
||||
|
||||
def _image_frames(text: str) -> dict[str, tuple[float, float]]:
|
||||
"""Map each ``Image(systemName:)`` to the first ``.frame(width:height:)`` that
|
||||
follows it (before the next image), i.e. the icon's explicit size from source."""
|
||||
frames: dict[str, tuple[float, float]] = {}
|
||||
images = list(_IMG_RE.finditer(text))
|
||||
for i, m in enumerate(images):
|
||||
name = m.group(1)
|
||||
end = images[i + 1].start() if i + 1 < len(images) else len(text)
|
||||
fm = _FRAME_RE.search(text, m.end(), end)
|
||||
if fm:
|
||||
frames[name] = (float(fm.group(1)), float(fm.group(2)))
|
||||
return frames
|
||||
|
||||
|
||||
def _pad_after(text: str, anchor: str, axis: str, default: float) -> float:
|
||||
"""First ``.padding(.{axis}, N)`` after ``anchor``; else ``default``."""
|
||||
idx = text.find(anchor)
|
||||
if idx == -1:
|
||||
return default
|
||||
m = re.search(_PAD_AXIS_RE.format(axis=axis), text[idx:])
|
||||
return float(m.group(1)) if m else default
|
||||
|
||||
|
||||
def _pad_all_after(text: str, anchor: str, default: float) -> float:
|
||||
"""First ``.padding(N)`` (all-edges) after ``anchor``; else ``default``."""
|
||||
idx = text.find(anchor)
|
||||
if idx == -1:
|
||||
return default
|
||||
m = _PAD_ALL_RE.search(text[idx:])
|
||||
return float(m.group(1)) if m else default
|
||||
|
||||
|
||||
# Records, per target id, whether the SIZE came from a parsed source frame or a
|
||||
# documented default. Populated during build for auditability.
|
||||
LAYOUT_ASSUMPTIONS: dict[str, str] = {}
|
||||
|
||||
|
||||
def _note(target_id: str, msg: str) -> None:
|
||||
LAYOUT_ASSUMPTIONS[target_id] = msg
|
||||
|
||||
|
||||
def _size_from_source(
|
||||
frames: dict[str, tuple[float, float]], system_name: str, target_id: str
|
||||
) -> tuple[float, float]:
|
||||
"""Return the parsed (w, h) for an icon, or the HIG-min fallback + a note."""
|
||||
if system_name in frames:
|
||||
return frames[system_name]
|
||||
_note(target_id, f"size: no .frame for Image({system_name!r}); assumed {HIG_MIN_PT}x{HIG_MIN_PT} (HIG min)")
|
||||
return (HIG_MIN_PT, HIG_MIN_PT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-screen builders.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _chat_targets(
|
||||
src: dict[str, str], screen_w: float, screen_h: float
|
||||
) -> list[UITarget]:
|
||||
"""Chat screen: header (top, pinned) + composer (bottom, pinned)."""
|
||||
chat = src.get("ChatView.swift", "")
|
||||
frames = _image_frames(chat)
|
||||
|
||||
h_margin = _pad_after(chat, "private var header", "horizontal", 16.0)
|
||||
header_vpad = _pad_after(chat, "private var header", "vertical", 8.0)
|
||||
comp_vpad = _pad_after(chat, "HStack(alignment: .bottom", "vertical", 8.0)
|
||||
comp_hmargin = _pad_after(chat, "HStack(alignment: .bottom", "horizontal", 16.0)
|
||||
# Text-field inner vertical padding (drives its single-line height).
|
||||
field_vpad = _pad_after(chat, "text: $draft", "vertical", 8.0)
|
||||
icon_gap = 8.0 # HStack(spacing: 8) in both header and composer
|
||||
|
||||
targets: list[UITarget] = []
|
||||
|
||||
# --- Settings (ellipsis) button: trailing item of the top-pinned header ---
|
||||
sw, sh = _size_from_source(frames, "ellipsis.circle", "settings")
|
||||
header_band_top = TOP_SAFE_INSET_PT
|
||||
settings_x = screen_w - h_margin - sw / 2.0
|
||||
settings_y = header_band_top + header_vpad + sh / 2.0
|
||||
targets.append(UITarget("settings", sw, sh, settings_x, settings_y, exists=True))
|
||||
|
||||
# --- Composer band, pinned to the bottom above the home indicator ---------
|
||||
band_bottom = screen_h - BOTTOM_SAFE_INSET_PT
|
||||
btn_bottom = band_bottom - comp_vpad # 44pt buttons sit on the band's bottom
|
||||
|
||||
# Send button (trailing, always present; just disabled when nothing to send).
|
||||
snd_w, snd_h = _size_from_source(frames, "arrow.up", "send")
|
||||
send_x = screen_w - comp_hmargin - snd_w / 2.0
|
||||
send_y = btn_bottom - snd_h / 2.0
|
||||
targets.append(UITarget("send", snd_w, snd_h, send_x, send_y, exists=True))
|
||||
|
||||
# Stop / interrupt button: rendered ONLY while processing -> exists=False but
|
||||
# still mapped so the cost model can price the processing state too.
|
||||
st_w, st_h = _size_from_source(frames, "stop.fill", "stop")
|
||||
send_left = send_x - snd_w / 2.0
|
||||
stop_x = (send_left - icon_gap) - st_w / 2.0
|
||||
stop_y = btn_bottom - st_h / 2.0
|
||||
targets.append(UITarget("stop", st_w, st_h, stop_x, stop_y, exists=False))
|
||||
_note("stop", "exists=False: only shown while model.session.isProcessing")
|
||||
|
||||
# Composer text field: fills the width left of the trailing buttons. In the
|
||||
# default (idle) chat state the stop button is absent, so the field extends
|
||||
# to just left of the send button.
|
||||
field_line = _FONT_LINE["body"] # TextField uses .font(.body)
|
||||
field_h = field_line + 2.0 * field_vpad
|
||||
field_left = comp_hmargin
|
||||
field_right = send_left - icon_gap
|
||||
field_w = field_right - field_left
|
||||
field_x = (field_left + field_right) / 2.0
|
||||
field_y = btn_bottom - field_h / 2.0 # HStack(alignment: .bottom)
|
||||
targets.append(UITarget("composer_field", field_w, field_h, field_x, field_y, exists=True))
|
||||
_note("composer_field", f"height = body line({field_line}) + 2*vpad({field_vpad}); single-line at rest")
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _settings_targets(
|
||||
src: dict[str, str], screen_w: float, screen_h: float
|
||||
) -> list[UITarget]:
|
||||
"""Settings sheet: NavigationStack + grouped List (Model/Sessions/Servers)."""
|
||||
targets: list[UITarget] = []
|
||||
row_x = screen_w / 2.0
|
||||
row_w = screen_w - 2.0 * LIST_H_MARGIN_PT
|
||||
|
||||
# "Done": trailing confirmationAction in the inline nav bar.
|
||||
done_x = screen_w - LIST_H_MARGIN_PT - NAV_DONE_W_PT / 2.0
|
||||
done_y = SHEET_TOP_PT + NAV_BAR_H_PT / 2.0
|
||||
targets.append(UITarget("settings_done", NAV_DONE_W_PT, NAV_BAR_H_PT, done_x, done_y, exists=True))
|
||||
_note("settings_done", f"sheet top assumed {SHEET_TOP_PT}pt (large-detent gap); 'Done' text width ~{NAV_DONE_W_PT}pt")
|
||||
|
||||
# Walk the list top-down.
|
||||
y = SHEET_TOP_PT + NAV_BAR_H_PT + LIST_TOP_PAD_PT
|
||||
|
||||
def add_rows(prefix: str, n: int, row_h: float) -> None:
|
||||
nonlocal y
|
||||
for i in range(n):
|
||||
cy = y + row_h / 2.0
|
||||
targets.append(UITarget(f"{prefix}_{i}", row_w, row_h, row_x, cy, exists=True))
|
||||
y += row_h
|
||||
|
||||
def add_button(tid: str) -> None:
|
||||
nonlocal y
|
||||
cy = y + LIST_ROW_H_PT / 2.0
|
||||
targets.append(UITarget(tid, row_w, LIST_ROW_H_PT, row_x, cy, exists=True))
|
||||
y += LIST_ROW_H_PT
|
||||
|
||||
# Section: Model
|
||||
y += SECTION_HEADER_H_PT
|
||||
add_rows("model_row", N_MODEL_ROWS, LIST_ROW_H_PT)
|
||||
_note("model_row_*", f"{N_MODEL_ROWS} representative rows (count is runtime/dynamic); {LIST_ROW_H_PT}pt each")
|
||||
|
||||
# Section: Sessions (rows + "Rename" + "New session")
|
||||
y += SECTION_GAP_PT + SECTION_HEADER_H_PT
|
||||
add_rows("session_row", N_SESSION_ROWS, LIST_ROW_H_PT)
|
||||
_note("session_row_*", f"{N_SESSION_ROWS} representative rows (count is runtime/dynamic); {LIST_ROW_H_PT}pt each")
|
||||
add_button("rename_session")
|
||||
add_button("new_session")
|
||||
|
||||
# Section: Servers (two-line rows + "Pair new server")
|
||||
y += SECTION_GAP_PT + SECTION_HEADER_H_PT
|
||||
add_rows("server_row", N_SERVER_ROWS, LIST_ROW_2LINE_H_PT)
|
||||
_note("server_row_*", f"{N_SERVER_ROWS} representative two-line rows ({LIST_ROW_2LINE_H_PT}pt); count is runtime/dynamic")
|
||||
add_button("pair_new_server")
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _pairing_targets(
|
||||
src: dict[str, str], screen_w: float, screen_h: float
|
||||
) -> list[UITarget]:
|
||||
"""Pairing screen: ScrollView VStack(spacing: 20), stacked from the top."""
|
||||
pair = src.get("PairingView.swift", "")
|
||||
vstack_pad = 16.0 # outer .padding(16)
|
||||
vstack_spacing = 20.0 # VStack(alignment:.leading, spacing: 20)
|
||||
header_top_pad = _pad_after(pair, "private var header", "top", 32.0)
|
||||
# Card lives in Theme.swift (.padding(14)); documented default if absent.
|
||||
card_pad = 14.0
|
||||
field_pad = _pad_all_after(pair, "private func field", 12.0)
|
||||
pair_vpad = _pad_after(pair, "Text(isPairing", "vertical", 16.0)
|
||||
scan_vpad = _pad_after(pair, "qrcode.viewfinder", "vertical", 12.0)
|
||||
|
||||
targets: list[UITarget] = []
|
||||
content_w = screen_w - 2.0 * vstack_pad
|
||||
full_x = screen_w / 2.0
|
||||
|
||||
# Header (title + subtitle), not interactive -> only used to advance y.
|
||||
y = TOP_SAFE_INSET_PT + vstack_pad + header_top_pad
|
||||
title_line = _mono_line(34.0)
|
||||
subtitle_line = _FONT_LINE["subheadline"]
|
||||
header_h = title_line + 8.0 + subtitle_line # internal spacing 8
|
||||
y += header_h + vstack_spacing
|
||||
|
||||
# Card with three text fields.
|
||||
field_line = _mono_line(16.0) # TextField uses Theme.mono(16)
|
||||
field_h = field_line + 2.0 * field_pad
|
||||
label_line = _FONT_LINE["caption"] # field label uses .font(.caption)
|
||||
field_block_h = label_line + 8.0 + field_h # VStack(spacing: 8)
|
||||
card_content_w = content_w - 2.0 * card_pad
|
||||
field_x = vstack_pad + card_pad + card_content_w / 2.0
|
||||
|
||||
card_content_top = y + card_pad
|
||||
for i, name in enumerate(("host", "port", "code")):
|
||||
block_top = card_content_top + i * (field_block_h + 16.0) # inner spacing 16
|
||||
tf_top = block_top + label_line + 8.0
|
||||
cy = tf_top + field_h / 2.0
|
||||
targets.append(UITarget(f"pairing_field_{name}", card_content_w, field_h, field_x, cy, exists=True))
|
||||
_note("pairing_field_*", f"height = mono(16) line({field_line}) + 2*pad({field_pad})")
|
||||
|
||||
card_h = 2.0 * card_pad + 3.0 * field_block_h + 2.0 * 16.0
|
||||
y += card_h + vstack_spacing
|
||||
|
||||
# Pair button (full width, prominent).
|
||||
pair_h = _FONT_LINE["headline"] + 2.0 * pair_vpad
|
||||
targets.append(UITarget("pair_button", content_w, pair_h, full_x, y + pair_h / 2.0, exists=True))
|
||||
_note("pair_button", f"height = headline line({_FONT_LINE['headline']}) + 2*vpad({pair_vpad})")
|
||||
y += pair_h + vstack_spacing
|
||||
|
||||
# Scan QR button (full width, secondary).
|
||||
scan_h = _FONT_LINE["subheadline"] + 2.0 * scan_vpad
|
||||
targets.append(UITarget("scan_qr", content_w, scan_h, full_x, y + scan_h / 2.0, exists=True))
|
||||
_note("scan_qr", f"height = subheadline line({_FONT_LINE['subheadline']}) + 2*vpad({scan_vpad})")
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_source_root() -> str:
|
||||
"""<repo>/ios/Sources/JCodeMobile, resolved relative to this file."""
|
||||
return str(Path(__file__).resolve().parents[3] / "Sources" / "JCodeMobile")
|
||||
|
||||
|
||||
def build_ui_map(
|
||||
source_root: str = "",
|
||||
screen_w_pt: float = DEFAULT_SCREEN_W_PT,
|
||||
screen_h_pt: float = DEFAULT_SCREEN_H_PT,
|
||||
) -> dict[str, list[UITarget]]:
|
||||
"""Build the per-screen map of tappable-control geometry from the Swift source.
|
||||
|
||||
Returns a dict keyed by screen/state id -- ``"chat"``, ``"settings_sheet"``,
|
||||
``"pairing"`` -- each a list of :class:`UITarget` (center + size in points).
|
||||
Conditionally-shown controls (e.g. the composer stop button) are included with
|
||||
``exists=False`` so the cost model can price both states. Size assumptions are
|
||||
recorded in :data:`LAYOUT_ASSUMPTIONS` (also via :func:`assumptions`).
|
||||
"""
|
||||
LAYOUT_ASSUMPTIONS.clear()
|
||||
root = source_root or _default_source_root()
|
||||
src = _read_sources(root)
|
||||
return {
|
||||
"chat": _chat_targets(src, screen_w_pt, screen_h_pt),
|
||||
"settings_sheet": _settings_targets(src, screen_w_pt, screen_h_pt),
|
||||
"pairing": _pairing_targets(src, screen_w_pt, screen_h_pt),
|
||||
}
|
||||
|
||||
|
||||
def assumptions() -> dict[str, str]:
|
||||
"""Return the assumption/provenance notes from the most recent build."""
|
||||
return dict(LAYOUT_ASSUMPTIONS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OPTIONAL: screenshot-based geometry (deterministic; needs NumPy + PIL).
|
||||
# Clearly separated from the source-based map above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Theme.mint = 0x4DD9A6 -> the dominant accent for the send button (and checks).
|
||||
_MINT_RGB: tuple[int, int, int] = (0x4D, 0xD9, 0xA6)
|
||||
|
||||
|
||||
def _largest_color_component(mask) -> tuple[int, int, int, int] | None:
|
||||
"""Bounding box (x0, y0, x1, y1) of the largest 4-connected True region.
|
||||
|
||||
Pure-NumPy flood fill; deterministic. Returns None if the mask is empty.
|
||||
"""
|
||||
import numpy as np # local import: optional dependency
|
||||
|
||||
visited = np.zeros_like(mask, dtype=bool)
|
||||
h, w = mask.shape
|
||||
best: tuple[int, tuple[int, int, int, int]] | None = None
|
||||
ys, xs = np.nonzero(mask)
|
||||
for sy, sx in zip(ys.tolist(), xs.tolist()):
|
||||
if visited[sy, sx]:
|
||||
continue
|
||||
# Iterative flood fill from this seed.
|
||||
stack = [(sy, sx)]
|
||||
visited[sy, sx] = True
|
||||
x0 = x1 = sx
|
||||
y0 = y1 = sy
|
||||
size = 0
|
||||
while stack:
|
||||
cy, cx = stack.pop()
|
||||
size += 1
|
||||
x0, x1 = min(x0, cx), max(x1, cx)
|
||||
y0, y1 = min(y0, cy), max(y1, cy)
|
||||
for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)):
|
||||
if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not visited[ny, nx]:
|
||||
visited[ny, nx] = True
|
||||
stack.append((ny, nx))
|
||||
if best is None or size > best[0]:
|
||||
best = (size, (x0, y0, x1, y1))
|
||||
return best[1] if best is not None else None
|
||||
|
||||
|
||||
def build_ui_map_from_screenshot(
|
||||
png_path: str,
|
||||
scale: float = 3.0,
|
||||
color_tol: int = 40,
|
||||
) -> dict[str, list[UITarget]]:
|
||||
"""Measure control geometry from a real screenshot (deterministic).
|
||||
|
||||
Detects the dominant ``Theme.mint`` blob (the send button's filled circle) by
|
||||
color thresholding and returns its measured center + size, converting pixels
|
||||
to points via ``/ scale`` (simulator screenshots are typically @3x). This is a
|
||||
pixel-measured complement to :func:`build_ui_map`; it returns ``{"chat":
|
||||
[UITarget("send_measured", ...)]}`` (empty if no mint blob is found).
|
||||
|
||||
Requires NumPy + PIL. Use it on screenshots under ``/tmp/jcode-ui-matrix/``.
|
||||
"""
|
||||
import numpy as np # local imports: optional deps
|
||||
from PIL import Image
|
||||
|
||||
arr = np.asarray(Image.open(png_path).convert("RGB"), dtype=np.int16)
|
||||
diff = np.abs(arr - np.array(_MINT_RGB, dtype=np.int16))
|
||||
mask = (diff.max(axis=2) <= color_tol)
|
||||
|
||||
out: dict[str, list[UITarget]] = {"chat": []}
|
||||
box = _largest_color_component(mask)
|
||||
if box is not None:
|
||||
x0, y0, x1, y1 = box
|
||||
w_px = (x1 - x0 + 1)
|
||||
h_px = (y1 - y0 + 1)
|
||||
out["chat"].append(
|
||||
UITarget(
|
||||
id="send_measured",
|
||||
width_pt=w_px / scale,
|
||||
height_pt=h_px / scale,
|
||||
x_pt=(x0 + x1 + 1) / 2.0 / scale,
|
||||
y_pt=(y0 + y1 + 1) / 2.0 / scale,
|
||||
exists=True,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI / self-test.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _as_jsonable(m: dict[str, list[UITarget]]) -> dict:
|
||||
return {
|
||||
screen: [
|
||||
{
|
||||
"id": t.id,
|
||||
"width_pt": round(t.width_pt, 2),
|
||||
"height_pt": round(t.height_pt, 2),
|
||||
"x_pt": round(t.x_pt, 2),
|
||||
"y_pt": round(t.y_pt, 2),
|
||||
"exists": t.exists,
|
||||
}
|
||||
for t in targets
|
||||
]
|
||||
for screen, targets in m.items()
|
||||
}
|
||||
|
||||
|
||||
def _self_test() -> None:
|
||||
"""Lightweight, deterministic invariants (source-based + synthetic screenshot)."""
|
||||
m = build_ui_map()
|
||||
chat = {t.id: t for t in m["chat"]}
|
||||
|
||||
# The recently-set 44x44 send button must read 44x44, near the bottom-right.
|
||||
send = chat["send"]
|
||||
assert (send.width_pt, send.height_pt) == (44.0, 44.0), send
|
||||
assert send.x_pt > DEFAULT_SCREEN_W_PT * 0.75, "send should be on the right"
|
||||
assert send.y_pt > DEFAULT_SCREEN_H_PT * 0.80, "send should be near the bottom"
|
||||
|
||||
# The 44x44 settings ellipsis must read 44x44, near the top-right.
|
||||
settings = chat["settings"]
|
||||
assert (settings.width_pt, settings.height_pt) == (44.0, 44.0), settings
|
||||
assert settings.x_pt > DEFAULT_SCREEN_W_PT * 0.75, "settings should be on the right"
|
||||
assert settings.y_pt < DEFAULT_SCREEN_H_PT * 0.20, "settings should be near the top"
|
||||
|
||||
# Stop button is conditional -> mapped but exists=False.
|
||||
assert chat["stop"].exists is False
|
||||
|
||||
# Screen-size parametrization shifts the bottom-pinned send button down.
|
||||
taller = build_ui_map(screen_h_pt=1000.0)
|
||||
send_tall = {t.id: t for t in taller["chat"]}["send"]
|
||||
assert send_tall.y_pt > send.y_pt, "taller screen pushes bottom controls down"
|
||||
|
||||
# Synthetic screenshot round-trip for the optional measured variant.
|
||||
try:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import tempfile
|
||||
|
||||
scale = 3.0
|
||||
W, H = int(DEFAULT_SCREEN_W_PT * scale), int(DEFAULT_SCREEN_H_PT * scale)
|
||||
img = np.full((H, W, 3), (0x0F, 0x0F, 0x14), dtype=np.uint8) # Theme.background
|
||||
cx, cy, r = int(send.x_pt * scale), int(send.y_pt * scale), int(22 * scale)
|
||||
yy, xx = np.ogrid[:H, :W]
|
||||
circle = (xx - cx) ** 2 + (yy - cy) ** 2 <= r ** 2
|
||||
img[circle] = _MINT_RGB
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tf:
|
||||
Image.fromarray(img).save(tf.name)
|
||||
measured = build_ui_map_from_screenshot(tf.name, scale=scale)["chat"]
|
||||
assert measured, "expected a measured send blob"
|
||||
msend = measured[0]
|
||||
assert abs(msend.x_pt - send.x_pt) < 4 and abs(msend.y_pt - send.y_pt) < 4, msend
|
||||
assert abs(msend.width_pt - 44.0) < 4, msend
|
||||
print("screenshot self-test: OK (synthetic mint blob recovered)")
|
||||
except ImportError:
|
||||
print("screenshot self-test: skipped (NumPy/PIL not installed)")
|
||||
|
||||
print("source self-test: OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ui_map = build_ui_map()
|
||||
print(json.dumps(_as_jsonable(ui_map), indent=2))
|
||||
print("\n# layout assumptions / provenance:")
|
||||
print(json.dumps(assumptions(), indent=2))
|
||||
_self_test()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""user_model.py - assemble the weighted user-behavior ActionGraph.
|
||||
|
||||
Combines the two grounded inputs into one model of how a real jcode-mobile user
|
||||
moves through the UI:
|
||||
|
||||
- log_mining.mine_usage() -> relative likelihoods of each high-level action
|
||||
(send_message, switch_session, scroll, soft_interrupt, ...), grounded in the
|
||||
user's actual TUI logs (falling back to literature defaults off-machine).
|
||||
- ui_map.build_ui_map() -> the tappable-target geometry per screen, so each
|
||||
action references the real control it taps (drives Fitts movement time).
|
||||
|
||||
The result is an `ActionGraph` (see model.py): nodes are UI states the user can
|
||||
be in, edges are concrete `Action`s with KLM/TLM operator sequences, a tapped
|
||||
`target_id`, a `response_s` system wait, and a `weight` taken from the mined
|
||||
usage profile. The engine then prices and walks this graph.
|
||||
|
||||
Why a graph and not a flat list: real usage is stateful. Switching sessions or
|
||||
changing the model requires first being in the Settings sheet; the cost of those
|
||||
flows depends on getting there and back. A Markov chain over states captures that
|
||||
the composer (chat state) is where the user spends most time and returns to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from reward.interaction.log_mining import mine_usage
|
||||
from reward.interaction.ui_map import build_ui_map
|
||||
from reward.interaction.model import (
|
||||
Action,
|
||||
ActionGraph,
|
||||
Task,
|
||||
UITarget,
|
||||
UserState,
|
||||
)
|
||||
|
||||
# States: the UI contexts a mobile user occupies.
|
||||
_STATES = [
|
||||
UserState(id="chat", label="Chat / composer", screen="chat"),
|
||||
UserState(id="settings_sheet", label="Settings sheet", screen="settings_sheet"),
|
||||
UserState(id="pairing", label="Pairing", screen="pairing"),
|
||||
]
|
||||
|
||||
|
||||
def _flatten_targets(ui_map: dict[str, list[UITarget]]) -> dict[str, UITarget]:
|
||||
"""All targets keyed by id (ids are unique across screens in ui_map)."""
|
||||
out: dict[str, UITarget] = {}
|
||||
for targets in ui_map.values():
|
||||
for t in targets:
|
||||
out[t.id] = t
|
||||
return out
|
||||
|
||||
|
||||
def build_user_model(
|
||||
*,
|
||||
days: int = 7,
|
||||
log_dir: str = "~/.jcode/logs",
|
||||
source_root: str | None = None,
|
||||
) -> tuple[ActionGraph, dict[str, UITarget], dict]:
|
||||
"""Return (graph, flat_targets, meta).
|
||||
|
||||
meta carries the usage source + raw mined weights so callers can audit how
|
||||
the edge weights were grounded.
|
||||
"""
|
||||
profile = mine_usage(log_dir=log_dir, days=days)
|
||||
w = profile.mobile_weights
|
||||
ui_map = build_ui_map(**({"source_root": source_root} if source_root else {}))
|
||||
targets = _flatten_targets(ui_map)
|
||||
|
||||
def wt(key: str, default: float = 0.0) -> float:
|
||||
return float(w.get(key, default))
|
||||
|
||||
# Edges. Each action: KLM/TLM operators + the target it acquires + any system
|
||||
# wait, weighted by mined usage. Operators follow KLM heuristics: an M (mental
|
||||
# decision) precedes a non-anticipated action; routine repeat taps drop the M.
|
||||
actions: list[Action] = [
|
||||
# --- from the chat screen (where the user lives) ---------------------
|
||||
Action(
|
||||
id="send_message", label="Type + send a message",
|
||||
src="chat", dst="chat", weight=wt("send_message", 0.45),
|
||||
target_id="send",
|
||||
# mental compose (M) + ~12 keystrokes typing + tap send. Typing cost
|
||||
# is intrinsic to messaging; engine prices K via operators.
|
||||
operators=["M"] + ["K"] * 12 + ["TAP"],
|
||||
response_s=0.0,
|
||||
),
|
||||
Action(
|
||||
id="scroll", label="Scroll the transcript",
|
||||
src="chat", dst="chat", weight=wt("scroll", 0.25),
|
||||
target_id=None,
|
||||
operators=["TAP"], # a swipe ~ one touch-drag; priced like a tap-move
|
||||
response_s=0.0,
|
||||
),
|
||||
Action(
|
||||
id="read_idle", label="Read / dwell (no input)",
|
||||
src="chat", dst="chat", weight=wt("read_idle", 0.10),
|
||||
target_id=None,
|
||||
operators=["M"], # a mental act, no motor cost
|
||||
response_s=0.0,
|
||||
),
|
||||
Action(
|
||||
id="soft_interrupt", label="Queue a message mid-run",
|
||||
src="chat", dst="chat", weight=wt("soft_interrupt", 0.06),
|
||||
target_id="send",
|
||||
operators=["M"] + ["K"] * 8 + ["TAP"],
|
||||
response_s=0.0,
|
||||
),
|
||||
Action(
|
||||
id="interrupt", label="Stop the running turn",
|
||||
src="chat", dst="chat", weight=wt("interrupt", 0.05),
|
||||
target_id="stop",
|
||||
operators=["M", "TAP"],
|
||||
response_s=0.0,
|
||||
),
|
||||
Action(
|
||||
id="open_settings", label="Open the settings sheet",
|
||||
src="chat", dst="settings_sheet", weight=wt("open_settings", 0.02),
|
||||
target_id="settings",
|
||||
operators=["M", "TAP"],
|
||||
response_s=0.35, # sheet present animation
|
||||
),
|
||||
# --- from the settings sheet ----------------------------------------
|
||||
Action(
|
||||
id="switch_session", label="Switch to another session",
|
||||
src="settings_sheet", dst="chat", weight=wt("switch_session", 0.05),
|
||||
target_id="session_row_1",
|
||||
operators=["M", "TAP"],
|
||||
response_s=0.30, # dismiss + reconnect/resubscribe
|
||||
),
|
||||
Action(
|
||||
id="change_model", label="Change the model",
|
||||
src="settings_sheet", dst="settings_sheet", weight=wt("change_model", 0.03),
|
||||
target_id="model_row_1",
|
||||
operators=["M", "TAP"],
|
||||
response_s=0.0,
|
||||
),
|
||||
Action(
|
||||
id="close_settings", label="Dismiss settings back to chat",
|
||||
src="settings_sheet", dst="chat", weight=max(wt("open_settings", 0.02), 0.02),
|
||||
target_id="settings_done",
|
||||
operators=["TAP"],
|
||||
response_s=0.30,
|
||||
),
|
||||
Action(
|
||||
id="pair_server", label="Pair a new server",
|
||||
src="settings_sheet", dst="pairing", weight=wt("pair_server", 0.01),
|
||||
target_id="pair_new_server",
|
||||
operators=["M", "TAP"],
|
||||
response_s=0.35, # nested sheet present
|
||||
),
|
||||
# --- from pairing ----------------------------------------------------
|
||||
Action(
|
||||
id="confirm_pair", label="Enter code + pair",
|
||||
src="pairing", dst="chat", weight=max(wt("pair_server", 0.01), 0.01),
|
||||
target_id="pair_button",
|
||||
operators=["M"] + ["K"] * 6 + ["TAP"],
|
||||
response_s=1.0, # network pair round-trip
|
||||
),
|
||||
]
|
||||
action_map = {a.id: a for a in actions}
|
||||
|
||||
# Canonical tasks (goal-level), with frequency from the mined profile so the
|
||||
# task-time summary reflects how often each goal actually happens.
|
||||
tasks = [
|
||||
Task(id="t_send", label="Send a message",
|
||||
action_ids=["send_message"], frequency=wt("send_message", 0.45)),
|
||||
Task(id="t_switch", label="Switch session",
|
||||
action_ids=["open_settings", "switch_session"],
|
||||
frequency=wt("switch_session", 0.05)),
|
||||
Task(id="t_model", label="Change model",
|
||||
action_ids=["open_settings", "change_model", "close_settings"],
|
||||
frequency=wt("change_model", 0.03)),
|
||||
Task(id="t_interrupt", label="Interrupt a run",
|
||||
action_ids=["interrupt"], frequency=wt("interrupt", 0.05)),
|
||||
Task(id="t_pair", label="Pair a new server",
|
||||
action_ids=["open_settings", "pair_server", "confirm_pair"],
|
||||
frequency=wt("pair_server", 0.01)),
|
||||
]
|
||||
|
||||
graph = ActionGraph(
|
||||
states={s.id: s for s in _STATES},
|
||||
actions=action_map,
|
||||
tasks=tasks,
|
||||
start="chat",
|
||||
)
|
||||
meta = {
|
||||
"usage_source": profile.source,
|
||||
"days_seen": profile.days_seen,
|
||||
"lines_scanned": profile.lines_scanned,
|
||||
"mobile_weights": profile.mobile_weights,
|
||||
"notes": list(profile.notes),
|
||||
}
|
||||
return graph, targets, meta
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
g, targets, meta = build_user_model()
|
||||
print("usage source:", meta["usage_source"], "lines:", meta["lines_scanned"])
|
||||
print("states:", list(g.states))
|
||||
print("actions:")
|
||||
for a in g.actions.values():
|
||||
print(f" {a.id:16} {a.src}->{a.dst:14} w={a.weight:.3f} target={a.target_id}")
|
||||
print("tasks:")
|
||||
for t in g.tasks:
|
||||
print(f" {t.id:12} freq={t.frequency:.3f} steps={t.action_ids}")
|
||||
print("mined weights:", json.dumps({k: round(v, 3) for k, v in meta["mobile_weights"].items()}))
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Scorer: D. accessibility.
|
||||
|
||||
STATIC scorer over the SwiftUI source (ctx.source_files). It rewards the
|
||||
accessibility affordances a VoiceOver / Dynamic Type user needs and penalizes
|
||||
icon-only buttons that ship no text equivalent:
|
||||
|
||||
- explicit `.accessibilityLabel` / `.accessibilityHint`
|
||||
- Dynamic Type usage: semantic `.font(.body|.headline|...)` (which scales)
|
||||
instead of only fixed `Theme.mono(<size>)` points
|
||||
- `.dynamicTypeSize` clamps / ranges
|
||||
- reduce-motion awareness via `@Environment(\\.accessibilityReduceMotion)`
|
||||
- `Label(text, systemImage:)` which gives an icon a spoken text label
|
||||
- icon-only `Button { ... Image(systemName:) ... }` with NO label is a defect
|
||||
|
||||
If there is no source AND no AX tree, the cell is genuinely unmeasurable and we
|
||||
return make_unavailable. The output is a blended 0..100 with per-signal counts
|
||||
in evidence so regressions are diffable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "accessibility"
|
||||
CATEGORY = "D"
|
||||
WEIGHT = 0.08
|
||||
|
||||
# Semantic text styles scale with Dynamic Type; fixed Theme.mono(pt) does not.
|
||||
_DYNAMIC_FONT = re.compile(
|
||||
r"\.font\(\s*\.(?:largeTitle|title3|title2|title|headline|subheadline"
|
||||
r"|body|callout|footnote|caption2|caption)\b"
|
||||
)
|
||||
_MONO_FONT = re.compile(r"Theme\.mono\s*\(")
|
||||
_A11Y_LABEL = re.compile(r"\.accessibilityLabel\s*\(")
|
||||
_A11Y_HINT = re.compile(r"\.accessibilityHint\s*\(")
|
||||
_A11Y_VALUE = re.compile(r"\.accessibility(?:Value|AddTraits|Element|Identifier)\s*\(")
|
||||
_DYNAMIC_TYPE_SIZE = re.compile(r"\.dynamicTypeSize\s*\(")
|
||||
_REDUCE_MOTION = re.compile(r"accessibilityReduceMotion")
|
||||
_LABEL_VIEW = re.compile(r"\bLabel\s*\(")
|
||||
_SYS_IMAGE = re.compile(r"Image\s*\(\s*systemName\s*:")
|
||||
|
||||
# A Button whose closure body is essentially just an Image(systemName:) with no
|
||||
# nearby text or accessibility label. We scan each `Button` occurrence's local
|
||||
# window of source to decide.
|
||||
_BUTTON = re.compile(r"\bButton\b")
|
||||
_TEXT_VIEW = re.compile(r"\bText\s*\(|\bLabel\s*\(")
|
||||
|
||||
|
||||
def _count(rx: re.Pattern, text: str) -> int:
|
||||
return len(rx.findall(text))
|
||||
|
||||
|
||||
def _icononly_buttons_without_label(text: str) -> int:
|
||||
"""Count buttons whose visible content is an SF Symbol with no text/label.
|
||||
|
||||
Heuristic: for each `Button`, inspect a forward window up to the next
|
||||
`Button` (bounded). If it contains an Image(systemName:) but no Text(),
|
||||
Label(), or .accessibilityLabel, it is an unlabeled icon-only button.
|
||||
"""
|
||||
flagged = 0
|
||||
idxs = [m.start() for m in _BUTTON.finditer(text)]
|
||||
for i, start in enumerate(idxs):
|
||||
end = idxs[i + 1] if i + 1 < len(idxs) else len(text)
|
||||
window = text[start:min(end, start + 600)]
|
||||
if not _SYS_IMAGE.search(window):
|
||||
continue
|
||||
has_text = bool(_TEXT_VIEW.search(window))
|
||||
has_label = bool(_A11Y_LABEL.search(window))
|
||||
if not has_text and not has_label:
|
||||
flagged += 1
|
||||
return flagged
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
files = ctx.source_files
|
||||
if not files and ctx.ax_tree is None:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no source or AX tree")
|
||||
if not files:
|
||||
# AX tree present but no source: we only grade source signals here.
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no source to grade")
|
||||
|
||||
blob = "\n".join(files.values())
|
||||
|
||||
a11y_label_count = _count(_A11Y_LABEL, blob)
|
||||
a11y_hint_count = _count(_A11Y_HINT, blob)
|
||||
a11y_semantic_count = _count(_A11Y_VALUE, blob)
|
||||
dynamic_font_count = _count(_DYNAMIC_FONT, blob)
|
||||
mono_font_count = _count(_MONO_FONT, blob)
|
||||
dynamic_type_size_count = _count(_DYNAMIC_TYPE_SIZE, blob)
|
||||
reduce_motion_signals = _count(_REDUCE_MOTION, blob)
|
||||
label_view_count = _count(_LABEL_VIEW, blob)
|
||||
icononly = _icononly_buttons_without_label(blob)
|
||||
|
||||
# Dynamic Type score: share of font usages that are scalable semantic styles
|
||||
# rather than fixed monospaced points. Label() also reads as text+icon.
|
||||
total_fonts = dynamic_font_count + mono_font_count
|
||||
dynamic_type_signals = dynamic_font_count + dynamic_type_size_count
|
||||
if total_fonts > 0:
|
||||
dynamic_ratio = dynamic_font_count / total_fonts
|
||||
else:
|
||||
dynamic_ratio = 0.0
|
||||
dynamic_score = 100.0 * dynamic_ratio
|
||||
if dynamic_type_size_count > 0:
|
||||
dynamic_score = min(100.0, dynamic_score + 10.0)
|
||||
|
||||
# Label coverage: explicit a11y labels + Label() views that name their icon,
|
||||
# relative to the symbol images that could otherwise be unspoken. Saturates.
|
||||
sys_image_count = max(1, _count(_SYS_IMAGE, blob))
|
||||
labelled = a11y_label_count + label_view_count
|
||||
label_coverage = min(1.0, labelled / sys_image_count)
|
||||
label_score = 100.0 * label_coverage
|
||||
|
||||
# Hint/semantic presence: small bonus band, present-or-not dominated.
|
||||
semantic_score = min(100.0, 100.0 * (a11y_hint_count + a11y_semantic_count) / 4.0)
|
||||
|
||||
# Reduce-motion: binary-ish, any usage is most of the credit.
|
||||
reduce_score = min(100.0, 60.0 + 40.0 * reduce_motion_signals) if reduce_motion_signals else 0.0
|
||||
|
||||
# Blend. Dynamic Type and labelling matter most for everyday legibility;
|
||||
# reduce-motion and hints are secondary affordances.
|
||||
value = (0.34 * dynamic_score
|
||||
+ 0.34 * label_score
|
||||
+ 0.16 * semantic_score
|
||||
+ 0.16 * reduce_score)
|
||||
|
||||
# Penalize icon-only buttons with no text equivalent: each is a VoiceOver
|
||||
# dead-end. Scale by how many such buttons relative to a small budget.
|
||||
penalty = min(40.0, icononly * 12.0)
|
||||
value = max(0.0, min(100.0, value - penalty))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"a11y_label_count": a11y_label_count,
|
||||
"a11y_hint_count": a11y_hint_count,
|
||||
"dynamic_type_signals": dynamic_type_signals,
|
||||
"dynamic_font_count": dynamic_font_count,
|
||||
"mono_font_count": mono_font_count,
|
||||
"reduce_motion_signals": reduce_motion_signals,
|
||||
"label_view_count": label_view_count,
|
||||
"icononly_buttons_without_label": icononly,
|
||||
"dynamic_ratio": round(dynamic_ratio, 3),
|
||||
"label_coverage": round(label_coverage, 3),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Scorer: F. ai_patterns - anti "AI-slop" (higher score = LESS slop).
|
||||
|
||||
This is the design-authenticity scorer that penalizes the documented, repeatable
|
||||
tells of AI-generated UI catalogued in `reward/AI_SLOP_RESEARCH.md`. It starts at
|
||||
100 (a perfectly un-slopped UI) and subtracts a bounded penalty per tell, so a
|
||||
crafted app keeps its score and a generated-looking one bleeds points.
|
||||
|
||||
Tells detected (each cites AI_SLOP_RESEARCH.md "The AI-slop tells"):
|
||||
|
||||
SOURCE (primary; scans every .swift under ctx.source_root incl. Theme.swift):
|
||||
* slop palette hexes - purple/indigo (#667eea #764ba2 #8b5cf6 #A855F7 #6366F1
|
||||
#7C3AED #818CF8) and neon cyan-on-dark (#38BDF8 #22D3EE). Detected from
|
||||
Color(hex: 0x..) AND Color(red:green:blue:) by classifying hue+saturation,
|
||||
so a renamed copy is still caught. (research: "Color / theme")
|
||||
* gradients - LinearGradient/RadialGradient/AngularGradient/.gradient, and
|
||||
ESPECIALLY a gradient applied to text (foregroundStyle(...Gradient) or
|
||||
.overlay(Gradient).mask(Text)), the "gradient text for impact" tell.
|
||||
* glassmorphism/blur overuse - .ultraThinMaterial/.regularMaterial/
|
||||
.thinMaterial/.blur(...) sprinkled on many surfaces. ("Material / depth")
|
||||
* generic fonts - "Inter"/"Roboto"/"Arial"/"Open Sans"/"Lato"/"Space Grotesk"
|
||||
inside .font(.custom(...)). ("Typography")
|
||||
* emoji-as-UI - emoji codepoints inside SwiftUI Text("...") literals.
|
||||
("Content: Emoji used as UI iconography")
|
||||
* uniform 0.1-opacity shadows applied broadly + oversized uniform corner
|
||||
radius (cornerRadius >= 24 on everything). ("Material / depth")
|
||||
* decorative badge proliferation - many "Live"/"New"/"Beta" pills. A SINGLE
|
||||
small functional status pill (our app's one "live" connection pill) is
|
||||
acceptable and is NOT penalized; only proliferation is.
|
||||
|
||||
PIXEL (corroboration; ctx.content_pixels): fraction of content pixels whose hue
|
||||
falls in the purple/indigo or neon-cyan range, plus the presence of large
|
||||
smooth multi-stop color ramps (gradient regions). Small penalty.
|
||||
|
||||
FAIRNESS: our app uses mint #4DD9A6 (hue ~158, a green-teal, NOT neon cyan and
|
||||
NOT purple), solid tokenized dark surfaces, mono fonts, SF Symbols (not emoji),
|
||||
and exactly one functional status pill -> every counter is 0 -> score 100.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "ai_patterns"
|
||||
CATEGORY = "F"
|
||||
WEIGHT = 0.04
|
||||
|
||||
# --- slop palette ----------------------------------------------------------
|
||||
# Canonical AI-slop hexes from AI_SLOP_RESEARCH.md (Tailwind indigo-500 family
|
||||
# and the neon cyan-on-dark accents). Matched literally as a backstop, in
|
||||
# addition to the hue classifier below.
|
||||
_SLOP_HEXES = {
|
||||
"667eea", "764ba2", "8b5cf6", "a855f7", "6366f1", "7c3aed", "818cf8", # purple/indigo
|
||||
"38bdf8", "22d3ee", # neon cyan
|
||||
}
|
||||
# Hue bands (degrees) for the hue-based classifier. Purple/indigo spans the
|
||||
# canonical hexes (~229-271deg); neon cyan spans ~185-205deg. Mint #4DD9A6 sits
|
||||
# at ~158deg (green-teal) and is deliberately OUTSIDE both bands.
|
||||
_PURPLE_BAND = (225.0, 295.0)
|
||||
_CYAN_BAND = (180.0, 205.0)
|
||||
# A slop accent is vivid: a near-neutral dark surface (e.g. #0F0F14, which has a
|
||||
# nominal blue hue) must NOT count, so require real saturation + brightness.
|
||||
_SRC_MIN_SAT = 0.40
|
||||
_SRC_MIN_VAL = 0.30
|
||||
|
||||
# Generic "AI escape-hatch" font families (AI_SLOP_RESEARCH.md "Typography").
|
||||
_GENERIC_FONTS = {"inter", "roboto", "arial", "open sans", "lato", "space grotesk"}
|
||||
|
||||
# Decorative badge words. "live" is intentionally included because our app uses
|
||||
# a single functional "live" pill; one is allowed (see _BADGE_FREE) and only
|
||||
# proliferation is penalized.
|
||||
_BADGE_WORDS = {"live", "new", "beta", "pro", "hot", "sale", "free", "premium", "trending"}
|
||||
|
||||
# --- regexes (deterministic, compiled once) --------------------------------
|
||||
_HEX_RE = re.compile(r"(?:0x|#)([0-9A-Fa-f]{6})\b")
|
||||
_RGB_RE = re.compile(
|
||||
r"Color\(\s*(?:\.sRGB[^,]*,\s*)?red:\s*([0-9.]+)\s*,\s*green:\s*([0-9.]+)\s*,\s*blue:\s*([0-9.]+)"
|
||||
)
|
||||
_GRADIENT_RE = re.compile(r"\b(?:Linear|Radial|Angular|Mesh|Elliptical)Gradient\b|\.gradient\b")
|
||||
_MATERIAL_RE = re.compile(
|
||||
r"\.(?:ultraThin|thin|regular|thick|ultraThick)Material\b|\.blur\s*\(",
|
||||
)
|
||||
_FONT_CUSTOM_RE = re.compile(r"\.custom\(\s*\"([^\"]+)\"")
|
||||
_TEXT_LITERAL_RE = re.compile(r"\bText\(\s*\"((?:[^\"\\]|\\.)*)\"")
|
||||
_SHADOW_RE = re.compile(r"\.shadow\s*\(")
|
||||
_CORNER_RE = re.compile(r"cornerRadius:\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
_GRADIENT_TOKEN_RE = re.compile(r"Gradient")
|
||||
# Emoji codepoints (pictographs, symbols, dingbats, flags, variation selectors).
|
||||
_EMOJI_RE = re.compile(
|
||||
"[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF"
|
||||
"\U00002B00-\U00002BFF\U0000FE00-\U0000FE0F\U00002300-\U000023FF]"
|
||||
)
|
||||
|
||||
# --- penalty schedule (per-tell weight, free allowance, hard cap) ----------
|
||||
# Purple/cyan palette and gradient-on-text are the loudest slop signals, so they
|
||||
# carry the heaviest per-hit weights and caps.
|
||||
P_HEX = (12.0, 0, 40.0) # purple/indigo or neon-cyan accent
|
||||
P_GRAD = (8.0, 0, 24.0) # any gradient
|
||||
P_GRAD_TEXT = (20.0, 0, 40.0) # gradient applied to text (heaviest)
|
||||
P_MATERIAL = (6.0, 2, 24.0) # blur/material: a couple is fine, many = slop
|
||||
P_FONT = (10.0, 0, 30.0) # generic .custom() font
|
||||
P_EMOJI = (8.0, 0, 24.0) # emoji-as-UI
|
||||
P_SHADOW = (5.0, 1, 15.0) # uniform ~0.1-opacity shadows applied broadly
|
||||
P_RADIUS = (4.0, 2, 16.0) # oversized uniform corner radius (>= 24)
|
||||
P_BADGE = (6.0, 1, 18.0) # decorative badge proliferation (1 pill is ok)
|
||||
P_PIXEL_CAP = 12.0 # corroborating pixel evidence cap
|
||||
|
||||
|
||||
def _penalty(count: int, schedule: tuple[float, int, float]) -> float:
|
||||
"""count over the free allowance, times per-hit weight, clamped to the cap."""
|
||||
per, free, cap = schedule
|
||||
return min(cap, max(0, count - free) * per)
|
||||
|
||||
|
||||
def _rgb_to_hsv(r: float, g: float, b: float) -> tuple[float, float, float]:
|
||||
"""RGB in 0..255 -> (hue degrees, saturation 0..1, value 0..1)."""
|
||||
rf, gf, bf = r / 255.0, g / 255.0, b / 255.0
|
||||
mx, mn = max(rf, gf, bf), min(rf, gf, bf)
|
||||
delta = mx - mn
|
||||
if delta < 1e-9:
|
||||
hue = 0.0
|
||||
elif mx == rf:
|
||||
hue = 60.0 * (((gf - bf) / delta) % 6.0)
|
||||
elif mx == gf:
|
||||
hue = 60.0 * (((bf - rf) / delta) + 2.0)
|
||||
else:
|
||||
hue = 60.0 * (((rf - gf) / delta) + 4.0)
|
||||
sat = 0.0 if mx <= 1e-9 else delta / mx
|
||||
return hue % 360.0, sat, mx
|
||||
|
||||
|
||||
def _is_slop_color(r: float, g: float, b: float) -> bool:
|
||||
"""A vivid purple/indigo or neon-cyan accent (mint #4DD9A6 fails this)."""
|
||||
hue, sat, val = _rgb_to_hsv(r, g, b)
|
||||
if sat < _SRC_MIN_SAT or val < _SRC_MIN_VAL:
|
||||
return False # near-neutral dark surface, not an accent
|
||||
if _PURPLE_BAND[0] <= hue <= _PURPLE_BAND[1]:
|
||||
return True
|
||||
if _CYAN_BAND[0] <= hue <= _CYAN_BAND[1]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- source scanning -------------------------------------------------------
|
||||
def _scan_source(files: dict[str, str]) -> dict[str, int]:
|
||||
slop_hex = grad = grad_text = material = font = emoji = shadow = radius = badge = 0
|
||||
|
||||
for text in files.values():
|
||||
# slop palette: explicit hex literals.
|
||||
for m in _HEX_RE.finditer(text):
|
||||
h = m.group(1).lower()
|
||||
if h in _SLOP_HEXES:
|
||||
slop_hex += 1
|
||||
continue
|
||||
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||
if _is_slop_color(r, g, b):
|
||||
slop_hex += 1
|
||||
# slop palette: Color(red:green:blue:) component literals.
|
||||
for m in _RGB_RE.finditer(text):
|
||||
vals = [float(x) for x in m.groups()]
|
||||
if all(v <= 1.0 for v in vals): # 0..1 fractions
|
||||
vals = [v * 255.0 for v in vals]
|
||||
if _is_slop_color(*vals):
|
||||
slop_hex += 1
|
||||
|
||||
# gradients (any) and the gradient-on-text special case.
|
||||
grad += len(_GRADIENT_RE.findall(text))
|
||||
for m in _GRADIENT_TOKEN_RE.finditer(text):
|
||||
window = text[max(0, m.start() - 220): m.end() + 220]
|
||||
if ("foregroundStyle" in window or "foregroundColor" in window
|
||||
or ".mask(" in window or ".mask {" in window):
|
||||
grad_text += 1
|
||||
|
||||
# glassmorphism / blur overuse.
|
||||
material += len(_MATERIAL_RE.findall(text))
|
||||
|
||||
# generic fonts inside .font(.custom("...")).
|
||||
for m in _FONT_CUSTOM_RE.finditer(text):
|
||||
if m.group(1).strip().lower() in _GENERIC_FONTS:
|
||||
font += 1
|
||||
|
||||
# emoji inside Text("...") literals only (not comments / symbols).
|
||||
for m in _TEXT_LITERAL_RE.finditer(text):
|
||||
if _EMOJI_RE.search(m.group(1)):
|
||||
emoji += 1
|
||||
|
||||
# uniform ~0.1-opacity shadows applied broadly.
|
||||
for m in _SHADOW_RE.finditer(text):
|
||||
window = text[m.start(): m.end() + 120]
|
||||
if re.search(r"0\.0?[5-9]\d*|0\.1\d*|0\.2\b", window):
|
||||
shadow += 1
|
||||
|
||||
# oversized uniform corner radius (>= 24).
|
||||
for m in _CORNER_RE.finditer(text):
|
||||
if float(m.group(1)) >= 24.0:
|
||||
radius += 1
|
||||
|
||||
# decorative badge words used as Text("...") pills.
|
||||
for m in _TEXT_LITERAL_RE.finditer(text):
|
||||
if m.group(1).strip().lower() in _BADGE_WORDS:
|
||||
badge += 1
|
||||
|
||||
return {
|
||||
"slop_hex_hits": slop_hex,
|
||||
"gradient_hits": grad,
|
||||
"gradient_on_text_hits": grad_text,
|
||||
"material_blur_hits": material,
|
||||
"generic_font_hits": font,
|
||||
"emoji_hits": emoji,
|
||||
"uniform_shadow_hits": shadow,
|
||||
"oversized_radius_hits": radius,
|
||||
"badge_count": badge,
|
||||
}
|
||||
|
||||
|
||||
# --- pixel corroboration ---------------------------------------------------
|
||||
def _purple_pixel_frac(content: np.ndarray, mask: np.ndarray) -> float:
|
||||
"""Fraction of vivid content pixels in the purple/indigo or neon-cyan band."""
|
||||
px = content[mask]
|
||||
if px.shape[0] == 0:
|
||||
return 0.0
|
||||
r, g, b = px[:, 0] / 255.0, px[:, 1] / 255.0, px[:, 2] / 255.0
|
||||
mx = np.maximum.reduce([r, g, b])
|
||||
mn = np.minimum.reduce([r, g, b])
|
||||
delta = mx - mn
|
||||
nz = delta > 1e-6
|
||||
hue = np.zeros_like(mx)
|
||||
rmax = (mx == r) & nz
|
||||
gmax = (mx == g) & nz & ~rmax
|
||||
bmax = (mx == b) & nz & ~rmax & ~gmax
|
||||
hue[rmax] = ((g[rmax] - b[rmax]) / delta[rmax]) % 6.0
|
||||
hue[gmax] = ((b[gmax] - r[gmax]) / delta[gmax]) + 2.0
|
||||
hue[bmax] = ((r[bmax] - g[bmax]) / delta[bmax]) + 4.0
|
||||
hue *= 60.0
|
||||
safe_mx = np.where(mx > 1e-9, mx, 1.0)
|
||||
sat = np.where(mx > 1e-9, delta / safe_mx, 0.0)
|
||||
vivid = (sat >= 0.40) & (mx >= 0.30)
|
||||
in_purple = (hue >= _PURPLE_BAND[0]) & (hue <= _PURPLE_BAND[1])
|
||||
in_cyan = (hue >= _CYAN_BAND[0]) & (hue <= _CYAN_BAND[1])
|
||||
return float((vivid & (in_purple | in_cyan)).mean())
|
||||
|
||||
|
||||
def _gradient_region_frac(content: np.ndarray) -> float:
|
||||
"""Fraction of a coarse block grid covered by smooth multi-stop color ramps.
|
||||
|
||||
A gradient region is a run of adjacent blocks whose mean color changes by a
|
||||
small, steady step in a consistent direction across many blocks (a smooth
|
||||
ramp), accumulating a large total color distance. Solid tokenized surfaces
|
||||
(zero step) and hard edges (large jumps) do not qualify, so our flat app
|
||||
reads ~0.
|
||||
"""
|
||||
h, w, _ = content.shape
|
||||
bs = max(16, min(h, w) // 24) # block size
|
||||
gh, gw = h // bs, w // bs
|
||||
if gh < 6 or gw < 6:
|
||||
return 0.0
|
||||
grid = content[: gh * bs, : gw * bs].reshape(gh, bs, gw, bs, 3).mean(axis=(1, 3))
|
||||
|
||||
covered = np.zeros((gh, gw), dtype=bool)
|
||||
|
||||
def mark_runs(line: np.ndarray, paint) -> None:
|
||||
"""Flag maximal runs of >=6 blocks of small, steady step that together
|
||||
span a large total color distance (a smooth multi-stop ramp).
|
||||
|
||||
Decorative AI-slop gradients are *colorful* (purple->blue); a grayscale
|
||||
brightness ramp (e.g. anti-aliased mono text over a dark panel) is not a
|
||||
slop gradient, so require real chroma somewhere along the run."""
|
||||
n = len(line)
|
||||
chroma = line.max(axis=1) - line.min(axis=1) # per-block max-min
|
||||
run_start = 0
|
||||
for i in range(1, n + 1):
|
||||
smooth = (i < n) and (2.0 < float(np.linalg.norm(line[i] - line[i - 1])) < 30.0)
|
||||
if not smooth: # run ends at i-1
|
||||
a, b = run_start, i - 1
|
||||
if (b - a >= 5
|
||||
and float(np.linalg.norm(line[b] - line[a])) >= 40.0
|
||||
and float(chroma[a:b + 1].max()) >= 30.0):
|
||||
paint(a, b)
|
||||
run_start = i
|
||||
|
||||
for y in range(gh):
|
||||
mark_runs(grid[y], lambda a, b, y=y: covered.__setitem__((y, slice(a, b + 1)), True))
|
||||
for x in range(gw):
|
||||
mark_runs(grid[:, x], lambda a, b, x=x: covered.__setitem__((slice(a, b + 1), x), True))
|
||||
|
||||
return float(covered.mean())
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
files = ctx.source_files
|
||||
content = ctx.content_pixels
|
||||
have_source = bool(files)
|
||||
have_pixels = content is not None
|
||||
|
||||
# make_unavailable only if BOTH source and screenshot are unavailable.
|
||||
if not have_source and not have_pixels:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no source and no screenshot")
|
||||
|
||||
ev: dict = {}
|
||||
|
||||
# --- source tells (primary) ---
|
||||
if have_source:
|
||||
src = _scan_source(files)
|
||||
else:
|
||||
src = {
|
||||
"slop_hex_hits": 0, "gradient_hits": 0, "gradient_on_text_hits": 0,
|
||||
"material_blur_hits": 0, "generic_font_hits": 0, "emoji_hits": 0,
|
||||
"uniform_shadow_hits": 0, "oversized_radius_hits": 0, "badge_count": 0,
|
||||
}
|
||||
ev.update(src)
|
||||
|
||||
source_penalty = (
|
||||
_penalty(src["slop_hex_hits"], P_HEX)
|
||||
+ _penalty(src["gradient_hits"], P_GRAD)
|
||||
+ _penalty(src["gradient_on_text_hits"], P_GRAD_TEXT)
|
||||
+ _penalty(src["material_blur_hits"], P_MATERIAL)
|
||||
+ _penalty(src["generic_font_hits"], P_FONT)
|
||||
+ _penalty(src["emoji_hits"], P_EMOJI)
|
||||
+ _penalty(src["uniform_shadow_hits"], P_SHADOW)
|
||||
+ _penalty(src["oversized_radius_hits"], P_RADIUS)
|
||||
+ _penalty(src["badge_count"], P_BADGE)
|
||||
)
|
||||
|
||||
# --- pixel corroboration (secondary, small) ---
|
||||
purple_frac = 0.0
|
||||
grad_frac = 0.0
|
||||
if have_pixels:
|
||||
mask = ctx.content_mask
|
||||
if mask is not None:
|
||||
purple_frac = _purple_pixel_frac(content, mask)
|
||||
grad_frac = _gradient_region_frac(content)
|
||||
ev["purple_pixel_frac"] = round(purple_frac, 5)
|
||||
ev["gradient_region_frac"] = round(grad_frac, 5)
|
||||
|
||||
# >2% vivid purple/cyan content saturates the 8pt purple penalty; a sizeable
|
||||
# smooth ramp saturates the 4pt gradient-region penalty.
|
||||
pixel_penalty = min(P_PIXEL_CAP, min(8.0, purple_frac / 0.02 * 8.0)
|
||||
+ min(4.0, grad_frac / 0.08 * 4.0))
|
||||
|
||||
value = max(0.0, min(100.0, 100.0 - source_penalty - pixel_penalty))
|
||||
|
||||
ev["source_penalty"] = round(source_penalty, 2)
|
||||
ev["pixel_penalty"] = round(pixel_penalty, 2)
|
||||
ev["source_available"] = have_source
|
||||
ev["pixel_available"] = have_pixels
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT,
|
||||
value=round(value, 2), evidence=ev,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Scorer: C. consistency.
|
||||
|
||||
Grades design discipline from two angles and blends them into one 0..100:
|
||||
|
||||
PIXEL palette discipline Quantize the rendered content pixels and count how
|
||||
many dominant colours actually appear (~4-9 is a
|
||||
healthy, intentional palette; far fewer reads as
|
||||
empty, far more reads as noisy). Also count distinct
|
||||
left-margin groups from the leftmost content edge of
|
||||
each row (1-3 is disciplined alignment).
|
||||
|
||||
SOURCE token discipline Scan ctx.source_files for raw style literals that
|
||||
should have gone through Theme.swift: Color(red:/
|
||||
white:/.sRGB/displayP3) and Color(hex:) used in
|
||||
views, plus .system(size:) fonts outside Theme.swift.
|
||||
Fewer raw hits is better.
|
||||
|
||||
Pixel evidence grades the output you can see; source evidence grades the habits
|
||||
that keep it consistent across future screens. We blend both so neither can be
|
||||
gamed alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context, TOKENS, hex_to_rgb
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "consistency"
|
||||
CATEGORY = "C"
|
||||
WEIGHT = 0.04
|
||||
|
||||
# Raw style literals that bypass the design tokens (mirrors ui_lint intent).
|
||||
_RAW_COLOR_RE = re.compile(r"Color\(\s*(red|white|\.sRGB|hue|displayP3)", re.IGNORECASE)
|
||||
_RAW_HEX_RE = re.compile(r"Color\(hex:")
|
||||
_SYSTEM_FONT_RE = re.compile(r"\.system\(\s*size:")
|
||||
|
||||
|
||||
def _dominant_colors(content: np.ndarray, mask: np.ndarray) -> int:
|
||||
"""Count quantized colour buckets covering >0.4% of content pixels."""
|
||||
px = content[mask]
|
||||
if len(px) == 0:
|
||||
return 0
|
||||
q = (px // 8).astype(np.int64) # 5 bits/channel
|
||||
keys = q[:, 0] * 1024 + q[:, 1] * 32 + q[:, 2]
|
||||
_, counts = np.unique(keys, return_counts=True)
|
||||
return int((counts > 0.004 * len(px)).sum())
|
||||
|
||||
|
||||
def _margin_groups(mask: np.ndarray, scale: int) -> int:
|
||||
"""Distinct clusters of per-row leftmost content edges (>2% of rows each)."""
|
||||
edges = []
|
||||
for row in mask:
|
||||
idx = int(np.argmax(row))
|
||||
if row[idx]:
|
||||
edges.append(idx)
|
||||
if not edges:
|
||||
return 0
|
||||
tol = max(1, int(8 * scale))
|
||||
centers = []
|
||||
for v in sorted(edges):
|
||||
if not centers or v - centers[-1] > tol:
|
||||
centers.append(v)
|
||||
bucket: dict[int, int] = {}
|
||||
for v in edges:
|
||||
for center in centers:
|
||||
if abs(v - center) <= tol:
|
||||
bucket[center] = bucket.get(center, 0) + 1
|
||||
break
|
||||
return sum(1 for n in bucket.values() if n > 0.02 * len(edges))
|
||||
|
||||
|
||||
def _source_hits(source_files: dict[str, str]) -> tuple[int, int]:
|
||||
"""Count raw colour + raw font literals outside Theme.swift."""
|
||||
color_hits = font_hits = 0
|
||||
for path, text in source_files.items():
|
||||
is_theme = path.endswith("Theme.swift")
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("//"):
|
||||
continue
|
||||
if not is_theme:
|
||||
if _RAW_COLOR_RE.search(line) or _RAW_HEX_RE.search(line):
|
||||
color_hits += 1
|
||||
if _SYSTEM_FONT_RE.search(line) and "Theme" not in line:
|
||||
font_hits += 1
|
||||
return color_hits, font_hits
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
mask = ctx.content_mask
|
||||
have_pixels = mask is not None
|
||||
source_files = ctx.source_files
|
||||
have_source = bool(source_files)
|
||||
|
||||
if not have_pixels and not have_source:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot and no source")
|
||||
|
||||
parts = []
|
||||
weights = []
|
||||
evidence: dict = {}
|
||||
|
||||
# --- PIXEL palette discipline -----------------------------------------
|
||||
if have_pixels:
|
||||
content = ctx.content_pixels
|
||||
dominant = _dominant_colors(content, mask)
|
||||
margins = _margin_groups(mask, ctx.scale)
|
||||
# 4-9 dominant colours is healthy; drift from 7 in either direction hurts.
|
||||
color_score = 100.0 * (1 - min(abs(dominant - 7) / 12.0, 1.0))
|
||||
# 1-3 left margins is disciplined; each extra group erodes the score.
|
||||
margin_score = 100.0 * (1 - min(max(margins - 2, 0) / 6.0, 1.0))
|
||||
pixel_score = 0.5 * color_score + 0.5 * margin_score
|
||||
parts.append(pixel_score)
|
||||
weights.append(0.6)
|
||||
evidence["dominant_colors"] = int(dominant)
|
||||
evidence["margin_groups"] = int(margins)
|
||||
|
||||
# --- SOURCE token discipline ------------------------------------------
|
||||
if have_source:
|
||||
color_hits, font_hits = _source_hits(source_files)
|
||||
# Each raw colour costs ~6 pts, each raw font ~4 pts (ui_lint weights).
|
||||
penalty = 6 * color_hits + 4 * font_hits
|
||||
source_score = max(0.0, 100.0 - penalty)
|
||||
parts.append(source_score)
|
||||
weights.append(0.4)
|
||||
evidence["raw_color_hits"] = int(color_hits)
|
||||
evidence["raw_font_hits"] = int(font_hits)
|
||||
|
||||
wsum = sum(weights)
|
||||
value = sum(p * w for p, w in zip(parts, weights)) / wsum if wsum > 0 else 0.0
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence=evidence,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Scorer A. content_safety - nothing clipped, overflowing, or hidden.
|
||||
|
||||
Efficient use of space must not come at the cost of correctness. This scorer
|
||||
checks that real content is not bleeding into the OS chrome (status bar / home
|
||||
indicator) and is not pinned hard against the horizontal edges (a sign of
|
||||
missing safe-area / padding or text that will clip). It complements
|
||||
space_efficiency: you can fill the canvas, but not by spilling under the clock.
|
||||
|
||||
Note: Context.content_pixels already trims the nominal chrome bands. Here we
|
||||
re-read the FULL frame so we can inspect the chrome rows themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context, TOKENS, hex_to_rgb
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "content_safety"
|
||||
CATEGORY = "A"
|
||||
WEIGHT = 0.05
|
||||
|
||||
STATUS_BAR_FRAC = 0.055
|
||||
HOME_INDICATOR_FRAC = 0.025
|
||||
# Status bar legitimately has the clock/wifi/battery glyphs, so allow some ink
|
||||
# there; we only flag the *app* drawing content into the home-indicator zone or
|
||||
# jamming the side edges.
|
||||
EDGE_PX_FRAC = 0.012 # outermost columns considered "the edge"
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
arr = ctx.pixels
|
||||
if arr is None:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot")
|
||||
|
||||
h, w, _ = arr.shape
|
||||
bg = hex_to_rgb(TOKENS["background"])
|
||||
dist = np.linalg.norm(arr - bg, axis=2)
|
||||
mask = dist > 18.0
|
||||
|
||||
# Home-indicator band: app content here is a clipping risk.
|
||||
home_start = int(h * (1 - HOME_INDICATOR_FRAC))
|
||||
home_bleed = float(mask[home_start:].mean())
|
||||
|
||||
# Horizontal edge safety: content in the outermost columns suggests no
|
||||
# horizontal inset (text/cards clipping at the screen edge).
|
||||
edge = max(1, int(w * EDGE_PX_FRAC))
|
||||
# Ignore the chrome rows when judging edges.
|
||||
body = mask[int(h * STATUS_BAR_FRAC):home_start]
|
||||
left_edge = float(body[:, :edge].mean())
|
||||
right_edge = float(body[:, -edge:].mean())
|
||||
edge_bleed = max(left_edge, right_edge)
|
||||
|
||||
# Each bleed signal drives the score down. 0 bleed -> 100.
|
||||
home_pen = min(home_bleed / 0.05, 1.0) # 5% occupancy in indicator = max penalty
|
||||
edge_pen = min(edge_bleed / 0.04, 1.0) # 4% occupancy at the edge = max penalty
|
||||
value = 100 * (1 - 0.5 * home_pen - 0.5 * edge_pen)
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"home_indicator_bleed": round(home_bleed, 4),
|
||||
"left_edge_occ": round(left_edge, 4),
|
||||
"right_edge_occ": round(right_edge, 4),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Scorer: D. contrast.
|
||||
|
||||
Grades WCAG text/background contrast on the *rendered* content. We isolate the
|
||||
text-ish foreground (pixels whose contrast vs the app background already exceeds
|
||||
a weak 2:1, which excludes the near-background card surfaces and anti-aliased
|
||||
fuzz) and read two bands: the brightest decile (primary copy / accents) and a
|
||||
dim lower-mid band (the secondary / tertiary label tier). Both are scored on the
|
||||
WCAG ramp (1:1 -> 0, >=7:1 -> 100 AAA) and a `below_AA` flag fires when a major
|
||||
text band drops under 4.5:1. relative_luminance + contrast_ratio are implemented
|
||||
here (sRGB linearization) so the scorer never trusts a view model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context, TOKENS, hex_to_rgb
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "contrast"
|
||||
CATEGORY = "D"
|
||||
WEIGHT = 0.14
|
||||
|
||||
# WCAG thresholds.
|
||||
AA = 4.5
|
||||
AAA = 7.0
|
||||
# Pixels at least this far above background contrast count as "text-ish"; this
|
||||
# discards the surface/elevated card fills (~1.2:1) and pure background noise.
|
||||
TEXT_MIN_RATIO = 2.0
|
||||
MIN_TEXT_PX = 200 # below this there is no measurable text on the cell
|
||||
|
||||
|
||||
def _relative_luminance(rgb: np.ndarray) -> np.ndarray:
|
||||
"""sRGB relative luminance per WCAG 2.x. Accepts (...,3) in 0..255."""
|
||||
c = np.asarray(rgb, dtype=np.float64) / 255.0
|
||||
lin = np.where(c <= 0.03928, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
||||
return 0.2126 * lin[..., 0] + 0.7152 * lin[..., 1] + 0.0722 * lin[..., 2]
|
||||
|
||||
|
||||
def _contrast_ratio(l1: float, l2: float) -> float:
|
||||
"""WCAG contrast ratio between two relative luminances."""
|
||||
hi, lo = max(l1, l2), min(l1, l2)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def _ramp(ratio: float) -> float:
|
||||
"""Map a contrast ratio onto 0..100: 1:1 -> 0, >=7:1 (AAA) -> 100."""
|
||||
return max(0.0, min(100.0, (ratio - 1.0) / (AAA - 1.0) * 100.0))
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
arr = ctx.content_pixels
|
||||
if arr is None:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot")
|
||||
|
||||
flat = arr.reshape(-1, 3)
|
||||
bg = hex_to_rgb(TOKENS["background"])
|
||||
bg_lum = float(_relative_luminance(bg))
|
||||
|
||||
lum = _relative_luminance(flat)
|
||||
ratio = (np.maximum(lum, bg_lum) + 0.05) / (np.minimum(lum, bg_lum) + 0.05)
|
||||
|
||||
fg = ratio >= TEXT_MIN_RATIO
|
||||
n_text = int(fg.sum())
|
||||
if n_text < MIN_TEXT_PX:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no measurable text")
|
||||
|
||||
fg_px = flat[fg]
|
||||
fg_lum = lum[fg]
|
||||
fg_ratio = ratio[fg]
|
||||
|
||||
# Primary band: brightest decile of text-ish pixels (titles, body, accents).
|
||||
p90 = np.percentile(fg_lum, 90)
|
||||
primary_rgb = fg_px[fg_lum >= p90].mean(axis=0)
|
||||
primary_contrast = _contrast_ratio(float(_relative_luminance(primary_rgb)), bg_lum)
|
||||
|
||||
# Secondary band: the dim lower-mid slice of text-ish pixels, i.e. the
|
||||
# secondary/tertiary label tier when it is distinct from the bright primary.
|
||||
lo, hi = np.percentile(fg_lum, 15), np.percentile(fg_lum, 50)
|
||||
band = fg_px[(fg_lum >= lo) & (fg_lum <= hi)]
|
||||
secondary_detected = band.shape[0] >= MIN_TEXT_PX // 2
|
||||
if secondary_detected:
|
||||
secondary_contrast = _contrast_ratio(float(_relative_luminance(band.mean(axis=0))), bg_lum)
|
||||
else:
|
||||
secondary_contrast = primary_contrast
|
||||
|
||||
# A "major text region" below AA is the secondary band; flag it.
|
||||
below_AA = bool(secondary_contrast < AA)
|
||||
|
||||
primary_score = _ramp(primary_contrast)
|
||||
secondary_score = _ramp(secondary_contrast)
|
||||
value = 0.6 * primary_score + 0.4 * secondary_score
|
||||
if below_AA:
|
||||
value -= 8.0 # dim copy that fails AA is a real legibility cost
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"primary_contrast": round(primary_contrast, 2),
|
||||
"est_secondary_contrast": round(secondary_contrast, 2),
|
||||
"below_AA": below_AA,
|
||||
"secondary_detected": secondary_detected,
|
||||
"text_px": n_text,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Scorer A. information_density - useful content vs. chrome.
|
||||
|
||||
Space efficiency asks "is the canvas filled?"; this asks "is what fills it
|
||||
*useful*?". It estimates the share of the content area devoted to the actual
|
||||
transcript versus fixed chrome (header band at the top, composer band at the
|
||||
bottom). A dense, efficient screen spends most of its non-empty pixels on the
|
||||
conversation, not on a tall header or an oversized composer.
|
||||
|
||||
Scenario-aware: the `empty` scenario has no transcript by design. Users do not
|
||||
experience an empty chat as "low density"; they experience it as either a calm
|
||||
starting point or a wall of chrome. In that mode we grade chrome leanness (how
|
||||
little of the screen the fixed chrome eats) instead of transcript ink share.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "information_density"
|
||||
CATEGORY = "A"
|
||||
WEIGHT = 0.05
|
||||
|
||||
# Scenarios that render a deliberate empty state (no transcript yet).
|
||||
EMPTY_SCENARIOS = {"empty"}
|
||||
# Empty-state chrome leanness: fraction of content rows carrying ink. Chrome +
|
||||
# a hint/affordance should stay within this band; more means the chrome itself
|
||||
# is bloated, which users DO feel on every future screen.
|
||||
EMPTY_ROWS_OK = 0.35
|
||||
|
||||
# Approximate chrome band heights as a fraction of the content area. The header
|
||||
# (title + status pill) and the composer are fixed overhead.
|
||||
HEADER_FRAC = 0.11
|
||||
COMPOSER_FRAC = 0.11
|
||||
|
||||
|
||||
def _score_empty_state(mask) -> CategoryScore:
|
||||
"""Empty scenario: grade chrome leanness, not transcript density."""
|
||||
row_occ = mask.mean(axis=1)
|
||||
inked_rows_frac = float((row_occ > 0.01).mean())
|
||||
|
||||
# Some ink must exist (a header/composer affordance); a fully blank frame
|
||||
# gives the user nothing to act on.
|
||||
if inked_rows_frac <= 0.0:
|
||||
value = 0.0
|
||||
elif inked_rows_frac <= EMPTY_ROWS_OK:
|
||||
value = 100.0
|
||||
else:
|
||||
# Chrome creep: decay linearly, hitting 0 when chrome covers all rows.
|
||||
value = 100.0 * max(0.0, 1.0 - (inked_rows_frac - EMPTY_ROWS_OK)
|
||||
/ (1.0 - EMPTY_ROWS_OK))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"mode": "empty_state",
|
||||
"inked_rows_frac": round(inked_rows_frac, 4),
|
||||
"empty_rows_ok": EMPTY_ROWS_OK,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
mask = ctx.content_mask
|
||||
if mask is None:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot")
|
||||
|
||||
if ctx.scenario in EMPTY_SCENARIOS:
|
||||
return _score_empty_state(mask)
|
||||
|
||||
ch = mask.shape[0]
|
||||
header_end = int(ch * HEADER_FRAC)
|
||||
composer_start = int(ch * (1 - COMPOSER_FRAC))
|
||||
|
||||
row_occ = mask.mean(axis=1)
|
||||
total = float(row_occ.sum()) + 1e-9
|
||||
|
||||
header_content = float(row_occ[:header_end].sum())
|
||||
composer_content = float(row_occ[composer_start:].sum())
|
||||
transcript_content = float(row_occ[header_end:composer_start].sum())
|
||||
|
||||
# Share of all content pixels that live in the transcript region.
|
||||
transcript_share = transcript_content / total
|
||||
chrome_share = (header_content + composer_content) / total
|
||||
|
||||
# A healthy chat spends most ink on the transcript. But a totally empty
|
||||
# transcript (everything in chrome) is bad; reward transcript_share while
|
||||
# requiring the transcript region to actually contain something.
|
||||
transcript_region_occ = float(row_occ[header_end:composer_start].mean())
|
||||
|
||||
# Blend: 70% transcript share of ink, 30% transcript region occupancy.
|
||||
value = 100 * (0.7 * transcript_share + 0.3 * min(transcript_region_occ * 3, 1.0))
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"mode": "transcript",
|
||||
"transcript_share": round(transcript_share, 4),
|
||||
"chrome_share": round(chrome_share, 4),
|
||||
"transcript_region_occ": round(transcript_region_occ, 4),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""B. interaction_cost - expected interaction cost, in real seconds (HCI-grounded).
|
||||
|
||||
This replaces the old regex tap-counter with a principled model:
|
||||
|
||||
* a weighted user-behavior GRAPH (Markov chain over UI states) whose edge
|
||||
weights are grounded in this user's REAL TUI usage logs (reward/interaction/
|
||||
log_mining.py), falling back to HCI-literature defaults off-machine;
|
||||
* a cost model that prices each action in SECONDS using KLM / Touch-Level-Model
|
||||
operators (M=1.35s, TAP=0.20s, H=0.40s, K) plus Fitts' law movement time over
|
||||
the actual control geometry parsed from the SwiftUI source (reward/interaction/
|
||||
ui_map.py); and
|
||||
* an engine that solves the stationary distribution and reports the expected
|
||||
seconds per user action (reward/interaction/engine.py).
|
||||
|
||||
The reward is `engine.reward_score()`: the expected per-action cost mapped to
|
||||
0..100 (cheaper = higher). Because the geometry comes from the source, shrinking
|
||||
a deep flow or enlarging/relocating a frequently-tapped control raises the score
|
||||
in a way that reflects real predicted user effort, not a regex heuristic.
|
||||
|
||||
Deterministic: same logs + same source -> same score. If the engine cannot run
|
||||
(e.g. import/parse failure), this degrades to make_unavailable so it never tanks
|
||||
the overall reward.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "interaction_cost"
|
||||
CATEGORY = "B"
|
||||
WEIGHT = 0.12
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
# Import lazily so a problem in the engine package can't break scorer
|
||||
# discovery for every other category.
|
||||
try:
|
||||
from reward.interaction.engine import run_engine, reward_score
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, f"engine import failed: {e}")
|
||||
|
||||
try:
|
||||
result = run_engine(source_root=ctx.source_root or None)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, f"engine run failed: {e}")
|
||||
|
||||
value = reward_score(result)
|
||||
|
||||
# Surface the most expensive frequently-used actions so the evidence points
|
||||
# straight at what to optimize next.
|
||||
ranked = sorted(
|
||||
result.action_costs_s.items(),
|
||||
key=lambda kv: kv[1] * result.action_probability.get(kv[0], 0.0),
|
||||
reverse=True,
|
||||
)
|
||||
top = [
|
||||
{"action": aid, "seconds": result.action_costs_s[aid],
|
||||
"prob": result.action_probability.get(aid, 0.0)}
|
||||
for aid, _ in ranked[:4]
|
||||
]
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"expected_action_cost_s": result.expected_action_cost_s,
|
||||
"mean_task_time_s": result.mean_task_time_s,
|
||||
"usage_source": result.meta.get("usage_source"),
|
||||
"log_lines_scanned": result.meta.get("lines_scanned"),
|
||||
"stationary": result.stationary,
|
||||
"task_times_s": result.task_times_s,
|
||||
"top_cost_actions": top,
|
||||
"model": "KLM/TLM + Fitts over log-grounded user graph",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Scorer: E. layout_robustness.
|
||||
|
||||
Layout fragility is a cross-cell property (variance of per-cell scores), but a
|
||||
scorer only ever sees ONE matrix cell. So this is a deterministic *intra-cell
|
||||
PROXY* for fragility: signals that, when present in a single screenshot, predict
|
||||
a layout that breaks across the device x content matrix.
|
||||
|
||||
Three honest signals, all read from the rendered pixels (`ctx.content_mask`,
|
||||
which is already trimmed of OS chrome via STATUS_BAR_FRAC / HOME_INDICATOR_FRAC):
|
||||
|
||||
(a) chrome bleed - content jammed against the chrome boundary. We measure
|
||||
occupancy in a thin band at the very top and very bottom of the content
|
||||
region. content_mask already excludes the status bar / home indicator, so
|
||||
heavy occupancy in these boundary bands means content is pressed right up
|
||||
to the chrome edge (a clip/overlap risk on notched / home-indicator
|
||||
devices). Near-zero occupancy is safe.
|
||||
(b) edge safety - content not jammed against the extreme left/right screen
|
||||
edges. Heavy occupancy in the outermost columns means missing side
|
||||
margins, which clips on rounded corners and varies by device width.
|
||||
(c) suspicious full-width rows - a row at ~100% occupancy spanning the full
|
||||
width is usually an unexpected overflow / error bar rather than intended
|
||||
content. A few solid rows (hairline dividers) are fine; a thick solid band
|
||||
is a fragility smell.
|
||||
|
||||
Everything is a saturating penalty: safe layouts score ~100, clear pathologies
|
||||
fall toward 0. No randomness, pure function of the pixels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "layout_robustness"
|
||||
CATEGORY = "E"
|
||||
WEIGHT = 0.05
|
||||
|
||||
# Boundary band thickness (fraction of the content region) used to detect
|
||||
# content bleeding into chrome / jammed against screen edges.
|
||||
BAND_FRAC = 0.025
|
||||
# Occupancy tolerated in a boundary band before it counts as "bleed". A header
|
||||
# or composer legitimately touches the boundary a little; only heavy, near-full
|
||||
# occupancy signals an actual clip/overlap risk.
|
||||
BLEED_TOL = 0.50
|
||||
EDGE_TOL = 0.50
|
||||
# A row counts as "full width" (overflow-bar smell) above this occupancy.
|
||||
FULL_ROW_THRESH = 0.97
|
||||
# Fraction of rows that may be solid full-width before it's suspicious, and the
|
||||
# fraction at which it's clearly a bar (score floors to 0).
|
||||
SUSP_TOL = 0.005
|
||||
SUSP_CEIL = 0.05
|
||||
|
||||
|
||||
def _saturating(value: float, tol: float, ceiling: float) -> float:
|
||||
"""100 at/below `tol`, 0 at/above `ceiling`, linear in between."""
|
||||
if value <= tol:
|
||||
return 100.0
|
||||
if value >= ceiling:
|
||||
return 0.0
|
||||
return 100.0 * (ceiling - value) / (ceiling - tol)
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
mask = ctx.content_mask
|
||||
if mask is None:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot")
|
||||
|
||||
h, w = mask.shape
|
||||
band_h = max(1, int(h * BAND_FRAC))
|
||||
band_w = max(1, int(w * BAND_FRAC))
|
||||
|
||||
# (a) chrome bleed: occupancy in the top / bottom boundary bands.
|
||||
bleed_top = float(mask[:band_h].mean())
|
||||
bleed_bottom = float(mask[-band_h:].mean())
|
||||
bleed_top_score = _saturating(bleed_top, BLEED_TOL, 1.0)
|
||||
bleed_bottom_score = _saturating(bleed_bottom, BLEED_TOL, 1.0)
|
||||
|
||||
# (b) edge safety: worst (most occupied) of the extreme left/right columns.
|
||||
edge_left = float(mask[:, :band_w].mean())
|
||||
edge_right = float(mask[:, -band_w:].mean())
|
||||
edge_worst = max(edge_left, edge_right)
|
||||
edge_safety = _saturating(edge_worst, EDGE_TOL, 1.0)
|
||||
|
||||
# (c) suspicious full-width rows: count solid, full-width rows and grade the
|
||||
# fraction of the content height they cover.
|
||||
row_occ = mask.mean(axis=1)
|
||||
susp_rows = int((row_occ > FULL_ROW_THRESH).sum())
|
||||
susp_frac = susp_rows / h
|
||||
susp_score = _saturating(susp_frac, SUSP_TOL, SUSP_CEIL)
|
||||
|
||||
value = (0.28 * bleed_top_score
|
||||
+ 0.28 * bleed_bottom_score
|
||||
+ 0.24 * edge_safety
|
||||
+ 0.20 * susp_score)
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
# Raw "bleed" occupancy fractions (lower = safer).
|
||||
"chrome_bleed_top": round(bleed_top, 4),
|
||||
"chrome_bleed_bottom": round(bleed_bottom, 4),
|
||||
# Edge-margin safety sub-score, 0..100 (higher = safer).
|
||||
"edge_safety": round(edge_safety, 2),
|
||||
# suspND: count of suspicious near-full-width rows (overflow-bar smell).
|
||||
"suspND": susp_rows,
|
||||
# Sub-scores behind the blend, for regression diffs.
|
||||
"bleed_top_score": round(bleed_top_score, 2),
|
||||
"bleed_bottom_score": round(bleed_bottom_score, 2),
|
||||
"edge_worst_occ": round(edge_worst, 4),
|
||||
"susp_score": round(susp_score, 2),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Scorer: E. perf.
|
||||
|
||||
Best-effort "feels instant" signal from runtime traces. Today the harness runs
|
||||
headless and does NOT collect runtime metrics, so the normal case is graceful
|
||||
degradation: when `ctx.runtime` is missing or empty, this returns
|
||||
`make_unavailable(...)` and the aggregator drops it and renormalizes weights so
|
||||
the reward is never tanked by data we simply don't have yet.
|
||||
|
||||
Expected `ctx.runtime` schema (a plain dict; all keys OPTIONAL, populate what a
|
||||
future harness can measure via simctl / Instruments / signposts):
|
||||
|
||||
{
|
||||
# Time from app launch to the process being ready, milliseconds.
|
||||
# <= 400ms feels instant -> 100; >= 2000ms feels sluggish -> 0.
|
||||
"cold_launch_ms": float,
|
||||
|
||||
# Time from launch to the first rendered frame, milliseconds.
|
||||
# <= 250ms -> 100; >= 1200ms -> 0.
|
||||
"first_frame_ms": float,
|
||||
|
||||
# Fraction of frames dropped / janked during a representative scroll,
|
||||
# 0.0 (perfectly smooth) .. 1.0 (every frame janked).
|
||||
# <= 0.02 -> 100; >= 0.30 -> 0.
|
||||
"scroll_jank_frac": float,
|
||||
}
|
||||
|
||||
The blended value is the weighted mean over WHICHEVER of the three sub-metrics
|
||||
are present, with weights renormalized over the present ones. If `runtime` is a
|
||||
dict but contains none of these keys, we still treat it as "no metrics" and go
|
||||
unavailable. Pure + deterministic: identical runtime dict -> identical score.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "perf"
|
||||
CATEGORY = "E"
|
||||
WEIGHT = 0.04
|
||||
|
||||
# (good_ms_or_frac, bad_ms_or_frac, sub_weight) per metric. `good` maps to 100,
|
||||
# `bad` maps to 0, linear in between, clamped to [0, 100].
|
||||
_METRICS = {
|
||||
"cold_launch_ms": (400.0, 2000.0, 0.40),
|
||||
"first_frame_ms": (250.0, 1200.0, 0.35),
|
||||
"scroll_jank_frac": (0.02, 0.30, 0.25),
|
||||
}
|
||||
|
||||
|
||||
def _ramp(value: float, good: float, bad: float) -> float:
|
||||
"""100 at `good`, 0 at `bad`, linear + clamped. Works for good<bad ramps."""
|
||||
if bad == good:
|
||||
return 100.0 if value <= good else 0.0
|
||||
frac = (bad - value) / (bad - good)
|
||||
return max(0.0, min(100.0, 100.0 * frac))
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
runtime = ctx.runtime
|
||||
if not isinstance(runtime, dict) or not runtime:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no runtime metrics")
|
||||
|
||||
sub_scores: dict[str, float] = {}
|
||||
inputs: dict[str, float] = {}
|
||||
weighted_sum = 0.0
|
||||
weight_total = 0.0
|
||||
|
||||
for key, (good, bad, w) in _METRICS.items():
|
||||
raw = runtime.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
raw = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
s = _ramp(raw, good, bad)
|
||||
inputs[key] = round(raw, 4)
|
||||
sub_scores[key] = round(s, 2)
|
||||
weighted_sum += s * w
|
||||
weight_total += w
|
||||
|
||||
if weight_total <= 0:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no runtime metrics")
|
||||
|
||||
value = max(0.0, min(100.0, weighted_sum / weight_total))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
# Echo the runtime inputs we actually consumed.
|
||||
"inputs": inputs,
|
||||
# Per-metric 0..100 sub-scores behind the blend.
|
||||
"sub_scores": sub_scores,
|
||||
# Which metrics were present (so a future harness sees coverage).
|
||||
"metrics_used": sorted(sub_scores.keys()),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""B. reachability - is the PRIMARY action in the comfortable thumb zone?
|
||||
|
||||
On a phone held one-handed, the thumb comfortably reaches the bottom of the
|
||||
screen; the top corners are the hardest to hit. The primary action in jcode is
|
||||
the composer send button, which should live in the bottom-right thumb zone, not
|
||||
stranded at the top.
|
||||
|
||||
This scorer uses the rendered content_mask to locate the salient interactive
|
||||
cluster nearest the bottom-right corner, then scores its vertical position:
|
||||
|
||||
* full credit when the primary action sits in the bottom ~25% of the screen
|
||||
(the comfortable zone),
|
||||
* a smooth ramp through the mid-screen,
|
||||
* heavy penalty if the primary action is up in the top third.
|
||||
|
||||
A small right-bias bonus rewards the conventional bottom-right placement for
|
||||
right-thumb reach. Pure + deterministic: same screenshot -> same score.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context, HOME_INDICATOR_FRAC, STATUS_BAR_FRAC
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "reachability"
|
||||
CATEGORY = "B"
|
||||
WEIGHT = 0.08
|
||||
|
||||
# Thumb-zone band, as a fraction of the *content* height (chrome trimmed). The
|
||||
# bottom 25% is the comfortable reach; below ~0.45 reachability degrades.
|
||||
THUMB_ZONE_TOP = 0.75 # content-fraction where the comfortable zone starts
|
||||
COMFORT_FLOOR = 0.45 # below this fraction the score starts ramping down
|
||||
|
||||
# Detect controls in the composer region (bottom of content). A control is a
|
||||
# compact, dense column cluster: we scan the bottom band for the rightmost
|
||||
# salient blob, which is the send button.
|
||||
COMPOSER_SCAN_FRAC = 0.18 # bottom 18% of content is the composer search area
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
mask = ctx.content_mask
|
||||
if mask is None:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot")
|
||||
|
||||
ch, cw = mask.shape
|
||||
|
||||
# Search the bottom band for the primary action. The send button is the
|
||||
# rightmost dense vertical cluster there. Work in content coordinates.
|
||||
band_top = int(ch * (1.0 - COMPOSER_SCAN_FRAC))
|
||||
band = mask[band_top:]
|
||||
if band.size == 0 or not band.any():
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT,
|
||||
"no content in composer band")
|
||||
|
||||
# Column occupancy in the band; the send button is a tall, narrow cluster
|
||||
# on the right. Take the rightmost contiguous run of well-occupied columns.
|
||||
col_occ = band.mean(axis=0)
|
||||
occupied = col_occ > 0.15
|
||||
# rightmost run of occupied columns
|
||||
x1 = None
|
||||
for x in range(cw - 1, -1, -1):
|
||||
if occupied[x]:
|
||||
x1 = x
|
||||
break
|
||||
if x1 is None:
|
||||
# fall back to overall right-side bias of content in the band
|
||||
x1 = cw - 1
|
||||
x0 = x1
|
||||
while x0 > 0 and occupied[x0 - 1]:
|
||||
x0 -= 1
|
||||
|
||||
# Vertical extent of that cluster within the band -> its center y.
|
||||
sub = band[:, x0:x1 + 1]
|
||||
rows = np.flatnonzero(sub.any(axis=1))
|
||||
if rows.size:
|
||||
cy_band = float(rows.mean())
|
||||
else:
|
||||
cy_band = band.shape[0] / 2.0
|
||||
cy_content = band_top + cy_band
|
||||
primary_y_frac_content = cy_content / ch
|
||||
|
||||
# Convert to a full-screen fraction for human-readable evidence (account
|
||||
# for the trimmed status bar / home indicator).
|
||||
visible = 1.0 - STATUS_BAR_FRAC - HOME_INDICATOR_FRAC
|
||||
primary_y_frac_screen = STATUS_BAR_FRAC + primary_y_frac_content * visible
|
||||
|
||||
primary_x_frac = (x0 + x1) / 2.0 / cw
|
||||
in_thumb_zone = primary_y_frac_content >= THUMB_ZONE_TOP
|
||||
|
||||
# Vertical score: 100 in the comfortable zone, ramp down to 0 toward the
|
||||
# top. Below COMFORT_FLOOR scales linearly to 0 at the very top.
|
||||
if primary_y_frac_content >= THUMB_ZONE_TOP:
|
||||
vscore = 100.0
|
||||
elif primary_y_frac_content >= COMFORT_FLOOR:
|
||||
# linear from 70 (at floor) to 100 (at thumb-zone top)
|
||||
t = (primary_y_frac_content - COMFORT_FLOOR) / (THUMB_ZONE_TOP - COMFORT_FLOOR)
|
||||
vscore = 70.0 + 30.0 * t
|
||||
else:
|
||||
# primary action stranded high: 0 at top -> 70 at the comfort floor
|
||||
t = primary_y_frac_content / COMFORT_FLOOR
|
||||
vscore = 70.0 * t
|
||||
|
||||
# Horizontal bonus: bottom-right is the canonical right-thumb sweet spot.
|
||||
# Small (+/-) nudge so layout that keeps send on the right edges higher.
|
||||
hbonus = 100.0 * min(1.0, primary_x_frac / 0.85)
|
||||
|
||||
value = 0.85 * vscore + 0.15 * hbonus
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"primary_action_y_frac": round(primary_y_frac_screen, 4),
|
||||
"primary_action_y_frac_content": round(primary_y_frac_content, 4),
|
||||
"primary_action_x_frac": round(primary_x_frac, 4),
|
||||
"in_thumb_zone": bool(in_thumb_zone),
|
||||
"thumb_zone_top_frac": THUMB_ZONE_TOP,
|
||||
"vertical_score": round(vscore, 2),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Scorer: C. rhythm.
|
||||
|
||||
Grades whether vertical spacing snaps to an 8pt grid (the iOS default rhythm).
|
||||
From the content mask we find content bands (contiguous runs of occupied rows)
|
||||
and measure the gaps between consecutive bands. Gaps whose size in points lands
|
||||
near a multiple of 8 read as deliberate, rhythmic spacing; off-grid gaps read
|
||||
as ad-hoc and jittery. As a source cross-check we scan ctx.source_files for
|
||||
spacing/padding literals and count how many fall off the {4,8,12,16,...} grid.
|
||||
|
||||
gap_count number of inter-band vertical gaps measured (pixels)
|
||||
mean_grid_snap 0..1, how close gaps sit to the nearest 8pt multiple
|
||||
offgrid_padding_hits source spacing/padding literals not on the grid
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "rhythm"
|
||||
CATEGORY = "C"
|
||||
WEIGHT = 0.04
|
||||
|
||||
_GRID_PT = 8
|
||||
# Accepted spacing values: the 8pt grid plus the common 4pt half-steps it is
|
||||
# built from. Anything else is an off-grid literal.
|
||||
_SPACING_GRID = {0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 56, 64}
|
||||
# Pull the numeric argument out of spacing:/padding literals.
|
||||
_SPACING_RE = re.compile(r"\bspacing:\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
_PADDING_RE = re.compile(r"\.padding\(\s*([0-9]+(?:\.[0-9]+)?)\s*\)")
|
||||
_PADDING_EDGE_RE = re.compile(r"\.padding\(\s*\.[a-zA-Z]+,\s*([0-9]+(?:\.[0-9]+)?)\s*\)")
|
||||
|
||||
|
||||
def _content_bands(row_occ: np.ndarray, thresh: float = 0.01) -> list[tuple[int, int]]:
|
||||
bands = []
|
||||
start = None
|
||||
for i, v in enumerate(row_occ):
|
||||
if v >= thresh and start is None:
|
||||
start = i
|
||||
elif v < thresh and start is not None:
|
||||
bands.append((start, i))
|
||||
start = None
|
||||
if start is not None:
|
||||
bands.append((start, len(row_occ)))
|
||||
# Drop sub-pixel specks so anti-aliasing noise isn't read as a band.
|
||||
return [b for b in bands if b[1] - b[0] > 3]
|
||||
|
||||
|
||||
def _offgrid_padding_hits(source_files: dict[str, str]) -> int:
|
||||
hits = 0
|
||||
for path, text in source_files.items():
|
||||
if path.endswith("Theme.swift"):
|
||||
continue
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("//"):
|
||||
continue
|
||||
for rx in (_SPACING_RE, _PADDING_RE, _PADDING_EDGE_RE):
|
||||
for m in rx.finditer(line):
|
||||
val = float(m.group(1))
|
||||
if val not in _SPACING_GRID:
|
||||
hits += 1
|
||||
return hits
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
mask = ctx.content_mask
|
||||
source_files = ctx.source_files
|
||||
have_pixels = mask is not None
|
||||
have_source = bool(source_files)
|
||||
|
||||
if not have_pixels and not have_source:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot and no source")
|
||||
|
||||
parts = []
|
||||
weights = []
|
||||
evidence: dict = {}
|
||||
|
||||
# --- PIXEL rhythm: inter-band gaps vs the 8pt grid --------------------
|
||||
if have_pixels:
|
||||
row_occ = mask.mean(axis=1)
|
||||
bands = _content_bands(row_occ)
|
||||
grid = max(1, int(_GRID_PT * ctx.scale))
|
||||
gaps = [bands[i + 1][0] - bands[i][1] for i in range(len(bands) - 1)]
|
||||
gaps = [g for g in gaps if g > 0]
|
||||
if gaps:
|
||||
# Snap = 1 when a gap lands exactly on a grid line, 0 at the worst
|
||||
# (half a cell) offset; average across all gaps.
|
||||
snaps = []
|
||||
for g in gaps:
|
||||
off = g % grid
|
||||
snaps.append(1.0 - min(off, grid - off) / (grid / 2.0))
|
||||
mean_snap = float(max(0.0, np.mean(snaps)))
|
||||
else:
|
||||
mean_snap = 0.0
|
||||
parts.append(100.0 * mean_snap)
|
||||
weights.append(0.7)
|
||||
evidence["gap_count"] = int(len(gaps))
|
||||
evidence["mean_grid_snap"] = round(mean_snap, 4)
|
||||
|
||||
# --- SOURCE cross-check: off-grid padding/spacing literals ------------
|
||||
if have_source:
|
||||
offgrid = _offgrid_padding_hits(source_files)
|
||||
# Each off-grid literal costs 8 pts off a perfect 100.
|
||||
source_score = max(0.0, 100.0 - 8.0 * offgrid)
|
||||
parts.append(source_score)
|
||||
weights.append(0.3)
|
||||
evidence["offgrid_padding_hits"] = int(offgrid)
|
||||
|
||||
wsum = sum(weights)
|
||||
value = sum(p * w for p, w in zip(parts, weights)) / wsum if wsum > 0 else 0.0
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence=evidence,
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Scorer: F. simplicity.
|
||||
|
||||
Grades anti-complexity: a crafted screen is shallow, focused, and calm, while
|
||||
AI-generated UI piles on structure for its own sake. Per
|
||||
`reward/AI_SLOP_RESEARCH.md`, two documented slop tells are "card nesting (cards
|
||||
inside cards inside cards; everything wrapped in a container regardless of
|
||||
need)" and "too many primitives" with "no real hierarchy". Higher score here =
|
||||
simpler / cleaner.
|
||||
|
||||
We blend SOURCE structure (what the SwiftUI tree actually is) with a PIXEL
|
||||
proxy (how cluttered the rendered content reads). Source is the honest, primary
|
||||
signal because nesting/primitive count are properties of the design itself and
|
||||
are independent of how much conversation happens to be on screen; the pixel pass
|
||||
is a lighter corroboration so neither can be gamed alone.
|
||||
|
||||
SOURCE signals (all measured on the parsed View structs):
|
||||
max_nesting_depth Deepest chain of layout containers (VStack/HStack/ZStack/
|
||||
ScrollView/List/...) anywhere in the source. Deep trees are
|
||||
the "wrapped in a container regardless of need" tell;
|
||||
shallow trees use spacing + type as structure. Reward
|
||||
shallow, penalize deep.
|
||||
cardincard_count Container shapes (Card { } / .clipShape(RoundedRectangle))
|
||||
nested inside another card region. This is the canonical
|
||||
"card-in-card" slop tell -> pure penalty.
|
||||
distinct_primitives Number of DISTINCT SwiftUI primitive types used in the
|
||||
heaviest (densest) view. A focused screen reaches for a
|
||||
few primitives; a slop screen sprays many. Reward few.
|
||||
avg_modifier_chain Average chained-modifier count per view element. Very long
|
||||
fiddly chains signal incidental complexity. Reward calm.
|
||||
|
||||
PIXEL signal (content_mask only, numpy-only, deterministic):
|
||||
pixel_region_count Distinct content "blobs" found by row/column banding of the
|
||||
content mask. We don't punish a chat for having many
|
||||
message rows; we punish many SMALL competing regions
|
||||
(scattered chips/dots/badges), which is the visual form of
|
||||
"too many primitives". Reward fewer small competing blobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "simplicity"
|
||||
CATEGORY = "F"
|
||||
WEIGHT = 0.04
|
||||
|
||||
# Layout containers that genuinely create UI nesting depth. Buttons/Menus also
|
||||
# take trailing closures but wrap a single label, so counting them would inflate
|
||||
# depth without reflecting real structural complexity; we keep this to the
|
||||
# stacking/scrolling/list primitives the spec names.
|
||||
_CONTAINERS = {
|
||||
"VStack", "HStack", "ZStack", "ScrollView", "ScrollViewReader",
|
||||
"List", "LazyVStack", "LazyHStack", "LazyVGrid", "LazyHGrid", "Grid",
|
||||
"Form", "Section", "Group", "NavigationStack", "NavigationView", "ForEach",
|
||||
}
|
||||
|
||||
# Leaf primitives. Distinct-primitive variety counts containers + leaves: a
|
||||
# focused screen draws from a small vocabulary.
|
||||
_LEAVES = {
|
||||
"Text", "Image", "Button", "TextField", "SecureField", "Toggle", "Label",
|
||||
"Spacer", "Divider", "ProgressView", "Circle", "Rectangle",
|
||||
"RoundedRectangle", "Capsule", "Link", "Menu", "Picker", "Stepper",
|
||||
"Slider", "Color", "Gauge",
|
||||
}
|
||||
_PRIMITIVES = _CONTAINERS | _LEAVES
|
||||
|
||||
# A standalone token (not a property access like Theme.Text or .Color).
|
||||
_TOKEN_RE = {p: re.compile(rf"(?<![\w.]){p}\b") for p in _PRIMITIVES}
|
||||
# A modifier call on its own line: `.font(...)`, `.padding(10)`, `.italic()`.
|
||||
_MODIFIER_RE = re.compile(r"^\s*\.[A-Za-z_]\w*\s*\(")
|
||||
# A clipped rounded surface = a "card". Capsule/Circle pills are intentional
|
||||
# status chrome, not cards, so they are excluded.
|
||||
_CARD_CLIP_RE = re.compile(r"\.clipShape\(\s*RoundedRectangle")
|
||||
_STRUCT_RE = re.compile(r"struct\s+(\w+)\s*:\s*[^{]*\bView\b[^{]*\{")
|
||||
_WORD_OR_BRACE_RE = re.compile(r"[A-Za-z_]\w*|[{}]")
|
||||
|
||||
|
||||
def _matching_brace(text: str, open_idx: int) -> int:
|
||||
"""Index of the brace matching the `{` at open_idx (or len(text))."""
|
||||
depth = 0
|
||||
for i in range(open_idx, len(text)):
|
||||
c = text[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
return len(text)
|
||||
|
||||
|
||||
def _view_bodies(source_files: dict[str, str]) -> list[tuple[str, str]]:
|
||||
"""(struct_name, struct_body_text) for every `struct X: View`, deterministic."""
|
||||
out: list[tuple[str, str]] = []
|
||||
for _path, text in sorted(source_files.items()):
|
||||
for m in _STRUCT_RE.finditer(text):
|
||||
brace = text.index("{", m.start())
|
||||
end = _matching_brace(text, brace)
|
||||
out.append((m.group(1), text[brace:end + 1]))
|
||||
return out
|
||||
|
||||
|
||||
def _container_depth(body: str) -> int:
|
||||
"""Max simultaneously-open layout containers (approx UI nesting depth).
|
||||
|
||||
Token scan: a container keyword arms `pending`; the next `{` opens a
|
||||
container frame. Other `{` (closures like Button { } / .onChange { }) open
|
||||
non-container frames so they never inflate structural depth.
|
||||
"""
|
||||
stack: list[bool] = []
|
||||
pending = False
|
||||
best = 0
|
||||
for tok in _WORD_OR_BRACE_RE.findall(body):
|
||||
if tok == "{":
|
||||
stack.append(pending)
|
||||
pending = False
|
||||
depth = sum(stack)
|
||||
if depth > best:
|
||||
best = depth
|
||||
elif tok == "}":
|
||||
if stack:
|
||||
stack.pop()
|
||||
elif tok in _CONTAINERS:
|
||||
pending = True
|
||||
return best
|
||||
|
||||
|
||||
def _cardincard(body: str) -> int:
|
||||
"""Count card regions nested inside another card region.
|
||||
|
||||
A frame is a "card" if its `{` was a `Card {` opener, or if the current
|
||||
scope carries a `.clipShape(RoundedRectangle)` surface modifier. Opening a
|
||||
second card while an ancestor card frame is still open is the card-in-card
|
||||
slop tell. This is lexical (an approximation): it fires on real nesting like
|
||||
`Card { ... Card { ... } }` and stays at 0 for a flat, disciplined app.
|
||||
"""
|
||||
count = 0
|
||||
stack: list[bool] = [] # is_card per brace frame
|
||||
pending_card = False
|
||||
i, n = 0, len(body)
|
||||
while i < n:
|
||||
c = body[i]
|
||||
if c == "{":
|
||||
is_card = pending_card
|
||||
if is_card and any(stack): # an ancestor frame is already a card
|
||||
count += 1
|
||||
stack.append(is_card)
|
||||
pending_card = False
|
||||
i += 1
|
||||
continue
|
||||
if c == "}":
|
||||
if stack:
|
||||
stack.pop()
|
||||
i += 1
|
||||
continue
|
||||
if body.startswith("Card", i) and (i == 0 or not (body[i - 1].isalnum() or body[i - 1] in "_.")):
|
||||
# `Card` container constructor -> next `{` is a card frame.
|
||||
pending_card = True
|
||||
i += 4
|
||||
continue
|
||||
if c == "." and _CARD_CLIP_RE.match(body, i):
|
||||
# A clipped rounded surface in the current scope: if an ancestor is
|
||||
# already a card, that is card-in-card; otherwise the scope becomes a
|
||||
# card (so a later inner clip would count).
|
||||
if stack:
|
||||
if not stack[-1] and any(stack[:-1]):
|
||||
count += 1
|
||||
stack[-1] = True
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
return count
|
||||
|
||||
|
||||
def _distinct_primitives(body: str) -> int:
|
||||
return sum(1 for p, rx in _TOKEN_RE.items() if rx.search(body))
|
||||
|
||||
|
||||
def _primitive_total(body: str) -> int:
|
||||
return sum(len(rx.findall(body)) for rx in _TOKEN_RE.values())
|
||||
|
||||
|
||||
def _modifier_density(bodies: list[tuple[str, str]]) -> float:
|
||||
"""Average chained modifiers per element across all views.
|
||||
|
||||
Modifiers in this codebase sit one-per-line, so counting `^\\s*.\\w+(` lines
|
||||
is a faithful chain length; dividing by element count normalizes for screen
|
||||
size. Long chains per element read as fiddly, incidental complexity.
|
||||
"""
|
||||
modifiers = elements = 0
|
||||
for _name, body in bodies:
|
||||
for line in body.splitlines():
|
||||
if _MODIFIER_RE.match(line):
|
||||
modifiers += 1
|
||||
elements += _primitive_total(body)
|
||||
return modifiers / elements if elements else 0.0
|
||||
|
||||
|
||||
def _pixel_regions(mask: np.ndarray, scale: int) -> tuple[int, int]:
|
||||
"""(total_regions, small_competing_regions) via coarse connected components.
|
||||
|
||||
Counting raw connected pixels would split every glyph into its own region;
|
||||
that measures text, not layout. Instead we downsample the content mask onto
|
||||
an 8pt grid (one coarse cell per `8*scale` px), mark a cell ON when it is
|
||||
meaningfully covered, then label 4-connected blobs deterministically. Each
|
||||
blob is a visual "block" (a message bubble, a card, the composer). Large,
|
||||
well-separated blocks read as simple; many TINY competing blobs (status
|
||||
dots, scattered chips/badges) are the visual form of "too many primitives",
|
||||
so we report them separately as the thing to penalize.
|
||||
"""
|
||||
block = max(1, int(8 * scale))
|
||||
h, w = mask.shape
|
||||
gh, gw = h // block, w // block
|
||||
if gh == 0 or gw == 0:
|
||||
return 0, 0
|
||||
cell_cov = (mask[:gh * block, :gw * block]
|
||||
.reshape(gh, block, gw, block).mean(axis=(1, 3)))
|
||||
grid = cell_cov > 0.12 # a cell is "content" when >12% of it is covered
|
||||
|
||||
labels = np.zeros((gh, gw), dtype=np.int32)
|
||||
total = small = 0
|
||||
for si in range(gh):
|
||||
for sj in range(gw):
|
||||
if not grid[si, sj] or labels[si, sj]:
|
||||
continue
|
||||
total += 1
|
||||
size = 0
|
||||
stack = [(si, sj)]
|
||||
labels[si, sj] = total
|
||||
while stack:
|
||||
y, x = stack.pop()
|
||||
size += 1
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
ny, nx = y + dy, x + dx
|
||||
if 0 <= ny < gh and 0 <= nx < gw and grid[ny, nx] and not labels[ny, nx]:
|
||||
labels[ny, nx] = total
|
||||
stack.append((ny, nx))
|
||||
# <=2 coarse cells ~ a region under ~16x16pt: a dot/chip, not a block.
|
||||
if size <= 2:
|
||||
small += 1
|
||||
return total, small
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
source_files = ctx.source_files
|
||||
have_source = bool(source_files)
|
||||
mask = ctx.content_mask
|
||||
have_pixels = mask is not None
|
||||
|
||||
if not have_source and not have_pixels:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot and no source")
|
||||
|
||||
parts: list[float] = []
|
||||
weights: list[float] = []
|
||||
evidence: dict = {}
|
||||
|
||||
# --- SOURCE structure (primary) ---------------------------------------
|
||||
if have_source:
|
||||
bodies = _view_bodies(source_files)
|
||||
max_depth = max((_container_depth(b) for _n, b in bodies), default=0)
|
||||
cardincard = sum(_cardincard(b) for _n, b in bodies)
|
||||
# Heaviest = densest view (most primitive instances) per the spec.
|
||||
heaviest = max(bodies, key=lambda nb: _primitive_total(nb[1]), default=("", ""))
|
||||
distinct = _distinct_primitives(heaviest[1])
|
||||
avg_mods = _modifier_density(bodies)
|
||||
|
||||
# Shallow trees are simple. A clean SwiftUI screen nests ~4 containers;
|
||||
# each extra level past that erodes the score, zeroing by ~12 deep.
|
||||
depth_score = 100.0 * (1 - min(max(max_depth - 4, 0) / 8.0, 1.0))
|
||||
# Card-in-card is a hard slop tell: each instance is a steep penalty.
|
||||
cardincard_score = max(0.0, 100.0 - 22.0 * cardincard)
|
||||
# A focused screen uses ~6 primitive types; sprawl past that is penalized.
|
||||
distinct_score = 100.0 * (1 - min(max(distinct - 6, 0) / 10.0, 1.0))
|
||||
# ~3 modifiers/element is calm; long fiddly chains (>=9) zero it out.
|
||||
modifier_score = 100.0 * (1 - min(max(avg_mods - 3.0, 0.0) / 6.0, 1.0))
|
||||
|
||||
source_score = (0.30 * depth_score
|
||||
+ 0.30 * cardincard_score
|
||||
+ 0.20 * distinct_score
|
||||
+ 0.20 * modifier_score)
|
||||
parts.append(source_score)
|
||||
weights.append(0.70)
|
||||
evidence["max_nesting_depth"] = int(max_depth)
|
||||
evidence["cardincard_count"] = int(cardincard)
|
||||
evidence["distinct_primitives"] = int(distinct)
|
||||
evidence["heaviest_view"] = heaviest[0]
|
||||
evidence["avg_modifier_chain"] = round(avg_mods, 3)
|
||||
|
||||
# --- PIXEL clutter proxy (corroboration) ------------------------------
|
||||
if have_pixels:
|
||||
regions, small = _pixel_regions(mask, ctx.scale)
|
||||
# Many small competing blobs read as cluttered; large well-separated
|
||||
# blocks do not. Penalize small competing regions (the visual "too many
|
||||
# primitives" tell) primarily, with a gentle nudge against very high
|
||||
# total block counts. Raw content volume (more messages) is NOT punished.
|
||||
small_score = 100.0 * (1 - min(small / 8.0, 1.0))
|
||||
total_score = 100.0 * (1 - min(max(regions - 12, 0) / 24.0, 1.0))
|
||||
pixel_score = 0.7 * small_score + 0.3 * total_score
|
||||
parts.append(pixel_score)
|
||||
weights.append(0.30)
|
||||
evidence["pixel_region_count"] = int(regions)
|
||||
evidence["pixel_small_regions"] = int(small)
|
||||
|
||||
wsum = sum(weights)
|
||||
value = sum(p * w for p, w in zip(parts, weights)) / wsum if wsum > 0 else 0.0
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence=evidence,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Reference scorer: A. space_efficiency.
|
||||
|
||||
Grades how well the rendered UI uses the canvas: fill ratio, vertical balance,
|
||||
and the largest empty "dead zone". This is the worked example every other
|
||||
scorer should follow (NAME / CATEGORY / WEIGHT / pure score()).
|
||||
|
||||
Scenario-aware: the `empty` scenario is a deliberate empty state. Real users
|
||||
judge an empty chat screen by "can I see where to start?" not "are the pixels
|
||||
filled?", so in that mode we reward a sparse canvas with a visible composer
|
||||
affordance instead of a 30-60% fill target.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "space_efficiency"
|
||||
CATEGORY = "A"
|
||||
WEIGHT = 0.05
|
||||
|
||||
# Scenarios that render a deliberate empty state (no transcript yet).
|
||||
EMPTY_SCENARIOS = {"empty"}
|
||||
# Empty-state fill band: some ink must exist (chrome + affordance), but the
|
||||
# canvas is expected to be mostly calm. Above the ceiling it stops looking
|
||||
# like an empty state and starts looking like clutter.
|
||||
EMPTY_FILL_FLOOR = 0.01
|
||||
EMPTY_FILL_CEIL = 0.22
|
||||
# Bottom band searched for the start affordance (the composer).
|
||||
AFFORDANCE_BAND_FRAC = 0.20
|
||||
AFFORDANCE_MIN_OCC = 0.02
|
||||
|
||||
|
||||
def _longest_run(flags) -> int:
|
||||
best = run = 0
|
||||
for v in flags:
|
||||
run = run + 1 if v else 0
|
||||
best = max(best, run)
|
||||
return best
|
||||
|
||||
|
||||
def _score_empty_state(mask: np.ndarray) -> CategoryScore:
|
||||
"""Empty scenario: a calm canvas with a clear affordance to start.
|
||||
|
||||
Users landing on an empty chat need exactly one thing: an obvious place to
|
||||
type. Reward (a) a visible composer/affordance in the bottom band, and
|
||||
(b) a fill ratio inside the calm empty-state band. No dead-zone penalty:
|
||||
an empty transcript IS a dead zone by design.
|
||||
"""
|
||||
ch = mask.shape[0]
|
||||
fill_ratio = float(mask.mean())
|
||||
row_occ = mask.mean(axis=1)
|
||||
|
||||
band_start = int(ch * (1 - AFFORDANCE_BAND_FRAC))
|
||||
affordance_occ = float(row_occ[band_start:].mean())
|
||||
affordance_score = 100.0 * min(affordance_occ / AFFORDANCE_MIN_OCC, 1.0)
|
||||
|
||||
if fill_ratio < EMPTY_FILL_FLOOR:
|
||||
calm_score = 100.0 * fill_ratio / EMPTY_FILL_FLOOR # truly blank screen
|
||||
elif fill_ratio <= EMPTY_FILL_CEIL:
|
||||
calm_score = 100.0
|
||||
else:
|
||||
# Past the ceiling, decay linearly: at 2x the ceiling it is no longer
|
||||
# an empty state at all.
|
||||
over = (fill_ratio - EMPTY_FILL_CEIL) / EMPTY_FILL_CEIL
|
||||
calm_score = 100.0 * max(0.0, 1.0 - over)
|
||||
|
||||
value = 0.6 * affordance_score + 0.4 * calm_score
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"mode": "empty_state",
|
||||
"fill_ratio": round(fill_ratio, 4),
|
||||
"affordance_band_occ": round(affordance_occ, 4),
|
||||
"affordance_score": round(affordance_score, 2),
|
||||
"calm_score": round(calm_score, 2),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
mask = ctx.content_mask
|
||||
if mask is None:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot")
|
||||
|
||||
if ctx.scenario in EMPTY_SCENARIOS:
|
||||
return _score_empty_state(mask)
|
||||
|
||||
ch = mask.shape[0]
|
||||
fill_ratio = float(mask.mean())
|
||||
|
||||
row_occ = mask.mean(axis=1)
|
||||
ys = np.arange(ch)
|
||||
occ_sum = row_occ.sum()
|
||||
com = float((ys * row_occ).sum() / occ_sum) / ch if occ_sum > 0 else 0.5
|
||||
vertical_balance = 1.0 - abs(com - 0.5) * 2.0
|
||||
|
||||
dead = _longest_run(row_occ < 0.01) / ch
|
||||
|
||||
# An efficient chat fills ~30-60% with content reasonably spread. Reward
|
||||
# closeness to that band; penalize a large dead zone hard.
|
||||
fill_score = 100 * (1 - min(abs(fill_ratio - 0.45) / 0.45, 1.0))
|
||||
value = (0.45 * fill_score
|
||||
+ 0.35 * (vertical_balance * 100)
|
||||
+ 0.20 * (100 * (1 - dead)))
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"mode": "transcript",
|
||||
"fill_ratio": round(fill_ratio, 4),
|
||||
"vertical_balance": round(vertical_balance, 4),
|
||||
"dead_zone_frac": round(dead, 4),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Scorer: F. styling.
|
||||
|
||||
Grades aesthetic coherence & intent - "designed, not generated". Grounded in
|
||||
reward/AI_SLOP_RESEARCH.md: crafted UI commits to ONE dominant colour plus a
|
||||
sharp, sparing accent (not a timid rainbow), uses a real type scale (deliberate
|
||||
size + weight contrast, not one-size-everywhere), and draws radii from a small
|
||||
consistent system. Higher = more crafted. Source and pixel are blended so
|
||||
neither can be gamed alone.
|
||||
|
||||
SOURCE intent (ctx.source_files)
|
||||
accent cohesion Count DISTINCT chromatic accent families referenced across
|
||||
views (Theme.mint/warning/error + any inline Color(hex:)),
|
||||
ignoring neutral surface/text tokens. A committed aesthetic
|
||||
is ONE dominant accent used sparingly (our app: mint
|
||||
#4DD9A6); a "timid rainbow" of many competing accents is
|
||||
slop. Reward few distinct accents AND a high dominance
|
||||
ratio for the leading one.
|
||||
type scale Detect a real scale: >=3 distinct deliberate sizes
|
||||
(Theme.mono(size:) / .system(size:) / semantic font roles)
|
||||
AND weight variation (>=2-3 weights). One size + one weight
|
||||
everywhere is the documented "no real hierarchy" tell.
|
||||
radius system Corner radii should come from a small consistent set.
|
||||
Reward few distinct radii; penalize many arbitrary ones
|
||||
(the opposite of "rounded-2xl on everything", but also the
|
||||
opposite of an unsystematic grab-bag).
|
||||
|
||||
PIXEL corroboration (ctx.content_pixels / content_mask)
|
||||
accent sparingness The accent (mint hue band) should be a SMALL fraction of
|
||||
content pixels - emphasis, not wallpaper. Reward ~1-12%
|
||||
coverage; penalize 0% (no focal accent at all) and >25%
|
||||
(accent overload, the accent stops reading as special).
|
||||
surface cohesion Reward a dominant, cohesive surface palette: most content
|
||||
pixels sit on the tokenized background/surface ramp rather
|
||||
than scattering into many unrelated colours.
|
||||
|
||||
Source grades the habits that keep the look intentional across future screens;
|
||||
pixel grades the look you can actually see. We blend both.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context, TOKENS, hex_to_rgb
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "styling"
|
||||
CATEGORY = "F"
|
||||
WEIGHT = 0.04
|
||||
|
||||
# Neutral Theme tokens (surfaces, borders, text). These are the canvas, not an
|
||||
# accent, so they never count toward the accent palette.
|
||||
_NEUTRAL_TOKENS = {
|
||||
"background", "surface", "surfaceElevated", "border",
|
||||
"textPrimary", "textSecondary", "textTertiary",
|
||||
}
|
||||
# Chromatic Theme tokens that DO read as accents. mintTint folds into mint (it is
|
||||
# the same hue at lower alpha, not a second accent).
|
||||
_ACCENT_TOKEN_FAMILY = {
|
||||
"mint": "mint", "mintTint": "mint",
|
||||
"warning": "warning", "error": "error",
|
||||
}
|
||||
|
||||
_THEME_TOKEN_RE = re.compile(r"Theme\.([A-Za-z]+)")
|
||||
_INLINE_HEX_RE = re.compile(r"Color\(hex:\s*0x([0-9A-Fa-f]+)\)")
|
||||
# Deliberate type steps: explicit point sizes + semantic SwiftUI font roles.
|
||||
_MONO_SIZE_RE = re.compile(r"Theme\.mono\(\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
_SYSTEM_SIZE_RE = re.compile(r"\.system\(\s*size:\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
_FONT_ROLE_RE = re.compile(
|
||||
r"\.font\(\.(largeTitle|title3|title2|title|headline|subheadline|"
|
||||
r"footnote|caption2|caption|callout|body)\b"
|
||||
)
|
||||
_WEIGHT_RE = re.compile(r"(?:weight:\s*\.|\.weight\(\.)([a-zA-Z]+)")
|
||||
_CORNER_RADIUS_RE = re.compile(r"cornerRadius:\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
|
||||
# Mint hue (~158 deg) defines the accent band; warning/error are functional
|
||||
# state colours, not the brand accent we measure for sparingness.
|
||||
_MINT_HUE = 158.0
|
||||
_HUE_TOL = 26.0
|
||||
|
||||
|
||||
def _base_hue(rgb: np.ndarray) -> float | None:
|
||||
"""Hue in degrees for an RGB triple, or None for a greyscale colour."""
|
||||
a = rgb / 255.0
|
||||
mx, mn = float(a.max()), float(a.min())
|
||||
d = mx - mn
|
||||
if d < 1e-9:
|
||||
return None
|
||||
r, g, b = a
|
||||
if mx == r:
|
||||
h = (b - g) / d
|
||||
elif mx == g:
|
||||
h = 2.0 + (r - b) / d
|
||||
else:
|
||||
h = 4.0 + (g - r) / d
|
||||
return (h / 6.0) % 1.0 * 360.0
|
||||
|
||||
|
||||
def _hsv(arr: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Vectorized HSV (hue degrees, sat 0..1, val 0..1) for an HxWx3 image."""
|
||||
a = arr / 255.0
|
||||
mx = a.max(axis=-1)
|
||||
mn = a.min(axis=-1)
|
||||
d = mx - mn
|
||||
v = mx
|
||||
s = np.where(mx > 1e-9, d / np.maximum(mx, 1e-9), 0.0)
|
||||
r, g, b = a[..., 0], a[..., 1], a[..., 2]
|
||||
rc = (mx - r) / np.maximum(d, 1e-9)
|
||||
gc = (mx - g) / np.maximum(d, 1e-9)
|
||||
bc = (mx - b) / np.maximum(d, 1e-9)
|
||||
h = np.where(mx == r, bc - gc, np.where(mx == g, 2.0 + rc - bc, 4.0 + gc - rc))
|
||||
h = np.where(d < 1e-9, 0.0, (h / 6.0) % 1.0 * 360.0)
|
||||
return h, s, v
|
||||
|
||||
|
||||
def _band(x: float, lo: float, hi: float, floor_at: float, ceil_at: float) -> float:
|
||||
"""100 inside [lo, hi]; linear ramp to 0 at floor_at (below) / ceil_at (above)."""
|
||||
if lo <= x <= hi:
|
||||
return 100.0
|
||||
if x < lo:
|
||||
return max(0.0, 100.0 * (x - floor_at) / (lo - floor_at)) if lo > floor_at else 0.0
|
||||
return max(0.0, 100.0 * (ceil_at - x) / (ceil_at - hi)) if ceil_at > hi else 0.0
|
||||
|
||||
|
||||
def _accent_palette(source_files: dict[str, str]) -> dict[str, int]:
|
||||
"""Usage count per distinct accent family referenced outside Theme.swift.
|
||||
|
||||
Chromatic Theme tokens collapse to a family (mintTint -> mint); inline
|
||||
Color(hex:) literals bucket by hue (15 deg buckets) so two near-identical
|
||||
custom colours are not double-counted as a "rainbow".
|
||||
"""
|
||||
usage: dict[str, int] = {}
|
||||
for path, text in source_files.items():
|
||||
if path.endswith("Theme.swift"):
|
||||
continue
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//"):
|
||||
continue
|
||||
for tok in _THEME_TOKEN_RE.findall(line):
|
||||
if tok in _NEUTRAL_TOKENS:
|
||||
continue
|
||||
family = _ACCENT_TOKEN_FAMILY.get(tok)
|
||||
if family is not None:
|
||||
usage[family] = usage.get(family, 0) + 1
|
||||
for hex_str in _INLINE_HEX_RE.findall(line):
|
||||
hue = _base_hue(hex_to_rgb(int(hex_str, 16)))
|
||||
if hue is None:
|
||||
continue # greyscale literal is neutral, not an accent
|
||||
key = f"hue{int(round(hue / 15.0) * 15) % 360}"
|
||||
usage[key] = usage.get(key, 0) + 1
|
||||
return usage
|
||||
|
||||
|
||||
def _type_scale(source_files: dict[str, str]) -> tuple[int, int, int]:
|
||||
"""(distinct deliberate sizes, distinct semantic roles, distinct weights)."""
|
||||
sizes: set[float] = set()
|
||||
roles: set[str] = set()
|
||||
weights: set[str] = set()
|
||||
for text in source_files.values():
|
||||
for m in _MONO_SIZE_RE.finditer(text):
|
||||
sizes.add(float(m.group(1)))
|
||||
for m in _SYSTEM_SIZE_RE.finditer(text):
|
||||
sizes.add(float(m.group(1)))
|
||||
for m in _FONT_ROLE_RE.finditer(text):
|
||||
roles.add(m.group(1))
|
||||
for m in _WEIGHT_RE.finditer(text):
|
||||
weights.add(m.group(1))
|
||||
return len(sizes), len(roles), len(weights)
|
||||
|
||||
|
||||
def _distinct_radii(source_files: dict[str, str]) -> int:
|
||||
radii: set[float] = set()
|
||||
for text in source_files.values():
|
||||
for m in _CORNER_RADIUS_RE.finditer(text):
|
||||
radii.add(float(m.group(1)))
|
||||
return len(radii)
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
mask = ctx.content_mask
|
||||
source_files = ctx.source_files
|
||||
have_pixels = mask is not None
|
||||
have_source = bool(source_files)
|
||||
|
||||
if not have_pixels and not have_source:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot and no source")
|
||||
|
||||
parts: list[float] = []
|
||||
weights: list[float] = []
|
||||
evidence: dict = {}
|
||||
|
||||
# --- SOURCE: accent cohesion + type scale + radius system ------------
|
||||
if have_source:
|
||||
palette = _accent_palette(source_files)
|
||||
distinct_accents = len(palette)
|
||||
total = sum(palette.values())
|
||||
dominance = (max(palette.values()) / total) if total else 0.0
|
||||
# 1-3 distinct accents reads as a committed palette; each extra competing
|
||||
# accent past 3 is a step toward a timid rainbow.
|
||||
spread_score = 100.0 if distinct_accents <= 3 else max(
|
||||
0.0, 100.0 - 22.0 * (distinct_accents - 3))
|
||||
# One accent doing >=50% of the work is a clear dominant choice.
|
||||
dominance_score = min(1.0, dominance / 0.5) * 100.0
|
||||
# No accent at all is timid; a single dominant accent is ideal.
|
||||
accent_src = 0.6 * spread_score + 0.4 * dominance_score
|
||||
|
||||
type_sizes, type_roles, type_weights = _type_scale(source_files)
|
||||
# Effective size tiers include semantic font roles (each maps to a real
|
||||
# size) so a view using .caption/.headline is not wrongly read as flat.
|
||||
size_tiers = type_sizes + type_roles
|
||||
# >=3 deliberate sizes -> full; one-size-everywhere -> 0.
|
||||
size_score = min(1.0, max(0.0, (size_tiers - 1) / 2.0)) * 100.0
|
||||
# >=3 weights -> full, 2 -> half, 1 (single weight everywhere) -> 0.
|
||||
weight_score = min(1.0, max(0.0, (type_weights - 1) / 2.0)) * 100.0
|
||||
type_intent = 0.6 * size_score + 0.4 * weight_score
|
||||
|
||||
distinct_radii = _distinct_radii(source_files)
|
||||
# <=4 radii is a tidy system; each extra arbitrary radius erodes it.
|
||||
radius_score = 100.0 if distinct_radii <= 4 else max(
|
||||
0.0, 100.0 - 14.0 * (distinct_radii - 4))
|
||||
|
||||
source_score = 0.4 * accent_src + 0.35 * type_intent + 0.25 * radius_score
|
||||
parts.append(source_score)
|
||||
weights.append(0.55)
|
||||
evidence["distinct_accents"] = int(distinct_accents)
|
||||
evidence["accent_dominance"] = round(dominance, 3)
|
||||
evidence["type_sizes"] = int(type_sizes)
|
||||
evidence["type_roles"] = int(type_roles)
|
||||
evidence["type_weights"] = int(type_weights)
|
||||
evidence["distinct_radii"] = int(distinct_radii)
|
||||
|
||||
# --- PIXEL: accent sparingness + surface cohesion --------------------
|
||||
if have_pixels:
|
||||
content = ctx.content_pixels
|
||||
h, s, v = _hsv(content)
|
||||
# Pixels in the mint hue band with enough saturation/value to be a real
|
||||
# accent (not background noise). Measured over non-background content.
|
||||
accent = (np.abs(h - _MINT_HUE) <= _HUE_TOL) & (s >= 0.18) & (v >= 0.12)
|
||||
nonbg = int(mask.sum())
|
||||
accent_frac = float(accent[mask].mean()) if nonbg > 0 else 0.0
|
||||
# Emphasis, not wallpaper: reward 1-12%; 0% = no focal accent (ramp from
|
||||
# 0), >25% = accent overload (ramp to 0 at 25%).
|
||||
accent_score = _band(accent_frac, 0.01, 0.12, 0.0, 0.25)
|
||||
|
||||
surf = np.stack([hex_to_rgb(TOKENS[k])
|
||||
for k in ("background", "surface", "surfaceElevated")])
|
||||
cm = content[mask]
|
||||
if len(cm) > 0:
|
||||
dmin = np.min(np.linalg.norm(cm[:, None, :] - surf[None, :, :], axis=2),
|
||||
axis=1)
|
||||
surface_frac = float((dmin < 30.0).mean())
|
||||
else:
|
||||
surface_frac = 0.0
|
||||
# A cohesive look keeps most content on the tokenized surface ramp;
|
||||
# ~70%+ reads as one dominant palette.
|
||||
surface_score = min(1.0, surface_frac / 0.7) * 100.0
|
||||
|
||||
pixel_score = 0.6 * accent_score + 0.4 * surface_score
|
||||
parts.append(pixel_score)
|
||||
weights.append(0.45)
|
||||
evidence["accent_pixel_frac"] = round(accent_frac, 4)
|
||||
evidence["surface_frac"] = round(surface_frac, 4)
|
||||
|
||||
wsum = sum(weights)
|
||||
value = sum(p * w for p, w in zip(parts, weights)) / wsum if wsum > 0 else 0.0
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence=evidence,
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""B. touch_targets - interactive controls >= 44x44pt with adequate spacing.
|
||||
|
||||
Apple's HIG asks for >= 44x44pt tap targets spaced so adjacent controls don't
|
||||
collide. This scorer measures that two ways and combines them:
|
||||
|
||||
1. PIXEL evidence (source of truth): detect compact, non-background blobs in
|
||||
the header band and the composer band of the rendered screenshot. Those
|
||||
blobs are the real tappable controls (send/interrupt button, the header
|
||||
"more" button, the status pill). Each blob's pixel size is converted to
|
||||
points via ctx.scale and graded against the 44pt minimum; bbox gaps are
|
||||
graded against the 8pt spacing minimum.
|
||||
|
||||
2. SOURCE evidence (corroboration): parse the SwiftUI source for explicit
|
||||
`.frame(width:height:)` / `.frame(width:)` / `.frame(height:)` modifiers
|
||||
applied to icon Buttons and flag any whose binding dimension is < 44pt.
|
||||
|
||||
The two are blended so the value tracks the real layout but is robustly
|
||||
hill-climbable: bumping a 40pt button to 44pt raises both the pixel and source
|
||||
components. Everything here is deterministic and pure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import TOKENS, hex_to_rgb, Context
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "touch_targets"
|
||||
CATEGORY = "B"
|
||||
WEIGHT = 0.1
|
||||
|
||||
# Apple HIG ergonomics constants.
|
||||
MIN_TARGET_PT = 44.0
|
||||
MIN_SPACING_PT = 8.0
|
||||
|
||||
# Vertical bands (fractions of the full screenshot height) where the chrome
|
||||
# controls live. The header sits just below the OS status bar; the composer
|
||||
# hugs the bottom above the home indicator. Bounds are intentionally generous.
|
||||
HEADER_BAND = (0.070, 0.145)
|
||||
COMPOSER_BAND = (0.875, 0.965)
|
||||
|
||||
# A "compact control" blob is neither a hairline glyph nor a full-width field.
|
||||
# In points: drop sub-12pt letter fragments and >80pt-wide regions (text field).
|
||||
MIN_BLOB_PT = 12.0
|
||||
MAX_BLOB_PT = 80.0
|
||||
|
||||
# Background-difference threshold, matched to Context.content_mask so a button
|
||||
# filled with Theme.surface/surfaceElevated still reads as foreground.
|
||||
BG_DELTA = 18.0
|
||||
|
||||
|
||||
def _blobs(mask: np.ndarray):
|
||||
"""4-connected components of a boolean mask via scanline run union-find.
|
||||
|
||||
Returns [(x0, y0, x1, y1, area)] in mask coordinates. Deterministic: the
|
||||
output is sorted by (y0, x0). Fast enough for the thin bands we scan.
|
||||
"""
|
||||
h, w = mask.shape
|
||||
runs: list[tuple[int, int, int]] = [] # (row, start_col, end_col_inclusive)
|
||||
row_runs: list[list[int]] = [] # per row: indices into `runs`
|
||||
for y in range(h):
|
||||
idx = np.flatnonzero(mask[y])
|
||||
these: list[int] = []
|
||||
if idx.size:
|
||||
breaks = np.flatnonzero(np.diff(idx) > 1)
|
||||
starts = np.concatenate(([0], breaks + 1))
|
||||
ends = np.concatenate((breaks, [idx.size - 1]))
|
||||
for s, e in zip(starts, ends):
|
||||
these.append(len(runs))
|
||||
runs.append((y, int(idx[s]), int(idx[e])))
|
||||
row_runs.append(these)
|
||||
|
||||
parent = list(range(len(runs)))
|
||||
|
||||
def find(a: int) -> int:
|
||||
while parent[a] != a:
|
||||
parent[a] = parent[parent[a]]
|
||||
a = parent[a]
|
||||
return a
|
||||
|
||||
def union(a: int, b: int) -> None:
|
||||
ra, rb = find(a), find(b)
|
||||
if ra != rb:
|
||||
parent[rb] = ra
|
||||
|
||||
for y in range(1, h):
|
||||
for ri in row_runs[y]:
|
||||
_, s, e = runs[ri]
|
||||
for pj in row_runs[y - 1]:
|
||||
_, ps, pe = runs[pj]
|
||||
if s <= pe and ps <= e: # column overlap -> vertically connected
|
||||
union(ri, pj)
|
||||
|
||||
boxes: dict[int, list[int]] = {}
|
||||
for ri, (y, s, e) in enumerate(runs):
|
||||
r = find(ri)
|
||||
if r not in boxes:
|
||||
boxes[r] = [s, y, e, y, e - s + 1]
|
||||
else:
|
||||
b = boxes[r]
|
||||
b[0] = min(b[0], s)
|
||||
b[1] = min(b[1], y)
|
||||
b[2] = max(b[2], e)
|
||||
b[3] = max(b[3], y)
|
||||
b[4] += e - s + 1
|
||||
out = [tuple(v) for v in boxes.values()]
|
||||
out.sort(key=lambda b: (b[1], b[0]))
|
||||
return out
|
||||
|
||||
|
||||
def _band_controls(mask: np.ndarray, band, scale: float):
|
||||
"""Detect compact control candidates in one full-image band.
|
||||
|
||||
Returns list of dicts with full-image pixel bbox + point size. y0/y1 are in
|
||||
full-image coordinates (band offset added) so spacing can be measured
|
||||
across bands.
|
||||
"""
|
||||
h = mask.shape[0]
|
||||
y0 = int(h * band[0])
|
||||
y1 = int(h * band[1])
|
||||
sub = mask[y0:y1]
|
||||
controls = []
|
||||
for (bx0, by0, bx1, by1, area) in _blobs(sub):
|
||||
w_pt = (bx1 - bx0 + 1) / scale
|
||||
h_pt = (by1 - by0 + 1) / scale
|
||||
min_pt = min(w_pt, h_pt)
|
||||
max_pt = max(w_pt, h_pt)
|
||||
# Keep compact, roughly button/pill-sized blobs; drop glyph fragments
|
||||
# and the full-width text field.
|
||||
if min_pt < MIN_BLOB_PT or max_pt > MAX_BLOB_PT:
|
||||
continue
|
||||
controls.append({
|
||||
"x0": bx0, "y0": by0 + y0, "x1": bx1, "y1": by1 + y0,
|
||||
"w_pt": round(w_pt, 1), "h_pt": round(h_pt, 1),
|
||||
"min_pt": round(min_pt, 1),
|
||||
})
|
||||
return controls
|
||||
|
||||
|
||||
def _rect_gap_pt(a: dict, b: dict, scale: float) -> float:
|
||||
"""Minimum edge-to-edge gap between two bboxes, in points (0 if touching)."""
|
||||
dx = max(a["x0"] - b["x1"], b["x0"] - a["x1"], 0)
|
||||
dy = max(a["y0"] - b["y1"], b["y0"] - a["y1"], 0)
|
||||
return float(np.hypot(dx, dy)) / scale
|
||||
|
||||
|
||||
_FRAME_WH = re.compile(r"\.frame\(\s*width:\s*(\d+)\s*,\s*height:\s*(\d+)")
|
||||
_FRAME_W = re.compile(r"\.frame\(\s*width:\s*(\d+)\s*\)")
|
||||
_FRAME_H = re.compile(r"\.frame\(\s*height:\s*(\d+)\s*\)")
|
||||
|
||||
|
||||
def _interactive_frame_dims(files: dict[str, str]) -> list[dict]:
|
||||
"""Find frame sizes applied to icon Buttons (Image(systemName:) controls).
|
||||
|
||||
A frame is "interactive" if, scanning a few lines up the modifier chain, we
|
||||
hit an `Image(systemName:` before a decorative `Circle(`/`Text(`. This keeps
|
||||
the 40x40 send/interrupt icons and rejects the 8x8 status dots that happen
|
||||
to live inside a Button row.
|
||||
"""
|
||||
out = []
|
||||
for path, text in sorted(files.items()):
|
||||
lines = text.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
m = _FRAME_WH.search(line)
|
||||
if m:
|
||||
dims = (int(m.group(1)), int(m.group(2)))
|
||||
else:
|
||||
mw = _FRAME_W.search(line)
|
||||
mh = _FRAME_H.search(line)
|
||||
if mw:
|
||||
dims = (int(mw.group(1)),)
|
||||
elif mh:
|
||||
dims = (int(mh.group(1)),)
|
||||
else:
|
||||
continue
|
||||
# Walk back up the chain to classify the leaf view.
|
||||
interactive = False
|
||||
for j in range(i, max(-1, i - 8), -1):
|
||||
up = lines[j]
|
||||
if "Image(systemName" in up:
|
||||
interactive = True
|
||||
break
|
||||
if "Circle(" in up or "Text(" in up or "Rectangle(" in up:
|
||||
break
|
||||
if not interactive:
|
||||
continue
|
||||
binding = min(dims) # the dimension that constrains the tap target
|
||||
out.append({"file": path, "line": i + 1,
|
||||
"dims_pt": list(dims), "binding_pt": binding})
|
||||
return out
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
scale = float(max(1, ctx.scale))
|
||||
components: list[tuple[float, float]] = [] # (score, weight)
|
||||
evidence: dict = {}
|
||||
|
||||
# --- pixel evidence ---------------------------------------------------
|
||||
arr = ctx.pixels
|
||||
pixel_controls: list[dict] = []
|
||||
if arr is not None:
|
||||
bg = hex_to_rgb(TOKENS["background"])
|
||||
mask = np.linalg.norm(arr - bg, axis=2) > BG_DELTA
|
||||
pixel_controls = (_band_controls(mask, HEADER_BAND, scale)
|
||||
+ _band_controls(mask, COMPOSER_BAND, scale))
|
||||
|
||||
if pixel_controls:
|
||||
# Graded size: full credit only at >= 44pt, linear below (climbable).
|
||||
size_quality = float(np.mean([min(1.0, c["min_pt"] / MIN_TARGET_PT)
|
||||
for c in pixel_controls]))
|
||||
n_ok = sum(1 for c in pixel_controls if c["min_pt"] >= MIN_TARGET_PT)
|
||||
compliance = n_ok / len(pixel_controls)
|
||||
pixel_score = 100.0 * (0.6 * size_quality + 0.4 * compliance)
|
||||
components.append((pixel_score, 0.45))
|
||||
evidence["pixel_controls"] = len(pixel_controls)
|
||||
evidence["pixel_controls_ge_44"] = n_ok
|
||||
evidence["pixel_min_pt"] = round(min(c["min_pt"] for c in pixel_controls), 1)
|
||||
evidence["pixel_size_quality"] = round(size_quality, 3)
|
||||
evidence["pixel_sizes_pt"] = [[c["w_pt"], c["h_pt"]] for c in pixel_controls]
|
||||
|
||||
# Spacing: only adjacent pairs (gap below one target width) can collide.
|
||||
violations = 0
|
||||
pairs = 0
|
||||
worst = None
|
||||
n = len(pixel_controls)
|
||||
for i in range(n):
|
||||
for k in range(i + 1, n):
|
||||
gap = _rect_gap_pt(pixel_controls[i], pixel_controls[k], scale)
|
||||
if gap < MIN_TARGET_PT: # neighbours, not far-apart bands
|
||||
pairs += 1
|
||||
worst = gap if worst is None else min(worst, gap)
|
||||
if 0.0 < gap < MIN_SPACING_PT:
|
||||
violations += 1
|
||||
if pairs:
|
||||
spacing_score = 100.0 * (1.0 - violations / pairs)
|
||||
components.append((spacing_score, 0.25))
|
||||
evidence["adjacent_pairs"] = pairs
|
||||
evidence["spacing_violations"] = violations
|
||||
evidence["min_gap_pt"] = round(worst, 1) if worst is not None else None
|
||||
|
||||
# --- source evidence --------------------------------------------------
|
||||
frames = _interactive_frame_dims(ctx.source_files)
|
||||
if frames:
|
||||
bindings = [f["binding_pt"] for f in frames]
|
||||
src_quality = float(np.mean([min(1.0, b / MIN_TARGET_PT) for b in bindings]))
|
||||
n_ok = sum(1 for b in bindings if b >= MIN_TARGET_PT)
|
||||
compliance = n_ok / len(bindings)
|
||||
src_score = 100.0 * (0.6 * src_quality + 0.4 * compliance)
|
||||
components.append((src_score, 0.30))
|
||||
evidence["source_icon_frames"] = len(frames)
|
||||
evidence["source_frames_ge_44"] = n_ok
|
||||
evidence["source_undersized"] = [
|
||||
{"file": f["file"], "line": f["line"], "binding_pt": f["binding_pt"]}
|
||||
for f in frames if f["binding_pt"] < MIN_TARGET_PT
|
||||
]
|
||||
|
||||
if not components:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT,
|
||||
"no detectable controls in pixels or source")
|
||||
|
||||
wsum = sum(w for _, w in components)
|
||||
value = sum(s * w for s, w in components) / wsum
|
||||
value = max(0.0, min(100.0, value))
|
||||
evidence["min_target_pt"] = MIN_TARGET_PT
|
||||
evidence["min_spacing_pt"] = MIN_SPACING_PT
|
||||
|
||||
return CategoryScore(name=NAME, category=CATEGORY, weight=WEIGHT,
|
||||
value=round(value, 2), evidence=evidence)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Scorer: C. visual_hierarchy.
|
||||
|
||||
Grades whether the rendered UI has ONE clear focal point. A good chat screen
|
||||
draws the eye to a single primary thing (the mint send button, the user
|
||||
bubble, the live badge) instead of scattering equally-loud elements all over
|
||||
the canvas. We build a per-pixel salience map (distance from the app
|
||||
background, with a strong boost for accent/mint pixels since those are the
|
||||
primary actions), pool it into a coarse block grid, then ask two questions:
|
||||
|
||||
focal_count how many distinct strong salient regions compete?
|
||||
salience_concentration what share of total salience lives in the single
|
||||
strongest region?
|
||||
|
||||
Reward exactly one dominant region that holds a large share of salience;
|
||||
penalize either nothing salient (no focal point) or many competing regions
|
||||
(visual noise spread evenly).
|
||||
|
||||
Scenario-aware: the `empty` scenario is a deliberate empty state. With almost
|
||||
no content, total salience is tiny, so demanding that the focal region hold
|
||||
half of ALL salience is unfair: the OS status bar and header glyphs alone
|
||||
dilute it. In empty mode a single focal affordance is the whole job, so the
|
||||
concentration target is relaxed and the single-focal-point term dominates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from reward.context import Context, TOKENS, hex_to_rgb
|
||||
from reward.types import CategoryScore, make_unavailable
|
||||
|
||||
NAME = "visual_hierarchy"
|
||||
CATEGORY = "C"
|
||||
WEIGHT = 0.04
|
||||
|
||||
# Scenarios that render a deliberate empty state (no transcript yet).
|
||||
EMPTY_SCENARIOS = {"empty"}
|
||||
# Concentration targets: a busy transcript should have a clearly dominant
|
||||
# region (>=50% of salience); an empty state only needs a modestly dominant
|
||||
# affordance (>=30%) because chrome glyphs dilute the tiny salience budget.
|
||||
_CONC_TARGET = 0.5
|
||||
_CONC_TARGET_EMPTY = 0.3
|
||||
|
||||
# Max possible RGB euclidean distance, used to normalize contrast salience.
|
||||
_MAX_RGB_DIST = math.sqrt(3 * 255.0 * 255.0)
|
||||
# Accent pixels within this RGB radius of mint are treated as primary-action
|
||||
# salience and weighted far above plain text contrast.
|
||||
_ACCENT_RADIUS = 120.0
|
||||
_ACCENT_WEIGHT = 3.0
|
||||
# Coarse pooling block size in points; large enough that neighbouring accent
|
||||
# glyphs merge into one focal region instead of fragmenting.
|
||||
_BLOCK_PT = 48
|
||||
|
||||
|
||||
def _connected_components(grid: np.ndarray) -> list[list[tuple[int, int]]]:
|
||||
"""4-connectivity flood fill over a 2D boolean block grid (numpy-only)."""
|
||||
rows, cols = grid.shape
|
||||
seen = np.zeros_like(grid, dtype=bool)
|
||||
comps = []
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if not grid[r, c] or seen[r, c]:
|
||||
continue
|
||||
stack = [(r, c)]
|
||||
seen[r, c] = True
|
||||
cells = []
|
||||
while stack:
|
||||
y, x = stack.pop()
|
||||
cells.append((y, x))
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
ny, nx = y + dy, x + dx
|
||||
if 0 <= ny < rows and 0 <= nx < cols and grid[ny, nx] and not seen[ny, nx]:
|
||||
seen[ny, nx] = True
|
||||
stack.append((ny, nx))
|
||||
comps.append(cells)
|
||||
return comps
|
||||
|
||||
|
||||
def score(ctx: Context) -> CategoryScore:
|
||||
arr = ctx.content_pixels
|
||||
if arr is None:
|
||||
return make_unavailable(NAME, CATEGORY, WEIGHT, "no screenshot")
|
||||
|
||||
ch, cw, _ = arr.shape
|
||||
bg = hex_to_rgb(TOKENS["background"])
|
||||
mint = hex_to_rgb(TOKENS["mint"])
|
||||
|
||||
# Per-pixel salience: contrast against the background (0..~1) plus a heavy
|
||||
# boost for pixels near the mint accent (the primary-action colour).
|
||||
dist_bg = np.linalg.norm(arr - bg, axis=2) / _MAX_RGB_DIST
|
||||
dist_mint = np.linalg.norm(arr - mint, axis=2)
|
||||
accent = np.clip(1.0 - dist_mint / _ACCENT_RADIUS, 0.0, 1.0)
|
||||
salience = dist_bg + _ACCENT_WEIGHT * accent
|
||||
|
||||
# Pool into a coarse block grid and sum salience per block.
|
||||
block = max(1, int(_BLOCK_PT * ctx.scale))
|
||||
rows = max(1, ch // block)
|
||||
cols = max(1, cw // block)
|
||||
blocks = np.zeros((rows, cols), dtype=np.float64)
|
||||
for r in range(rows):
|
||||
y0, y1 = r * block, (r + 1) * block if r < rows - 1 else ch
|
||||
for c in range(cols):
|
||||
x0, x1 = c * block, (c + 1) * block if c < cols - 1 else cw
|
||||
blocks[r, c] = salience[y0:y1, x0:x1].sum()
|
||||
|
||||
total = float(blocks.sum())
|
||||
peak = float(blocks.max())
|
||||
if total <= 1e-9 or peak <= 1e-9:
|
||||
# Nothing stands out anywhere: there is no focal point to grade.
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=20.0,
|
||||
evidence={"focal_count": 0, "salience_concentration": 0.0},
|
||||
)
|
||||
|
||||
# Focal blocks are those at least half as loud as the loudest block; group
|
||||
# adjacent ones into regions so a single button isn't counted twice.
|
||||
focal = blocks >= 0.5 * peak
|
||||
comps = _connected_components(focal)
|
||||
focal_count = len(comps)
|
||||
|
||||
strongest = max((blocks[tuple(np.array(cells).T.tolist())].sum() for cells in comps),
|
||||
default=0.0)
|
||||
concentration = strongest / total
|
||||
|
||||
# One region is ideal; each extra competing region decays the reward.
|
||||
focal_score = 100.0 * math.exp(-0.35 * (focal_count - 1)) if focal_count >= 1 else 0.0
|
||||
is_empty = ctx.scenario in EMPTY_SCENARIOS
|
||||
conc_target = _CONC_TARGET_EMPTY if is_empty else _CONC_TARGET
|
||||
conc_score = 100.0 * min(1.0, concentration / conc_target)
|
||||
|
||||
if is_empty:
|
||||
# Empty state: "is there ONE obvious thing to do?" dominates.
|
||||
value = 0.7 * focal_score + 0.3 * conc_score
|
||||
else:
|
||||
value = 0.55 * focal_score + 0.45 * conc_score
|
||||
value = max(0.0, min(100.0, value))
|
||||
|
||||
return CategoryScore(
|
||||
name=NAME, category=CATEGORY, weight=WEIGHT, value=round(value, 2),
|
||||
evidence={
|
||||
"mode": "empty_state" if is_empty else "transcript",
|
||||
"focal_count": int(focal_count),
|
||||
"salience_concentration": round(float(concentration), 4),
|
||||
"concentration_target": conc_target,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Determinism + contract checks for reward scorers.
|
||||
|
||||
Every scorer must be a pure function: same Context in -> same CategoryScore out,
|
||||
and must declare the contract attributes. This guards the reward signal from
|
||||
flaky scorers that would make the hill un-climbable.
|
||||
|
||||
Run: python3 -m reward.test_determinism
|
||||
Exit non-zero on any failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
from reward.aggregate import discover_scorers # noqa: E402
|
||||
from reward.context import Context # noqa: E402
|
||||
|
||||
IOS = HERE.parent.parent
|
||||
SOURCE_ROOT = str(IOS / "Sources" / "JCodeMobile")
|
||||
|
||||
|
||||
def find_a_screenshot() -> str | None:
|
||||
import glob
|
||||
import os
|
||||
candidates = []
|
||||
for base in (os.environ.get("TMPDIR", "/tmp"), "/tmp"):
|
||||
candidates += glob.glob(os.path.join(base, "jcode-ui-matrix", "*.png"))
|
||||
candidates += glob.glob(os.path.join(base, "jcode_ios_*.png"))
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def main():
|
||||
scorers = discover_scorers()
|
||||
if not scorers:
|
||||
print("FAIL: no scorers discovered", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
shot = find_a_screenshot()
|
||||
failures = []
|
||||
|
||||
# Weights must sum to 1.0 (spec invariant), so the reward stays comparable
|
||||
# across revisions of the framework.
|
||||
wsum = sum(getattr(m, "WEIGHT", 0.0) for m in scorers)
|
||||
if abs(wsum - 1.0) > 1e-6:
|
||||
failures.append(f"scorer WEIGHTs sum to {wsum:.6f}, expected 1.0")
|
||||
|
||||
for mod in scorers:
|
||||
# contract
|
||||
for attr in ("NAME", "CATEGORY", "WEIGHT", "score"):
|
||||
if not hasattr(mod, attr):
|
||||
failures.append(f"{mod.__name__}: missing {attr}")
|
||||
if not (0.0 <= getattr(mod, "WEIGHT", -1) <= 1.0):
|
||||
failures.append(f"{mod.__name__}: WEIGHT out of [0,1]")
|
||||
|
||||
# determinism: run twice per scenario, compare. "empty" exercises the
|
||||
# scenario-aware branches (empty-state grading) as well.
|
||||
for scenario in ("short", "empty"):
|
||||
try:
|
||||
a = mod.score(Context(screenshot=shot, device="iPhone 17",
|
||||
scenario=scenario, scale=3,
|
||||
source_root=SOURCE_ROOT))
|
||||
b = mod.score(Context(screenshot=shot, device="iPhone 17",
|
||||
scenario=scenario, scale=3,
|
||||
source_root=SOURCE_ROOT))
|
||||
except Exception as e:
|
||||
failures.append(f"{mod.NAME} [{scenario}]: raised {e!r}")
|
||||
continue
|
||||
if a.value != b.value or a.available != b.available:
|
||||
failures.append(f"{mod.NAME} [{scenario}]: non-deterministic "
|
||||
f"({a.value}/{a.available} vs {b.value}/{b.available})")
|
||||
if a.available and not (0.0 <= a.value <= 100.0):
|
||||
failures.append(f"{mod.NAME} [{scenario}]: value {a.value} out of [0,100]")
|
||||
|
||||
print(f"checked {len(scorers)} scorers; "
|
||||
f"{'OK' if not failures else str(len(failures)) + ' FAILURES'}")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Shared types for the jcode iOS UX reward framework.
|
||||
|
||||
These are the only data structures scorers and the aggregator share. Keep this
|
||||
module tiny and dependency-light so every scorer can import it freely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class CategoryScore:
|
||||
"""One scorer's verdict for one matrix cell.
|
||||
|
||||
value: 0..100, higher is better.
|
||||
evidence: raw measurements behind the value, for debugging + regression
|
||||
diffs. Keep keys stable.
|
||||
available: False means "this scorer could not measure here" (e.g. perf with
|
||||
no Instruments). The aggregator drops unavailable scores and
|
||||
renormalizes weights so missing data never tanks the reward.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: str
|
||||
weight: float
|
||||
value: float = 0.0
|
||||
evidence: dict[str, Any] = field(default_factory=dict)
|
||||
available: bool = True
|
||||
|
||||
def clamped(self) -> float:
|
||||
return max(0.0, min(100.0, float(self.value)))
|
||||
|
||||
|
||||
def make_unavailable(name: str, category: str, weight: float, reason: str) -> "CategoryScore":
|
||||
return CategoryScore(
|
||||
name=name, category=category, weight=weight,
|
||||
value=0.0, evidence={"reason": reason}, available=False,
|
||||
)
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# End-to-end iOS harness: builds the app, runs the deterministic mock gateway,
|
||||
# boots a simulator, seeds a paired-server credential, launches the app, and
|
||||
# captures a screenshot proving the live connection + transcript render.
|
||||
#
|
||||
# This gives agents a verifiable, repeatable target to develop the client
|
||||
# against without an LLM, network, or manual device steps.
|
||||
#
|
||||
# Usage:
|
||||
# ./TestHarness/run_e2e.sh [--device "iPhone 17"] [--push-demo]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # ios/
|
||||
HARNESS="TestHarness"
|
||||
DEVICE="iPhone 17"
|
||||
PUSH_DEMO=""
|
||||
BUNDLE_ID="com.jcode.mobile"
|
||||
PORT=7643
|
||||
SHOT_DIR="${TMPDIR:-/tmp}/jcode-ios-e2e"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--device) DEVICE="$2"; shift 2 ;;
|
||||
--push-demo) PUSH_DEMO="--push-demo"; shift ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$SHOT_DIR"
|
||||
|
||||
log() { printf '\033[36m[e2e]\033[0m %s\n' "$*"; }
|
||||
|
||||
cleanup() {
|
||||
[[ -n "${GW_PID:-}" ]] && kill "$GW_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 1. Swift unit tests (headless behavior layer).
|
||||
log "swift test"
|
||||
swift test 2>&1 | tail -1
|
||||
|
||||
# 2. Build the app for the simulator.
|
||||
log "xcodegen + xcodebuild ($DEVICE)"
|
||||
xcodegen generate >/dev/null
|
||||
xcodebuild build \
|
||||
-project JCodeMobile.xcodeproj \
|
||||
-scheme JCodeMobile \
|
||||
-destination "platform=iOS Simulator,name=$DEVICE" \
|
||||
-derivedDataPath .build-ios >/dev/null
|
||||
APP=".build-ios/Build/Products/Debug-iphonesimulator/JCodeMobile.app"
|
||||
|
||||
# 3. Start the deterministic mock gateway.
|
||||
log "starting mock gateway on :$PORT $PUSH_DEMO"
|
||||
pkill -f mock_gateway.py 2>/dev/null || true
|
||||
sleep 0.5
|
||||
python3 "$HARNESS/mock_gateway.py" --port "$PORT" --host 127.0.0.1 $PUSH_DEMO \
|
||||
>"$SHOT_DIR/mockgw.log" 2>&1 &
|
||||
GW_PID=$!
|
||||
sleep 1.5
|
||||
|
||||
# 4. Protocol smoke test (asserts full message/tool/markdown sequence).
|
||||
log "protocol smoke test"
|
||||
python3 "$HARNESS/protocol_smoke_test.py" --port "$PORT" | tail -1
|
||||
|
||||
# 5. Boot the simulator (idempotent).
|
||||
log "booting simulator: $DEVICE"
|
||||
xcrun simctl boot "$DEVICE" 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# 6. Install fresh + seed a paired-server credential so the app auto-connects.
|
||||
log "installing app + seeding credential"
|
||||
xcrun simctl uninstall "$DEVICE" "$BUNDLE_ID" 2>/dev/null || true
|
||||
xcrun simctl install "$DEVICE" "$APP"
|
||||
CONTAINER="$(xcrun simctl get_app_container "$DEVICE" "$BUNDLE_ID" data)"
|
||||
APPSUP="$CONTAINER/Library/Application Support"
|
||||
mkdir -p "$APPSUP"
|
||||
printf '%s\n' \
|
||||
'[{"host":"127.0.0.1","port":7643,"token":"mocktoken0123456789abcdef","serverName":"mock-jcode","serverVersion":"mock-0.32.0","pairedAt":770000000}]' \
|
||||
> "$APPSUP/jcode-servers.json"
|
||||
|
||||
# 7. Launch + screenshot.
|
||||
log "launching app"
|
||||
xcrun simctl launch "$DEVICE" "$BUNDLE_ID" >/dev/null
|
||||
sleep 6
|
||||
SHOT="$SHOT_DIR/chat.png"
|
||||
xcrun simctl io "$DEVICE" screenshot "$SHOT" >/dev/null 2>&1
|
||||
log "screenshot: $SHOT"
|
||||
log "gateway log: $SHOT_DIR/mockgw.log"
|
||||
log "done"
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Design-system discipline linter for the jcode iOS SwiftUI sources.
|
||||
|
||||
Screenshots grade the *output*; this grades the *source*. A consistent UI comes
|
||||
from routing every color, font, and spacing value through the design tokens in
|
||||
Theme.swift instead of sprinkling magic numbers. This linter scans the SwiftUI
|
||||
views and reports, per file and overall, a 0-100 discipline score plus the
|
||||
exact offending lines so regressions are actionable.
|
||||
|
||||
Checks (each contributes to the score):
|
||||
raw_color Color(red:..) / Color(.sRGB..) / Color(white:..) literals and
|
||||
.opacity() on non-token colors that should be Theme tokens.
|
||||
raw_font .font(.system(size:..)) literals instead of Theme.mono(..).
|
||||
magic_radius cornerRadius: <literal> values not drawn from a small scale.
|
||||
magic_pad .padding(<literal>) values off the 4pt spacing grid.
|
||||
long_view view files / body blocks past a size budget (split them).
|
||||
|
||||
Usage:
|
||||
python3 ui_lint.py [--root Sources/JCodeMobile] [--json] [--max-issues N]
|
||||
Exit code is non-zero when the overall score drops below --min (default 0,
|
||||
i.e. report-only); raise --min in CI once the baseline is cleaned up.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
|
||||
# Allowed spacing values (4pt grid + a few common insets). Bypass with Theme.
|
||||
SPACING_GRID = {0, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 56, 64}
|
||||
RADIUS_SCALE = {0, 8, 10, 12, 14, 16, 20, 999} # 999 ~ Capsule-ish; allow round
|
||||
|
||||
RAW_COLOR_RE = re.compile(
|
||||
r"Color\(\s*(red|white|\.sRGB|hue|displayP3)", re.IGNORECASE)
|
||||
RAW_HEX_IN_VIEW_RE = re.compile(r"Color\(hex:")
|
||||
SYSTEM_FONT_RE = re.compile(r"\.system\(\s*size:")
|
||||
CORNER_RADIUS_RE = re.compile(r"cornerRadius:\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
PADDING_RE = re.compile(r"\.padding\(\s*([0-9]+(?:\.[0-9]+)?)\s*\)")
|
||||
PADDING_EDGE_RE = re.compile(r"\.padding\(\s*\.[a-z]+,\s*([0-9]+(?:\.[0-9]+)?)\s*\)")
|
||||
|
||||
# Penalty weight per issue category (points off per occurrence, capped).
|
||||
WEIGHTS = {
|
||||
"raw_color": 6,
|
||||
"raw_font": 4,
|
||||
"magic_radius": 2,
|
||||
"magic_pad": 1,
|
||||
"long_view": 5,
|
||||
}
|
||||
BODY_LINE_BUDGET = 220 # a single view file beyond this likely needs splitting
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
file: str
|
||||
line: int
|
||||
kind: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
score: float
|
||||
issues: list = field(default_factory=list)
|
||||
per_file: dict = field(default_factory=dict)
|
||||
counts: dict = field(default_factory=dict)
|
||||
|
||||
def render(self, max_issues=40):
|
||||
out = ["Design-system discipline", "=" * 40]
|
||||
out.append(f" score {self.score:5.1f}/100 ({sum(self.counts.values())} issues)")
|
||||
out.append(" by category:")
|
||||
for k in WEIGHTS:
|
||||
out.append(f" {k:14} {self.counts.get(k, 0)}")
|
||||
out.append(" by file:")
|
||||
for f, s in sorted(self.per_file.items(), key=lambda kv: kv[1]):
|
||||
out.append(f" {s:5.1f} {f}")
|
||||
if self.issues:
|
||||
out.append("-" * 40)
|
||||
for i in self.issues[:max_issues]:
|
||||
out.append(f" {i.file}:{i.line} [{i.kind}] {i.text.strip()[:70]}")
|
||||
if len(self.issues) > max_issues:
|
||||
out.append(f" ... +{len(self.issues) - max_issues} more")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def is_token_file(path: Path) -> bool:
|
||||
return path.name == "Theme.swift"
|
||||
|
||||
|
||||
def lint_file(path: Path):
|
||||
issues = []
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
token_file = is_token_file(path)
|
||||
|
||||
for n, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//"):
|
||||
continue
|
||||
|
||||
if not token_file:
|
||||
if RAW_COLOR_RE.search(line) or RAW_HEX_IN_VIEW_RE.search(line):
|
||||
issues.append(Issue(path.name, n, "raw_color", line))
|
||||
if SYSTEM_FONT_RE.search(line) and "Theme" not in line:
|
||||
issues.append(Issue(path.name, n, "raw_font", line))
|
||||
|
||||
m = CORNER_RADIUS_RE.search(line)
|
||||
if m and float(m.group(1)) not in RADIUS_SCALE and not token_file:
|
||||
issues.append(Issue(path.name, n, "magic_radius", line))
|
||||
|
||||
for rx in (PADDING_RE, PADDING_EDGE_RE):
|
||||
pm = rx.search(line)
|
||||
if pm:
|
||||
val = float(pm.group(1))
|
||||
if val not in SPACING_GRID:
|
||||
issues.append(Issue(path.name, n, "magic_pad", line))
|
||||
|
||||
if not token_file and len(lines) > BODY_LINE_BUDGET:
|
||||
issues.append(
|
||||
Issue(path.name, len(lines), "long_view",
|
||||
f"{len(lines)} lines > {BODY_LINE_BUDGET} budget"))
|
||||
return issues, len(lines)
|
||||
|
||||
|
||||
def file_score(issues):
|
||||
penalty = sum(WEIGHTS[i.kind] for i in issues)
|
||||
return max(0.0, 100.0 - penalty)
|
||||
|
||||
|
||||
def lint_root(root: Path):
|
||||
issues = []
|
||||
per_file = {}
|
||||
files = sorted(root.rglob("*.swift"))
|
||||
for f in files:
|
||||
fi, _ = lint_file(f)
|
||||
issues.extend(fi)
|
||||
per_file[str(f.relative_to(root))] = round(file_score(fi), 1)
|
||||
|
||||
counts = {}
|
||||
for i in issues:
|
||||
counts[i.kind] = counts.get(i.kind, 0) + 1
|
||||
|
||||
# Overall: penalty-weighted, normalized by file count so adding clean files
|
||||
# doesn't dilute the signal; clamp to [0,100].
|
||||
total_penalty = sum(WEIGHTS[i.kind] for i in issues)
|
||||
denom = max(1, len(files))
|
||||
score = max(0.0, 100.0 - total_penalty / denom)
|
||||
return Report(score=round(score, 1), issues=issues,
|
||||
per_file=per_file, counts=counts)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
default_root = Path(__file__).resolve().parent.parent / "Sources" / "JCodeMobile"
|
||||
ap.add_argument("--root", default=str(default_root))
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--max-issues", type=int, default=40)
|
||||
ap.add_argument("--min", type=float, default=0.0,
|
||||
help="fail (exit 1) if score < this")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = Path(args.root)
|
||||
if not root.exists():
|
||||
print(f"no such root: {root}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
report = lint_root(root)
|
||||
if args.json:
|
||||
print(json.dumps({
|
||||
"score": report.score,
|
||||
"counts": report.counts,
|
||||
"per_file": report.per_file,
|
||||
"issues": [asdict(i) for i in report.issues],
|
||||
}, indent=2))
|
||||
else:
|
||||
print(report.render(args.max_issues))
|
||||
|
||||
sys.exit(1 if report.score < args.min else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Layout efficiency matrix for the jcode iOS app.
|
||||
|
||||
A single screenshot only measures one content state. Real UI quality means the
|
||||
layout stays efficient across the *range* of content (empty, short, one tool,
|
||||
long thread, code-heavy) and devices. This harness:
|
||||
|
||||
1. builds the app once,
|
||||
2. for each (scenario, device) cell: starts the mock gateway pre-seeded with
|
||||
that scenario, seeds a paired credential, launches, screenshots, and
|
||||
scores the screenshot with ui_metrics.py,
|
||||
3. prints an aggregate table + a single mean overall score.
|
||||
|
||||
That mean is the hill to climb: a layout change is an improvement only if it
|
||||
raises the mean across the whole matrix, not just one lucky screen.
|
||||
|
||||
Beyond pixels, each cell also records best-effort *runtime* metrics
|
||||
(cold_launch_ms, first_frame_ms) measured around `xcrun simctl launch`, in the
|
||||
schema reward/scorers/perf.py consumes. When measurement fails the cell simply
|
||||
omits the runtime dict and the reward degrades gracefully to "unavailable".
|
||||
|
||||
The matrix axes are device x Dynamic Type size x scenario: by default a large
|
||||
phone and an SE-class small phone, plus an accessibility text-size variant on
|
||||
the primary device (via `simctl ui ... content_size`), so layout robustness is
|
||||
measured against real size pressure, not just one happy path.
|
||||
|
||||
Usage:
|
||||
python3 ui_matrix.py [--devices "iPhone 17,iPhone SE (3rd generation)"] \
|
||||
[--scenarios empty,short,tool,long,code] \
|
||||
[--a11y-size accessibility-large] [--no-perf] [--out DIR] [--json]
|
||||
python3 ui_matrix.py --baseline-json before.json --candidate-json after.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
IOS = HERE.parent
|
||||
BUNDLE = "com.jcode.mobile"
|
||||
APP = IOS / ".build-ios/Build/Products/Debug-iphonesimulator/JCodeMobile.app"
|
||||
PORT = 7643
|
||||
TOKEN = "mocktoken0123456789abcdef"
|
||||
CRED = ('[{"host":"127.0.0.1","port":7643,"token":"%s","serverName":'
|
||||
'"mock-jcode","serverVersion":"mock-0.32.0","pairedAt":770000000}]' % TOKEN)
|
||||
|
||||
# Default matrix axes. The SE-class device exercises the small-screen end;
|
||||
# the a11y content size exercises Dynamic Type pressure on the primary device.
|
||||
DEFAULT_DEVICES = "iPhone 17,iPhone SE (3rd generation)"
|
||||
DEFAULT_CONTENT_SIZE = "large" # iOS default Dynamic Type category
|
||||
DEFAULT_A11Y_SIZE = "accessibility-large"
|
||||
|
||||
# App background token (mirrors Theme.swift) used for first-frame detection.
|
||||
APP_BG = (0x0F, 0x0F, 0x14)
|
||||
# A frame "is the app" once this fraction of pixels sits near the background.
|
||||
FIRST_FRAME_BG_FRAC = 0.30
|
||||
# Give the app this long (total, from launch) before giving up on first frame.
|
||||
FIRST_FRAME_TIMEOUT_S = 12.0
|
||||
# Keep total post-launch settle constant so screenshots stay comparable.
|
||||
SETTLE_TOTAL_S = 5.0
|
||||
|
||||
sys.path.insert(0, str(HERE))
|
||||
import ui_metrics # noqa: E402
|
||||
|
||||
|
||||
def sh(cmd, **kw):
|
||||
return subprocess.run(cmd, shell=True, text=True, capture_output=True, **kw)
|
||||
|
||||
|
||||
def build_app(device):
|
||||
sh("xcodegen generate", cwd=IOS)
|
||||
r = sh(
|
||||
"xcodebuild build -project JCodeMobile.xcodeproj -scheme JCodeMobile "
|
||||
f"-destination 'platform=iOS Simulator,name={device}' "
|
||||
"-derivedDataPath .build-ios",
|
||||
cwd=IOS,
|
||||
)
|
||||
if "BUILD SUCCEEDED" not in r.stdout:
|
||||
print(r.stdout[-2000:], file=sys.stderr)
|
||||
raise SystemExit("build failed")
|
||||
|
||||
|
||||
def boot(device):
|
||||
sh(f'xcrun simctl boot "{device}"')
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
def set_content_size(device, size):
|
||||
"""Set the simulator's Dynamic Type category. Returns True on success."""
|
||||
r = sh(f'xcrun simctl ui "{device}" content_size {size}')
|
||||
ok = r.returncode == 0
|
||||
if not ok:
|
||||
print(f"warning: content_size {size} on {device} failed: "
|
||||
f"{(r.stderr or r.stdout).strip()}", file=sys.stderr)
|
||||
return ok
|
||||
|
||||
|
||||
def start_gateway(scenario):
|
||||
sh("pkill -f mock_gateway.py")
|
||||
time.sleep(0.4)
|
||||
# background process; inherit no pipes so it stays alive
|
||||
return subprocess.Popen(
|
||||
[sys.executable, str(HERE / "mock_gateway.py"),
|
||||
"--port", str(PORT), "--host", "127.0.0.1", "--scenario", scenario],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def _app_bg_frac(png_path):
|
||||
"""Fraction of pixels close to the app background color (0..1)."""
|
||||
try:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
arr = np.asarray(Image.open(png_path).convert("RGB"), dtype=np.float64)
|
||||
bg = np.array(APP_BG, dtype=np.float64)
|
||||
dist = np.sqrt(((arr - bg) ** 2).sum(axis=2))
|
||||
return float((dist < 28.0).mean())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def measure_first_frame(device, t_launch):
|
||||
"""Poll screenshots until the app's background dominates the screen.
|
||||
|
||||
Returns elapsed ms from launch, or None. Includes screenshot-capture
|
||||
latency, so treat it as a harness-relative (but consistent) measure.
|
||||
"""
|
||||
deadline = t_launch + FIRST_FRAME_TIMEOUT_S
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf:
|
||||
probe = tf.name
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
r = sh(f'xcrun simctl io "{device}" screenshot "{probe}"')
|
||||
if r.returncode == 0 and _app_bg_frac(probe) >= FIRST_FRAME_BG_FRAC:
|
||||
return (time.monotonic() - t_launch) * 1000.0
|
||||
time.sleep(0.15)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(probe)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def seed_and_launch(device, collect_perf=True):
|
||||
"""Install fresh, seed the credential, cold-launch, and (best-effort) time it.
|
||||
|
||||
Returns a runtime metrics dict in the reward/scorers/perf.py schema, or {}
|
||||
when nothing could be measured. The uninstall/install makes every launch a
|
||||
true cold launch, so the timing is comparable across cells.
|
||||
"""
|
||||
sh(f'xcrun simctl uninstall "{device}" {BUNDLE}')
|
||||
sh(f'xcrun simctl install "{device}" "{APP}"')
|
||||
container = sh(
|
||||
f'xcrun simctl get_app_container "{device}" {BUNDLE} data'
|
||||
).stdout.strip()
|
||||
appsup = Path(container) / "Library/Application Support"
|
||||
appsup.mkdir(parents=True, exist_ok=True)
|
||||
(appsup / "jcode-servers.json").write_text(CRED + "\n")
|
||||
|
||||
runtime = {}
|
||||
t0 = time.monotonic()
|
||||
r = sh(f'xcrun simctl launch "{device}" {BUNDLE}')
|
||||
launched_ok = r.returncode == 0
|
||||
if collect_perf and launched_ok:
|
||||
# Time for simctl to spawn the process and hand back a pid: the
|
||||
# closest cheap analogue of "cold launch to process ready".
|
||||
runtime["cold_launch_ms"] = round((time.monotonic() - t0) * 1000.0, 1)
|
||||
ff = measure_first_frame(device, t0)
|
||||
if ff is not None:
|
||||
runtime["first_frame_ms"] = round(ff, 1)
|
||||
# Keep total settle constant so transcript streaming finishes and shots
|
||||
# stay comparable regardless of how long first-frame polling took.
|
||||
remaining = SETTLE_TOTAL_S - (time.monotonic() - t0)
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
return runtime
|
||||
|
||||
|
||||
def screenshot(device, path):
|
||||
sh(f'xcrun simctl io "{device}" screenshot "{path}"')
|
||||
|
||||
|
||||
def device_scale(device):
|
||||
"""Device pixels per point. SE/mini-class phones and iPads are 2x."""
|
||||
d = device.lower()
|
||||
if "iphone se" in d or "mini" in d or "ipad" in d:
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
def run_matrix(devices, scenarios, out_dir, a11y_size=DEFAULT_A11Y_SIZE,
|
||||
collect_perf=True):
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
results = []
|
||||
first_device = devices[0]
|
||||
build_app(first_device)
|
||||
|
||||
# Cell plan: every device at the default Dynamic Type size, plus the
|
||||
# primary device again at the accessibility size (if requested).
|
||||
plan = [(d, DEFAULT_CONTENT_SIZE) for d in devices]
|
||||
if a11y_size:
|
||||
plan.append((first_device, a11y_size))
|
||||
|
||||
booted = set()
|
||||
for device, content_size in plan:
|
||||
if device not in booted:
|
||||
boot(device)
|
||||
booted.add(device)
|
||||
scale = device_scale(device)
|
||||
size_ok = set_content_size(device, content_size)
|
||||
if content_size != DEFAULT_CONTENT_SIZE and not size_ok:
|
||||
print(f"skipping {device} @ {content_size} (unsupported)",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
for scenario in scenarios:
|
||||
gw = start_gateway(scenario)
|
||||
try:
|
||||
runtime = seed_and_launch(device, collect_perf=collect_perf)
|
||||
shot = out_dir / (f"{slug(device)}__{slug(content_size)}"
|
||||
f"__{scenario}.png")
|
||||
screenshot(device, str(shot))
|
||||
card = ui_metrics.analyze(str(shot), scale=scale)
|
||||
row = {
|
||||
"device": device, "scenario": scenario,
|
||||
"content_size": content_size,
|
||||
"scale": scale,
|
||||
"shot": str(shot),
|
||||
"overall": card.overall, "space": card.space,
|
||||
"consistency": card.consistency,
|
||||
"legibility": card.legibility, "rhythm": card.rhythm,
|
||||
"fill_ratio": card.fill_ratio,
|
||||
"dead_zone_frac": card.dead_zone_frac,
|
||||
}
|
||||
if runtime:
|
||||
row["runtime"] = runtime
|
||||
results.append(row)
|
||||
finally:
|
||||
gw.terminate()
|
||||
# Never leave a booted simulator stuck on an accessibility size.
|
||||
if content_size != DEFAULT_CONTENT_SIZE:
|
||||
set_content_size(device, DEFAULT_CONTENT_SIZE)
|
||||
sh("pkill -f mock_gateway.py")
|
||||
return results
|
||||
|
||||
|
||||
def slug(s):
|
||||
return s.replace(" ", "-").replace("(", "").replace(")", "")
|
||||
|
||||
|
||||
def print_table(results):
|
||||
print(f"{'device':22} {'size':14} {'scenario':9} {'ovr':>5} {'spc':>5} "
|
||||
f"{'fill':>5} {'dead':>5} {'launch':>7} {'frame':>7}")
|
||||
print("-" * 92)
|
||||
for r in results:
|
||||
rt = r.get("runtime") or {}
|
||||
launch = f"{rt['cold_launch_ms']:.0f}ms" if "cold_launch_ms" in rt else "-"
|
||||
frame = f"{rt['first_frame_ms']:.0f}ms" if "first_frame_ms" in rt else "-"
|
||||
size = r.get("content_size", DEFAULT_CONTENT_SIZE)
|
||||
size = size.replace("accessibility", "a11y")
|
||||
print(f"{r['device'][:22]:22} {size[:14]:14} {r['scenario']:9} "
|
||||
f"{r['overall']:5.1f} {r['space']:5.1f} "
|
||||
f"{r['fill_ratio']:5.2f} {r['dead_zone_frac']:5.2f} "
|
||||
f"{launch:>7} {frame:>7}")
|
||||
print("-" * 92)
|
||||
mean = sum(r["overall"] for r in results) / max(1, len(results))
|
||||
worst = min(results, key=lambda r: r["overall"]) if results else None
|
||||
print(f"MEAN overall: {mean:5.1f}")
|
||||
if worst:
|
||||
print(f"WORST cell: {worst['overall']:.1f} "
|
||||
f"({worst['device']} / "
|
||||
f"{worst.get('content_size', DEFAULT_CONTENT_SIZE)} / "
|
||||
f"{worst['scenario']})")
|
||||
return mean
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--devices", default=DEFAULT_DEVICES)
|
||||
ap.add_argument("--scenarios", default="empty,short,tool,long,code")
|
||||
ap.add_argument("--a11y-size", default=DEFAULT_A11Y_SIZE,
|
||||
help="Dynamic Type category to re-run the primary device "
|
||||
"at (empty string disables the variant)")
|
||||
ap.add_argument("--no-perf", action="store_true",
|
||||
help="skip runtime (launch/first-frame) measurement")
|
||||
ap.add_argument("--out", default=str(Path(os.environ.get("TMPDIR", "/tmp"))
|
||||
/ "jcode-ui-matrix"))
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--baseline-json")
|
||||
ap.add_argument("--candidate-json")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.baseline_json and args.candidate_json:
|
||||
a = json.loads(Path(args.baseline_json).read_text())
|
||||
b = json.loads(Path(args.candidate_json).read_text())
|
||||
am = sum(r["overall"] for r in a) / max(1, len(a))
|
||||
bm = sum(r["overall"] for r in b) / max(1, len(b))
|
||||
print(f"baseline mean {am:5.1f}")
|
||||
print(f"candidate mean {bm:5.1f}")
|
||||
print(f"delta {'+' if bm >= am else ''}{bm - am:.1f}")
|
||||
sys.exit(0 if bm >= am - 0.5 else 1)
|
||||
|
||||
devices = [d.strip() for d in args.devices.split(",") if d.strip()]
|
||||
scenarios = [s.strip() for s in args.scenarios.split(",") if s.strip()]
|
||||
out_dir = Path(args.out)
|
||||
results = run_matrix(devices, scenarios, out_dir,
|
||||
a11y_size=args.a11y_size.strip(),
|
||||
collect_perf=not args.no_perf)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2))
|
||||
else:
|
||||
print_table(results)
|
||||
print(f"\nscreenshots: {out_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+322
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""UI efficiency analyzer for jcode iOS screenshots.
|
||||
|
||||
Turns "this looks ugly" into hill-climbable numbers. Given a PNG screenshot
|
||||
(from `xcrun simctl io ... screenshot`), it scores the rendered UI on
|
||||
objective axes and prints a scorecard plus an overall 0-100 efficiency score.
|
||||
|
||||
Axes (each 0-100, higher is better):
|
||||
space use of the available canvas: fill ratio, vertical balance,
|
||||
and the size of the largest empty "dead zone".
|
||||
consistency visual discipline: how few distinct dominant colors, and how
|
||||
well content aligns to a small set of left margins.
|
||||
legibility text/background contrast (WCAG-style) for the brightest content.
|
||||
rhythm whether vertical gaps between content rows snap to an 8pt grid.
|
||||
|
||||
It is deliberately renderer-agnostic: it reads pixels, not the view tree, so
|
||||
the same tool grades any screenshot and regressions are caught without trust
|
||||
in the code. Pair with ui_lint.py (source discipline) for full coverage.
|
||||
|
||||
Usage:
|
||||
python3 ui_metrics.py SHOT.png [--scale 3] [--json] [--annotate OUT.png]
|
||||
python3 ui_metrics.py --baseline a.png --candidate b.png # compare two
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# --- Design tokens (must mirror Sources/JCodeMobile/Theme.swift) -------------
|
||||
TOKENS = {
|
||||
"background": 0x0F0F14,
|
||||
"surface": 0x1A1A1F,
|
||||
"surfaceElevated": 0x242429,
|
||||
"mint": 0x4DD9A6,
|
||||
"warning": 0xF59E0B,
|
||||
"error": 0xD94D59,
|
||||
}
|
||||
|
||||
# iPhone status bar / home indicator are OS chrome, not our UI. Trim them so we
|
||||
# grade the app's content area, not Apple's clock.
|
||||
STATUS_BAR_FRAC = 0.055
|
||||
HOME_INDICATOR_FRAC = 0.025
|
||||
|
||||
|
||||
def hex_to_rgb(h):
|
||||
return np.array([(h >> 16) & 0xFF, (h >> 8) & 0xFF, h & 0xFF], dtype=np.float64)
|
||||
|
||||
|
||||
def relative_luminance(rgb):
|
||||
srgb = rgb / 255.0
|
||||
lin = np.where(srgb <= 0.03928, srgb / 12.92, ((srgb + 0.055) / 1.055) ** 2.4)
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
||||
|
||||
|
||||
def contrast_ratio(a, b):
|
||||
la, lb = relative_luminance(a), relative_luminance(b)
|
||||
hi, lo = max(la, lb), min(la, lb)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scorecard:
|
||||
space: float
|
||||
consistency: float
|
||||
legibility: float
|
||||
rhythm: float
|
||||
overall: float
|
||||
# raw measurements for debugging / regression diffing
|
||||
fill_ratio: float
|
||||
vertical_balance: float
|
||||
dead_zone_frac: float
|
||||
dominant_colors: int
|
||||
margin_groups: int
|
||||
text_contrast: float
|
||||
rhythm_snap: float
|
||||
|
||||
def render(self):
|
||||
lines = [
|
||||
"UI efficiency scorecard",
|
||||
"=" * 40,
|
||||
f" space {bar(self.space)} {self.space:5.1f}",
|
||||
f" consistency {bar(self.consistency)} {self.consistency:5.1f}",
|
||||
f" legibility {bar(self.legibility)} {self.legibility:5.1f}",
|
||||
f" rhythm {bar(self.rhythm)} {self.rhythm:5.1f}",
|
||||
"-" * 40,
|
||||
f" OVERALL {bar(self.overall)} {self.overall:5.1f}",
|
||||
"",
|
||||
"raw:",
|
||||
f" fill_ratio {self.fill_ratio:.3f} (content / canvas)",
|
||||
f" vertical_balance {self.vertical_balance:.3f} (1=centered)",
|
||||
f" dead_zone_frac {self.dead_zone_frac:.3f} (largest empty band)",
|
||||
f" dominant_colors {self.dominant_colors}",
|
||||
f" margin_groups {self.margin_groups} (distinct left edges)",
|
||||
f" text_contrast {self.text_contrast:.2f}:1 (WCAG)",
|
||||
f" rhythm_snap {self.rhythm_snap:.3f} (gap->8pt grid)",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def bar(v, width=20):
|
||||
filled = int(round(v / 100 * width))
|
||||
return "[" + "#" * filled + "." * (width - filled) + "]"
|
||||
|
||||
|
||||
def clamp(v, lo=0.0, hi=100.0):
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
def analyze(path, scale=3, annotate=None):
|
||||
img = Image.open(path).convert("RGB")
|
||||
arr = np.asarray(img, dtype=np.float64)
|
||||
H, W, _ = arr.shape
|
||||
|
||||
# Trim OS chrome.
|
||||
top = int(H * STATUS_BAR_FRAC)
|
||||
bot = int(H * (1 - HOME_INDICATOR_FRAC))
|
||||
content = arr[top:bot]
|
||||
ch, cw, _ = content.shape
|
||||
|
||||
bg = hex_to_rgb(TOKENS["background"])
|
||||
# Per-pixel distance from the app background.
|
||||
dist = np.linalg.norm(content - bg, axis=2)
|
||||
is_content = dist > 18.0 # tolerance for AA + jpeg-ish noise
|
||||
|
||||
# --- SPACE -------------------------------------------------------------
|
||||
fill_ratio = float(is_content.mean())
|
||||
|
||||
row_occ = is_content.mean(axis=1) # fraction of content per row
|
||||
ys = np.arange(ch)
|
||||
occ_sum = row_occ.sum()
|
||||
if occ_sum > 0:
|
||||
com = float((ys * row_occ).sum() / occ_sum) / ch # 0..1 center of mass
|
||||
else:
|
||||
com = 0.5
|
||||
vertical_balance = 1.0 - abs(com - 0.5) * 2.0
|
||||
|
||||
# Largest contiguous "empty" band (rows with <1% content).
|
||||
empty = row_occ < 0.01
|
||||
dead = longest_run(empty) / ch
|
||||
|
||||
# Space score: reward filling, centering, and penalize a huge dead zone.
|
||||
# An ideal chat fills ~35-65% with content reasonably spread.
|
||||
fill_score = 100 * (1 - abs(fill_ratio - 0.45) / 0.45)
|
||||
space = clamp(0.45 * clamp(fill_score) + 0.35 * (vertical_balance * 100)
|
||||
+ 0.20 * (100 * (1 - dead)))
|
||||
|
||||
# --- CONSISTENCY -------------------------------------------------------
|
||||
# Distinct dominant colors: quantize to 5 bits/channel, count buckets that
|
||||
# cover >0.4% of content pixels. Clean designs use few.
|
||||
cont_px = content[is_content]
|
||||
if len(cont_px):
|
||||
q = (cont_px // 8).astype(np.int64)
|
||||
keys = q[:, 0] * 1024 + q[:, 1] * 32 + q[:, 2]
|
||||
_, counts = np.unique(keys, return_counts=True)
|
||||
dominant = int((counts > 0.004 * len(cont_px)).sum())
|
||||
else:
|
||||
dominant = 0
|
||||
# 4-9 dominant colors is healthy; more = noisy, fewer = empty.
|
||||
color_score = 100 * (1 - clamp(abs(dominant - 7) / 12, 0, 1))
|
||||
|
||||
# Left-margin alignment: leftmost content x per row, clustered.
|
||||
margins = leftmost_edges(is_content)
|
||||
margin_groups = cluster_count(margins, tol=int(8 * scale))
|
||||
# A disciplined layout uses 1-3 left margins.
|
||||
margin_score = 100 * (1 - clamp((margin_groups - 2) / 6, 0, 1))
|
||||
|
||||
consistency = clamp(0.5 * color_score + 0.5 * margin_score)
|
||||
|
||||
# --- LEGIBILITY --------------------------------------------------------
|
||||
# Brightest content (text-ish) contrast vs background.
|
||||
if len(cont_px):
|
||||
with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
|
||||
lum = cont_px @ np.array([0.299, 0.587, 0.114], dtype=np.float64)
|
||||
bright = cont_px[lum > np.percentile(lum, 90)]
|
||||
sample = bright.mean(axis=0) if len(bright) else cont_px.mean(axis=0)
|
||||
text_contrast = contrast_ratio(sample, bg)
|
||||
else:
|
||||
text_contrast = 1.0
|
||||
# WCAG AA body text wants >= 4.5:1; AAA 7:1. Map 1->0, 7->100.
|
||||
legibility = clamp((text_contrast - 1.0) / (7.0 - 1.0) * 100)
|
||||
|
||||
# --- RHYTHM ------------------------------------------------------------
|
||||
# Gaps between content bands; reward snapping to an 8pt grid.
|
||||
bands = content_bands(row_occ, thresh=0.01)
|
||||
gaps = [bands[i + 1][0] - bands[i][1] for i in range(len(bands) - 1)]
|
||||
grid = 8 * scale
|
||||
if gaps:
|
||||
snap = np.mean([1 - min(abs((g % grid)), grid - (g % grid)) / (grid / 2)
|
||||
for g in gaps if g > 0]) if any(g > 0 for g in gaps) else 0.0
|
||||
snap = float(max(0.0, snap))
|
||||
else:
|
||||
snap = 0.0
|
||||
rhythm = clamp(snap * 100)
|
||||
|
||||
overall = clamp(0.40 * space + 0.30 * consistency
|
||||
+ 0.20 * legibility + 0.10 * rhythm)
|
||||
|
||||
card = Scorecard(
|
||||
space=round(space, 1), consistency=round(consistency, 1),
|
||||
legibility=round(legibility, 1), rhythm=round(rhythm, 1),
|
||||
overall=round(overall, 1),
|
||||
fill_ratio=round(fill_ratio, 4), vertical_balance=round(vertical_balance, 4),
|
||||
dead_zone_frac=round(dead, 4), dominant_colors=dominant,
|
||||
margin_groups=margin_groups, text_contrast=round(text_contrast, 2),
|
||||
rhythm_snap=round(snap, 4),
|
||||
)
|
||||
|
||||
if annotate:
|
||||
draw_overlay(img.copy(), top, bot, is_content, bands, annotate, scale)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
def longest_run(boolean_arr):
|
||||
best = run = 0
|
||||
for v in boolean_arr:
|
||||
run = run + 1 if v else 0
|
||||
best = max(best, run)
|
||||
return best
|
||||
|
||||
|
||||
def leftmost_edges(mask):
|
||||
edges = []
|
||||
for row in mask:
|
||||
idx = np.argmax(row)
|
||||
if row[idx]:
|
||||
edges.append(int(idx))
|
||||
return edges
|
||||
|
||||
|
||||
def cluster_count(values, tol):
|
||||
if not values:
|
||||
return 0
|
||||
vals = sorted(values)
|
||||
groups = 1
|
||||
anchor = vals[0]
|
||||
# weight by frequency: only count a cluster if it has enough rows
|
||||
from collections import Counter
|
||||
c = Counter(values)
|
||||
centers = []
|
||||
for v in sorted(c):
|
||||
if not centers or v - centers[-1] > tol:
|
||||
centers.append(v)
|
||||
# keep clusters covering >2% of rows
|
||||
total = len(values)
|
||||
significant = 0
|
||||
bucket = {}
|
||||
for v in values:
|
||||
placed = False
|
||||
for center in centers:
|
||||
if abs(v - center) <= tol:
|
||||
bucket[center] = bucket.get(center, 0) + 1
|
||||
placed = True
|
||||
break
|
||||
return sum(1 for center, n in bucket.items() if n > 0.02 * total)
|
||||
|
||||
|
||||
def content_bands(row_occ, thresh):
|
||||
bands = []
|
||||
start = None
|
||||
for i, v in enumerate(row_occ):
|
||||
if v >= thresh and start is None:
|
||||
start = i
|
||||
elif v < thresh and start is not None:
|
||||
bands.append((start, i))
|
||||
start = None
|
||||
if start is not None:
|
||||
bands.append((start, len(row_occ)))
|
||||
# merge tiny gaps
|
||||
return [b for b in bands if b[1] - b[0] > 3]
|
||||
|
||||
|
||||
def draw_overlay(img, top, bot, mask, bands, out, scale):
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([0, top, img.width - 1, bot], outline=(77, 217, 166), width=2)
|
||||
for (s, e) in bands:
|
||||
d.rectangle([0, top + s, img.width - 1, top + e],
|
||||
outline=(245, 158, 11), width=1)
|
||||
img.save(out)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("path", nargs="?")
|
||||
ap.add_argument("--scale", type=int, default=3, help="device px per point")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--annotate", help="write an annotated PNG to this path")
|
||||
ap.add_argument("--baseline")
|
||||
ap.add_argument("--candidate")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.baseline and args.candidate:
|
||||
a = analyze(args.baseline, args.scale)
|
||||
b = analyze(args.candidate, args.scale)
|
||||
print(f"baseline overall {a.overall:5.1f}")
|
||||
print(f"candidate overall {b.overall:5.1f}")
|
||||
delta = b.overall - a.overall
|
||||
sign = "+" if delta >= 0 else ""
|
||||
print(f"delta {sign}{delta:.1f}")
|
||||
for k in ["space", "consistency", "legibility", "rhythm"]:
|
||||
av, bv = getattr(a, k), getattr(b, k)
|
||||
dd = bv - av
|
||||
s = "+" if dd >= 0 else ""
|
||||
print(f" {k:12} {av:5.1f} -> {bv:5.1f} ({s}{dd:.1f})")
|
||||
sys.exit(0 if delta >= -0.5 else 1)
|
||||
|
||||
if not args.path:
|
||||
ap.error("provide a screenshot path, or --baseline/--candidate")
|
||||
|
||||
card = analyze(args.path, args.scale, args.annotate)
|
||||
if args.json:
|
||||
print(json.dumps(asdict(card), indent=2))
|
||||
else:
|
||||
print(card.render())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import JCodeKit
|
||||
|
||||
/// Scriptable in-memory transport for Connection tests.
|
||||
actor FakeTransport: WebSocketTransport {
|
||||
enum Behavior {
|
||||
case succeed
|
||||
case failConnect
|
||||
case unauthorized
|
||||
}
|
||||
|
||||
let behavior: Behavior
|
||||
private(set) var sentLines: [String] = []
|
||||
private var incoming: [String] = []
|
||||
private var waiters: [CheckedContinuation<String?, Never>] = []
|
||||
private var closed = false
|
||||
|
||||
init(behavior: Behavior = .succeed) {
|
||||
self.behavior = behavior
|
||||
}
|
||||
|
||||
func connect(url: URL, authToken: String) async throws {
|
||||
if behavior == .failConnect {
|
||||
throw TransportError.notConnected
|
||||
}
|
||||
if behavior == .unauthorized {
|
||||
throw TransportError.unauthorized
|
||||
}
|
||||
}
|
||||
|
||||
func send(text: String) async throws {
|
||||
if closed { throw TransportError.notConnected }
|
||||
sentLines.append(text)
|
||||
}
|
||||
|
||||
func receiveText() async throws -> String? {
|
||||
if closed { return nil }
|
||||
if !incoming.isEmpty {
|
||||
return incoming.removeFirst()
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
waiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func close() async {
|
||||
closed = true
|
||||
for waiter in waiters {
|
||||
waiter.resume(returning: nil)
|
||||
}
|
||||
waiters.removeAll()
|
||||
}
|
||||
|
||||
/// Test helper: push a server frame to the client.
|
||||
func push(_ line: String) {
|
||||
if let waiter = waiters.first {
|
||||
waiters.removeFirst()
|
||||
waiter.resume(returning: line)
|
||||
} else {
|
||||
incoming.append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeConnection(transport: FakeTransport) -> Connection {
|
||||
Connection(
|
||||
configuration: .init(
|
||||
gateway: Gateway(host: "test.local"),
|
||||
authToken: "tok",
|
||||
maxReconnectAttempts: 1,
|
||||
baseBackoffSeconds: 0.01
|
||||
),
|
||||
makeTransport: { transport }
|
||||
)
|
||||
}
|
||||
|
||||
@Test func connectSubscribesAndSyncsHistory() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
// connecting -> connected
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
#expect(await iterator.next() == .phase(.connected))
|
||||
|
||||
// Wait for the subscribe + get_history requests to land.
|
||||
var sent: [String] = []
|
||||
for _ in 0..<50 {
|
||||
sent = await transport.sentLines
|
||||
if sent.count >= 2 { break }
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
#expect(sent.count == 2)
|
||||
#expect(sent[0].contains("\"type\":\"subscribe\""))
|
||||
#expect(sent[1].contains("\"type\":\"get_history\""))
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func decodesPushedEvents() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
#expect(await iterator.next() == .phase(.connected))
|
||||
|
||||
await transport.push(#"{"type":"text_delta","text":"hi"}"#)
|
||||
#expect(await iterator.next() == .event(.textDelta(text: "hi")))
|
||||
|
||||
// Multiple newline-delimited events in one frame.
|
||||
await transport.push("{\"type\":\"message_end\"}\n{\"type\":\"done\",\"id\":1}")
|
||||
#expect(await iterator.next() == .event(.messageEnd))
|
||||
#expect(await iterator.next() == .event(.done(id: 1)))
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func sendAssignsMonotonicRequestIDs() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
_ = await iterator.next()
|
||||
_ = await iterator.next()
|
||||
|
||||
// Wait until the automatic subscribe/get_history sends (IDs 1-2) finish
|
||||
// so they cannot interleave with our test sends.
|
||||
for _ in 0..<100 {
|
||||
if await transport.sentLines.count >= 2 { break }
|
||||
try await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
|
||||
let first = try await connection.send { .message(id: $0, content: "a") }
|
||||
let second = try await connection.send { .ping(id: $0) }
|
||||
#expect(second == first + 1)
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func failedConnectReportsFailureAfterRetries() async throws {
|
||||
let transport = FakeTransport(behavior: .failConnect)
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var phases: [ConnectionPhase] = []
|
||||
for await output in stream {
|
||||
if case let .phase(phase) = output {
|
||||
phases.append(phase)
|
||||
if case .failed = phase { break }
|
||||
}
|
||||
}
|
||||
#expect(phases.first == .connecting)
|
||||
#expect(phases.contains(.reconnecting(attempt: 1)))
|
||||
if case .failed = phases.last {
|
||||
} else {
|
||||
Issue.record("expected failed phase, got \(phases)")
|
||||
}
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func trackSessionIDForResubscribe() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
_ = await iterator.next() // connecting
|
||||
_ = await iterator.next() // connected
|
||||
|
||||
await transport.push(#"{"type":"session","session_id":"sess_42"}"#)
|
||||
#expect(await iterator.next() == .event(.sessionID(sessionID: "sess_42")))
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func sessionCloseRequestStopsReconnecting() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
#expect(await iterator.next() == .phase(.connected))
|
||||
|
||||
await transport.push(#"{"type":"session_close_requested","reason":"taken over"}"#)
|
||||
#expect(
|
||||
await iterator.next()
|
||||
== .event(.sessionCloseRequested(reason: "taken over")))
|
||||
|
||||
// The loop must terminate with a failed phase instead of reconnecting.
|
||||
var sawFailed = false
|
||||
while let output = await iterator.next() {
|
||||
if case .phase(.reconnecting) = output {
|
||||
Issue.record("must not reconnect after session_close_requested")
|
||||
break
|
||||
}
|
||||
if case .phase(.failed(let reason)) = output {
|
||||
#expect(reason == "taken over")
|
||||
sawFailed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
#expect(sawFailed)
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func reloadingTriggersFastReconnect() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
#expect(await iterator.next() == .phase(.connected))
|
||||
|
||||
await transport.push(#"{"type":"reloading"}"#)
|
||||
#expect(await iterator.next() == .event(.reloading(newSocket: nil)))
|
||||
|
||||
// Simulate the server dropping the socket for the restart.
|
||||
await transport.close()
|
||||
|
||||
// The connection reconnects (the shared FakeTransport accepts again).
|
||||
var phases: [ConnectionPhase] = []
|
||||
while let output = await iterator.next() {
|
||||
if case let .phase(phase) = output {
|
||||
phases.append(phase)
|
||||
if phase == .connected { break }
|
||||
}
|
||||
}
|
||||
#expect(phases.contains(.reconnecting(attempt: 1)))
|
||||
#expect(phases.last == .connected)
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func unauthorizedStopsReconnectingAndAsksForRePair() async throws {
|
||||
let transport = FakeTransport(behavior: .unauthorized)
|
||||
let connection = Connection(
|
||||
configuration: .init(
|
||||
gateway: Gateway(host: "test.local"),
|
||||
authToken: "tok",
|
||||
maxReconnectAttempts: nil, // would retry forever without the 401 short-circuit
|
||||
baseBackoffSeconds: 0.01
|
||||
),
|
||||
makeTransport: { transport }
|
||||
)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
let final = await iterator.next()
|
||||
guard case .phase(.failed(let reason))? = final else {
|
||||
Issue.record("expected .failed phase, got \(String(describing: final))")
|
||||
return
|
||||
}
|
||||
#expect(reason.contains("Re-pair"))
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import JCodeKit
|
||||
|
||||
private func run(_ events: [ConnectionOutput], from state: SessionState = SessionState())
|
||||
-> SessionState
|
||||
{
|
||||
events.reduce(state) { SessionReducer.reduce($0, $1) }
|
||||
}
|
||||
|
||||
private func event(_ line: String) -> ConnectionOutput {
|
||||
.event(try! ServerEvent.decode(line: line))
|
||||
}
|
||||
|
||||
// MARK: - Streaming text
|
||||
|
||||
@Test func ackDoesNotMarkProcessing() {
|
||||
let state = run([event(#"{"type":"ack","id":1}"#)])
|
||||
#expect(state.isProcessing == false)
|
||||
}
|
||||
|
||||
@Test func streamsAssistantText() {
|
||||
let state = run([
|
||||
.phase(.connected),
|
||||
event(#"{"type":"ack","id":1}"#),
|
||||
event(#"{"type":"text_delta","text":"Hel"}"#),
|
||||
event(#"{"type":"text_delta","text":"lo"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
])
|
||||
#expect(state.transcript.count == 1)
|
||||
#expect(state.transcript[0].role == .assistant)
|
||||
#expect(state.transcript[0].text == "Hello")
|
||||
#expect(state.transcript[0].isStreaming == false)
|
||||
#expect(state.isProcessing == false)
|
||||
}
|
||||
|
||||
@Test func userMessageThenResponse() {
|
||||
var state = SessionState()
|
||||
state = SessionReducer.reduce(state, intent: .userSentMessage("hi"))
|
||||
#expect(state.isProcessing)
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"hey"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
], from: state)
|
||||
#expect(state.transcript.map(\.role) == [.user, .assistant])
|
||||
#expect(state.transcript[1].text == "hey")
|
||||
}
|
||||
|
||||
@Test func textReplaceOverwritesStreamedText() {
|
||||
let state = run([
|
||||
event(#"{"type":"text_delta","text":"garbled{{tool"}"#),
|
||||
event(#"{"type":"text_replace","text":"clean prefix"}"#),
|
||||
])
|
||||
#expect(state.transcript[0].text == "clean prefix")
|
||||
}
|
||||
|
||||
@Test func messageEndSplitsTurns() {
|
||||
let state = run([
|
||||
event(#"{"type":"text_delta","text":"first"}"#),
|
||||
event(#"{"type":"message_end"}"#),
|
||||
event(#"{"type":"text_delta","text":"second"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
])
|
||||
#expect(state.transcript.map(\.text) == ["first", "second"])
|
||||
}
|
||||
|
||||
// MARK: - Reasoning
|
||||
|
||||
@Test func reasoningStreamsSeparately() {
|
||||
var state = run([
|
||||
event(#"{"type":"reasoning_delta","text":"thinking..."}"#)
|
||||
])
|
||||
#expect(state.isReasoning)
|
||||
#expect(state.transcript[0].reasoning == "thinking...")
|
||||
#expect(state.transcript[0].text == "")
|
||||
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"reasoning_done","duration_secs":2.0}"#),
|
||||
event(#"{"type":"text_delta","text":"answer"}"#),
|
||||
], from: state)
|
||||
#expect(state.isReasoning == false)
|
||||
#expect(state.transcript[0].text == "answer")
|
||||
}
|
||||
|
||||
// MARK: - Tool lifecycle
|
||||
|
||||
@Test func toolLifecycleTransitions() {
|
||||
var state = run([
|
||||
event(#"{"type":"tool_start","id":"t1","name":"bash"}"#),
|
||||
event(#"{"type":"tool_input","delta":"{\"command\":"}"#),
|
||||
event(#"{"type":"tool_input","delta":"\"ls\"}"}"#),
|
||||
])
|
||||
#expect(state.transcript[0].toolCalls.count == 1)
|
||||
#expect(state.transcript[0].toolCalls[0].status == .streamingInput)
|
||||
#expect(state.transcript[0].toolCalls[0].input == #"{"command":"ls"}"#)
|
||||
|
||||
state = run([event(#"{"type":"tool_exec","id":"t1","name":"bash"}"#)], from: state)
|
||||
#expect(state.transcript[0].toolCalls[0].status == .running)
|
||||
|
||||
state = run(
|
||||
[event(#"{"type":"tool_done","id":"t1","name":"bash","output":"file.txt"}"#)],
|
||||
from: state)
|
||||
#expect(state.transcript[0].toolCalls[0].status == .succeeded)
|
||||
#expect(state.transcript[0].toolCalls[0].output == "file.txt")
|
||||
}
|
||||
|
||||
@Test func toolFailureRecorded() {
|
||||
let state = run([
|
||||
event(#"{"type":"tool_start","id":"t1","name":"bash"}"#),
|
||||
event(#"{"type":"tool_exec","id":"t1","name":"bash"}"#),
|
||||
event(#"{"type":"tool_done","id":"t1","name":"bash","output":"","error":"exit 1"}"#),
|
||||
])
|
||||
#expect(state.transcript[0].toolCalls[0].status == .failed("exit 1"))
|
||||
}
|
||||
|
||||
@Test func multipleToolsInOneTurn() {
|
||||
let state = run([
|
||||
event(#"{"type":"tool_start","id":"t1","name":"read"}"#),
|
||||
event(#"{"type":"tool_done","id":"t1","name":"read","output":"a"}"#),
|
||||
event(#"{"type":"tool_start","id":"t2","name":"bash"}"#),
|
||||
event(#"{"type":"tool_done","id":"t2","name":"bash","output":"b"}"#),
|
||||
event(#"{"type":"text_delta","text":"done"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
])
|
||||
#expect(state.transcript.count == 1)
|
||||
#expect(state.transcript[0].toolCalls.map(\.name) == ["read", "bash"])
|
||||
#expect(state.transcript[0].text == "done")
|
||||
}
|
||||
|
||||
// MARK: - Errors and interrupts
|
||||
|
||||
@Test func errorSetsBannerAndStopsProcessing() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"error","id":1,"message":"overloaded","retry_after_secs":15}"#)
|
||||
], from: state)
|
||||
#expect(state.errorBanner == "overloaded (retry in 15s)")
|
||||
#expect(state.isProcessing == false)
|
||||
|
||||
state = SessionReducer.reduce(state, intent: .dismissError)
|
||||
#expect(state.errorBanner == nil)
|
||||
}
|
||||
|
||||
@Test func interruptStopsProcessingAndNotes() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"partial"}"#),
|
||||
event(#"{"type":"interrupted"}"#),
|
||||
], from: state)
|
||||
#expect(state.isProcessing == false)
|
||||
#expect(state.transcript.last?.text == "partial")
|
||||
#expect(state.transcript.last?.isStreaming == false)
|
||||
#expect(state.notices.contains { $0.message == "Interrupted" })
|
||||
}
|
||||
|
||||
// MARK: - Notices (notifications / compaction / dismissal)
|
||||
|
||||
@Test func notificationBecomesDismissibleNotice() {
|
||||
let state = run([
|
||||
event(#"{"type":"notification","from_name":"swarm","message":"build done"}"#)
|
||||
])
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].kind == .notification)
|
||||
#expect(state.notices[0].message == "swarm: build done")
|
||||
}
|
||||
|
||||
@Test func notificationWithoutSenderHasNoPrefix() {
|
||||
let state = run([
|
||||
event(#"{"type":"notification","message":"heads up"}"#)
|
||||
])
|
||||
#expect(state.notices[0].message == "heads up")
|
||||
}
|
||||
|
||||
@Test func foregroundCompactionSurfacesNotice() {
|
||||
let state = run([
|
||||
event(#"{"type":"compaction","trigger":"manual","tokens_saved":1234}"#)
|
||||
])
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].kind == .compaction)
|
||||
#expect(state.notices[0].message.contains("1234"))
|
||||
}
|
||||
|
||||
@Test func backgroundCompactionIsSilent() {
|
||||
let state = run([
|
||||
event(#"{"type":"compaction","trigger":"background","tokens_saved":50}"#)
|
||||
])
|
||||
#expect(state.notices.isEmpty)
|
||||
}
|
||||
|
||||
@Test func dismissNoticeRemovesOnlyThatNotice() {
|
||||
var state = run([
|
||||
event(#"{"type":"notification","message":"first"}"#),
|
||||
event(#"{"type":"notification","message":"second"}"#),
|
||||
])
|
||||
#expect(state.notices.count == 2)
|
||||
let firstID = state.notices[0].id
|
||||
state = SessionReducer.reduce(state, intent: .dismissNotice(firstID))
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].message == "second")
|
||||
}
|
||||
|
||||
@Test func clearedConversationWipesTranscriptButKeepsSession() {
|
||||
var state = run([
|
||||
event(#"{"type":"session","session_id":"sess_keep"}"#),
|
||||
event(#"{"type":"text_delta","text":"stale"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
])
|
||||
#expect(!state.transcript.isEmpty)
|
||||
state = SessionReducer.reduce(state, intent: .clearedConversation)
|
||||
#expect(state.transcript.isEmpty)
|
||||
#expect(state.isProcessing == false)
|
||||
#expect(state.sessionID == "sess_keep")
|
||||
}
|
||||
|
||||
@Test func emptyStreamingStubIsDropped() {
|
||||
let state = run([
|
||||
event(#"{"type":"ack","id":1}"#),
|
||||
event(#"{"type":"interrupted"}"#),
|
||||
])
|
||||
#expect(state.transcript.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - History sync
|
||||
|
||||
@Test func historyReplacesTranscript() {
|
||||
var state = run([
|
||||
event(#"{"type":"text_delta","text":"stale"}"#)
|
||||
])
|
||||
let history = """
|
||||
{"type":"history","id":2,"session_id":"sess_1","messages":[\
|
||||
{"role":"user","content":"question"},\
|
||||
{"role":"assistant","content":"answer"}\
|
||||
],"provider_model":"claude-sonnet-4","available_models":["a","b"],\
|
||||
"all_sessions":["sess_1","sess_2"],"total_tokens":[100,50],\
|
||||
"server_version":"v1","display_title":"T"}
|
||||
"""
|
||||
state = run([event(history)], from: state)
|
||||
#expect(state.transcript.map(\.text) == ["question", "answer"])
|
||||
#expect(state.sessionID == "sess_1")
|
||||
#expect(state.modelName == "claude-sonnet-4")
|
||||
#expect(state.availableModels == ["a", "b"])
|
||||
#expect(state.allSessions == ["sess_1", "sess_2"])
|
||||
#expect(state.tokenInput == 100)
|
||||
#expect(state.tokenOutput == 50)
|
||||
#expect(state.sessionTitle == "T")
|
||||
}
|
||||
|
||||
@Test func historySkipsEmptyAssistantStubs() {
|
||||
let history = """
|
||||
{"type":"history","id":1,"session_id":"s","messages":[\
|
||||
{"role":"user","content":"q"},\
|
||||
{"role":"assistant","content":""},\
|
||||
{"role":"tool","content":"raw tool output"}\
|
||||
]}
|
||||
"""
|
||||
let state = run([event(history)])
|
||||
#expect(state.transcript.count == 1)
|
||||
#expect(state.transcript[0].role == .user)
|
||||
}
|
||||
|
||||
@Test func historyMapsToolData() {
|
||||
let history = """
|
||||
{"type":"history","id":1,"session_id":"s","messages":[\
|
||||
{"role":"assistant","content":"used a tool","tool_data":\
|
||||
{"id":"t1","name":"bash","input":"{}","output":"ok"}}\
|
||||
]}
|
||||
"""
|
||||
let state = run([event(history)])
|
||||
#expect(state.transcript[0].toolCalls.count == 1)
|
||||
#expect(state.transcript[0].toolCalls[0].status == .succeeded)
|
||||
}
|
||||
|
||||
// MARK: - Connection phases
|
||||
|
||||
@Test func disconnectFinishesStreamingEntries() {
|
||||
let state = run([
|
||||
.phase(.connected),
|
||||
event(#"{"type":"text_delta","text":"part"}"#),
|
||||
.phase(.reconnecting(attempt: 1)),
|
||||
])
|
||||
#expect(state.phase == .reconnecting(attempt: 1))
|
||||
#expect(state.transcript[0].isStreaming == false)
|
||||
#expect(state.isProcessing == false)
|
||||
}
|
||||
|
||||
@Test func failureSetsBanner() {
|
||||
let state = run([.phase(.failed(reason: "Could not reach server"))])
|
||||
#expect(state.errorBanner == "Could not reach server")
|
||||
}
|
||||
|
||||
@Test func reconnectClearsBannerOnConnected() {
|
||||
var state = run([.phase(.failed(reason: "down"))])
|
||||
state = run([.phase(.connected)], from: state)
|
||||
#expect(state.errorBanner == nil)
|
||||
}
|
||||
|
||||
// MARK: - Session metadata
|
||||
|
||||
@Test func sessionRenameAppliesToCurrentSession() {
|
||||
var state = run([event(#"{"type":"session","session_id":"sess_1"}"#)])
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"session_renamed","session_id":"sess_1","display_title":"Renamed"}"#)
|
||||
], from: state)
|
||||
#expect(state.sessionTitle == "Renamed")
|
||||
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"session_renamed","session_id":"other","display_title":"Nope"}"#)
|
||||
], from: state)
|
||||
#expect(state.sessionTitle == "Renamed")
|
||||
}
|
||||
|
||||
@Test func modelChangeUpdatesOrErrors() {
|
||||
var state = run([event(#"{"type":"model_changed","id":1,"model":"gpt-5"}"#)])
|
||||
#expect(state.modelName == "gpt-5")
|
||||
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"model_changed","id":2,"model":"","error":"unknown model"}"#)
|
||||
], from: state)
|
||||
#expect(state.errorBanner == "unknown model")
|
||||
#expect(state.modelName == "gpt-5")
|
||||
}
|
||||
|
||||
@Test func resetClearsEverything() {
|
||||
var state = run([
|
||||
event(#"{"type":"text_delta","text":"data"}"#),
|
||||
event(#"{"type":"tokens","input":5,"output":6}"#),
|
||||
])
|
||||
state = SessionReducer.reduce(state, intent: .reset)
|
||||
#expect(state == SessionState())
|
||||
}
|
||||
|
||||
// MARK: - Queued soft-interrupts
|
||||
|
||||
@Test func queuedInterruptTracksPendingState() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = SessionReducer.reduce(state, intent: .userQueuedInterrupt("also do y"))
|
||||
#expect(state.hasPendingInterrupts)
|
||||
#expect(state.pendingInterrupts == ["also do y"])
|
||||
#expect(state.transcript.last?.isQueued == true)
|
||||
#expect(state.transcript.last?.text == "also do y")
|
||||
}
|
||||
|
||||
@Test func injectionClearsMatchingQueuedInterrupt() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = SessionReducer.reduce(state, intent: .userQueuedInterrupt("also do y"))
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"soft_interrupt_injected","content":"also do y","point":"C"}"#)
|
||||
], from: state)
|
||||
#expect(state.pendingInterrupts.isEmpty)
|
||||
#expect(state.transcript.last?.isQueued == false)
|
||||
#expect(state.transcript.last?.text == "also do y")
|
||||
}
|
||||
|
||||
@Test func injectionFromAnotherClientAppendsEntry() {
|
||||
let state = run([
|
||||
event(
|
||||
#"{"type":"soft_interrupt_injected","content":"external note","display_role":"system","point":"A"}"#
|
||||
)
|
||||
])
|
||||
#expect(state.transcript.count == 1)
|
||||
#expect(state.transcript[0].role == .system)
|
||||
#expect(state.transcript[0].text == "external note")
|
||||
#expect(state.transcript[0].isQueued == false)
|
||||
}
|
||||
|
||||
@Test func cancelledQueuedInterruptsRemovesOptimisticBubbles() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = SessionReducer.reduce(state, intent: .userQueuedInterrupt("nevermind"))
|
||||
state = SessionReducer.reduce(state, intent: .cancelledQueuedInterrupts)
|
||||
#expect(state.pendingInterrupts.isEmpty)
|
||||
#expect(!state.transcript.contains { $0.isQueued })
|
||||
#expect(state.transcript.map(\.text) == ["go"])
|
||||
}
|
||||
|
||||
@Test func turnCompletionDrainsPendingInterrupts() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = SessionReducer.reduce(state, intent: .userQueuedInterrupt("late note"))
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"answer"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
], from: state)
|
||||
#expect(state.pendingInterrupts.isEmpty)
|
||||
#expect(!state.transcript.contains { $0.isQueued })
|
||||
}
|
||||
|
||||
@Test func historyResyncClearsPendingInterrupts() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userQueuedInterrupt("queued"))
|
||||
#expect(state.hasPendingInterrupts)
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"history","id":1,"session_id":"s","messages":[{"role":"user","content":"q"}]}"#
|
||||
)
|
||||
], from: state)
|
||||
#expect(state.pendingInterrupts.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Turn-level connection phase
|
||||
|
||||
@Test func connectionPhaseTracksAndClears() {
|
||||
var state = run([event(#"{"type":"connection_phase","phase":"authenticating"}"#)])
|
||||
#expect(state.serverPhase == "authenticating")
|
||||
|
||||
state = run([event(#"{"type":"done","id":1}"#)], from: state)
|
||||
#expect(state.serverPhase == nil)
|
||||
|
||||
state = run([event(#"{"type":"connection_phase","phase":"waiting"}"#)], from: state)
|
||||
state = run([.phase(.reconnecting(attempt: 1))], from: state)
|
||||
#expect(state.serverPhase == nil)
|
||||
}
|
||||
|
||||
// MARK: - Retry rollback
|
||||
|
||||
@Test func retryRollbackDiscardsPartialOutput() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"reasoning_delta","text":"thinking"}"#),
|
||||
event(#"{"type":"text_delta","text":"partial answer"}"#),
|
||||
event(#"{"type":"retry_rollback","attempt":1,"max":5}"#),
|
||||
], from: state)
|
||||
// Only the user message survives; the partial assistant entry is discarded.
|
||||
#expect(state.transcript.map(\.role) == [.user])
|
||||
#expect(state.isReasoning == false)
|
||||
#expect(state.statusDetail == "Retrying (1/5)")
|
||||
|
||||
// The replayed response streams into a fresh entry without duplication.
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"clean answer"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
], from: state)
|
||||
#expect(state.transcript.map(\.text) == ["go", "clean answer"])
|
||||
}
|
||||
|
||||
@Test func retryRollbackKeepsCommittedTurns() {
|
||||
let state = run([
|
||||
event(#"{"type":"text_delta","text":"first"}"#),
|
||||
event(#"{"type":"message_end"}"#),
|
||||
event(#"{"type":"text_delta","text":"second partial"}"#),
|
||||
event(#"{"type":"retry_rollback","attempt":1,"max":3}"#),
|
||||
])
|
||||
#expect(state.transcript.map(\.text) == ["first"])
|
||||
}
|
||||
|
||||
// MARK: - Session titles
|
||||
|
||||
@Test func renameOfOtherSessionStillRecordsTitle() {
|
||||
var state = run([event(#"{"type":"session","session_id":"sess_1"}"#)])
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"session_renamed","session_id":"other","display_title":"Other work"}"#)
|
||||
], from: state)
|
||||
#expect(state.sessionTitle == nil)
|
||||
#expect(state.title(forSession: "other") == "Other work")
|
||||
}
|
||||
|
||||
@Test func historyRecordsTitleForSessionList() {
|
||||
let state = run([
|
||||
event(
|
||||
#"{"type":"history","id":1,"session_id":"sess_1","messages":[],"all_sessions":["sess_1","sess_2"],"display_title":"Main"}"#
|
||||
)
|
||||
])
|
||||
#expect(state.title(forSession: "sess_1") == "Main")
|
||||
#expect(state.title(forSession: "sess_2") == nil)
|
||||
#expect(state.sessionTitle == "Main")
|
||||
}
|
||||
|
||||
// MARK: - Reasoning effort
|
||||
|
||||
@Test func reasoningEffortChangeUpdatesOrErrors() {
|
||||
var state = run([
|
||||
event(#"{"type":"reasoning_effort_changed","id":1,"effort":"high"}"#)
|
||||
])
|
||||
#expect(state.reasoningEffort == "high")
|
||||
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"reasoning_effort_changed","id":2,"error":"unsupported"}"#)
|
||||
], from: state)
|
||||
#expect(state.errorBanner == "unsupported")
|
||||
#expect(state.reasoningEffort == "high")
|
||||
}
|
||||
|
||||
@Test func historyCarriesReasoningEffortIntoState() {
|
||||
let state = run([
|
||||
event(
|
||||
#"{"type":"history","id":1,"session_id":"s","messages":[],"reasoning_effort":"medium"}"#
|
||||
)
|
||||
])
|
||||
#expect(state.reasoningEffort == "medium")
|
||||
}
|
||||
|
||||
// MARK: - Compaction result
|
||||
|
||||
@Test func compactResultSuccessBecomesNotice() {
|
||||
let state = run([
|
||||
event(#"{"type":"compact_result","id":1,"message":"Compaction started","success":true}"#)
|
||||
])
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].kind == .compaction)
|
||||
#expect(state.errorBanner == nil)
|
||||
}
|
||||
|
||||
@Test func compactResultFailureBecomesError() {
|
||||
let state = run([
|
||||
event(#"{"type":"compact_result","id":1,"message":"Nothing to compact","success":false}"#)
|
||||
])
|
||||
#expect(state.notices.isEmpty)
|
||||
#expect(state.errorBanner == "Nothing to compact")
|
||||
}
|
||||
|
||||
// MARK: - Server lifecycle events
|
||||
|
||||
@Test func reloadingSurfacesNotice() {
|
||||
let state = run([event(#"{"type":"reloading"}"#)])
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].message.contains("updating"))
|
||||
}
|
||||
|
||||
@Test func sessionCloseRequestedStopsProcessingWithReason() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"partial"}"#),
|
||||
event(#"{"type":"session_close_requested","reason":"taken over by TUI"}"#),
|
||||
], from: state)
|
||||
#expect(state.isProcessing == false)
|
||||
#expect(state.errorBanner == "taken over by TUI")
|
||||
#expect(state.transcript.last?.isStreaming == false)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import JCodeKit
|
||||
|
||||
// MARK: - Gateway / PairURI
|
||||
|
||||
@Test func gatewayBuildsEndpoints() {
|
||||
let gateway = Gateway(host: "devbox.tailnet.ts.net")
|
||||
#expect(gateway.healthURL.absoluteString == "http://devbox.tailnet.ts.net:7643/health")
|
||||
#expect(gateway.pairURL.absoluteString == "http://devbox.tailnet.ts.net:7643/pair")
|
||||
#expect(gateway.webSocketURL.absoluteString == "ws://devbox.tailnet.ts.net:7643/ws")
|
||||
}
|
||||
|
||||
@Test func pairURIParsesQRPayload() {
|
||||
let payload = PairURI.parse("jcode://pair?host=mybox.ts.net&port=7643&code=123456")
|
||||
#expect(payload?.gateway.host == "mybox.ts.net")
|
||||
#expect(payload?.gateway.port == 7643)
|
||||
#expect(payload?.code == "123456")
|
||||
}
|
||||
|
||||
@Test func pairURIDefaultsPort() {
|
||||
let payload = PairURI.parse("jcode://pair?host=mybox&code=987654")
|
||||
#expect(payload?.gateway.port == Gateway.defaultPort)
|
||||
}
|
||||
|
||||
@Test func pairURIRejectsGarbage() {
|
||||
#expect(PairURI.parse("https://example.com/pair?host=x&code=1") == nil)
|
||||
#expect(PairURI.parse("jcode://pair?host=&code=1") == nil)
|
||||
#expect(PairURI.parse("jcode://pair?host=x") == nil)
|
||||
#expect(PairURI.parse("not a uri") == nil)
|
||||
}
|
||||
|
||||
// MARK: - Request encoding (must match crates/jcode-protocol/src/wire.rs)
|
||||
|
||||
private func encodedObject(_ request: Request) throws -> [String: Any] {
|
||||
let line = try request.encodedLine()
|
||||
let data = line.data(using: .utf8)!
|
||||
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
}
|
||||
|
||||
@Test func encodesMessageRequest() throws {
|
||||
let object = try encodedObject(.message(id: 7, content: "hello"))
|
||||
#expect(object["type"] as? String == "message")
|
||||
#expect(object["id"] as? UInt64 == 7)
|
||||
#expect(object["content"] as? String == "hello")
|
||||
}
|
||||
|
||||
@Test func encodesSubscribeWithTargetSession() throws {
|
||||
let object = try encodedObject(.subscribe(id: 1, targetSessionID: "sess_abc"))
|
||||
#expect(object["type"] as? String == "subscribe")
|
||||
#expect(object["target_session_id"] as? String == "sess_abc")
|
||||
|
||||
let bare = try encodedObject(.subscribe(id: 2, targetSessionID: nil))
|
||||
#expect(bare["target_session_id"] == nil)
|
||||
}
|
||||
|
||||
@Test func encodesControlRequests() throws {
|
||||
#expect(try encodedObject(.cancel(id: 3))["type"] as? String == "cancel")
|
||||
#expect(try encodedObject(.ping(id: 4))["type"] as? String == "ping")
|
||||
#expect(try encodedObject(.getHistory(id: 5))["type"] as? String == "get_history")
|
||||
#expect(try encodedObject(.clear(id: 6))["type"] as? String == "clear")
|
||||
#expect(
|
||||
try encodedObject(.cancelSoftInterrupts(id: 8))["type"] as? String
|
||||
== "cancel_soft_interrupts")
|
||||
|
||||
let soft = try encodedObject(.softInterrupt(id: 9, content: "also do x", urgent: true))
|
||||
#expect(soft["type"] as? String == "soft_interrupt")
|
||||
#expect(soft["content"] as? String == "also do x")
|
||||
#expect(soft["urgent"] as? Bool == true)
|
||||
|
||||
let resume = try encodedObject(.resumeSession(id: 10, sessionID: "sess_x"))
|
||||
#expect(resume["type"] as? String == "resume_session")
|
||||
#expect(resume["session_id"] as? String == "sess_x")
|
||||
|
||||
let model = try encodedObject(.setModel(id: 11, model: "claude-sonnet-4"))
|
||||
#expect(model["type"] as? String == "set_model")
|
||||
#expect(model["model"] as? String == "claude-sonnet-4")
|
||||
|
||||
let rename = try encodedObject(.renameSession(id: 12, title: "My session"))
|
||||
#expect(rename["type"] as? String == "rename_session")
|
||||
#expect(rename["title"] as? String == "My session")
|
||||
}
|
||||
|
||||
@Test func encodesReasoningEffortAndCompact() throws {
|
||||
let effort = try encodedObject(.setReasoningEffort(id: 13, effort: "high"))
|
||||
#expect(effort["type"] as? String == "set_reasoning_effort")
|
||||
#expect(effort["id"] as? UInt64 == 13)
|
||||
#expect(effort["effort"] as? String == "high")
|
||||
|
||||
let compact = try encodedObject(.compact(id: 14))
|
||||
#expect(compact["type"] as? String == "compact")
|
||||
#expect(compact["id"] as? UInt64 == 14)
|
||||
}
|
||||
|
||||
// MARK: - ServerEvent decoding (fixtures mirror real server output)
|
||||
|
||||
@Test func decodesStreamingEvents() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"text_delta","text":"Hel"}"#)
|
||||
== .textDelta(text: "Hel"))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"reasoning_delta","text":"hmm"}"#)
|
||||
== .reasoningDelta(text: "hmm"))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"reasoning_done","duration_secs":1.5}"#)
|
||||
== .reasoningDone(durationSecs: 1.5))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"text_replace","text":"clean"}"#)
|
||||
== .textReplace(text: "clean"))
|
||||
#expect(try ServerEvent.decode(line: #"{"type":"message_end"}"#) == .messageEnd)
|
||||
#expect(try ServerEvent.decode(line: #"{"type":"done","id":3}"#) == .done(id: 3))
|
||||
#expect(try ServerEvent.decode(line: #"{"type":"interrupted"}"#) == .interrupted)
|
||||
}
|
||||
|
||||
@Test func decodesToolLifecycle() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"tool_start","id":"t1","name":"bash"}"#)
|
||||
== .toolStart(id: "t1", name: "bash"))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"tool_input","delta":"{\"cmd\""}"#)
|
||||
== .toolInput(delta: "{\"cmd\""))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"tool_exec","id":"t1","name":"bash"}"#)
|
||||
== .toolExec(id: "t1", name: "bash"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"tool_done","id":"t1","name":"bash","output":"ok"}"#)
|
||||
== .toolDone(id: "t1", name: "bash", output: "ok", error: nil))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"tool_done","id":"t2","name":"bash","output":"","error":"boom"}"#)
|
||||
== .toolDone(id: "t2", name: "bash", output: "", error: "boom"))
|
||||
}
|
||||
|
||||
@Test func decodesErrorAndStatus() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"error","id":1,"message":"rate limited","retry_after_secs":30}"#)
|
||||
== .error(id: 1, message: "rate limited", retryAfterSecs: 30))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"tokens","input":1200,"output":340}"#)
|
||||
== .tokenUsage(input: 1200, output: 340))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"status_detail","detail":"thinking"}"#)
|
||||
== .statusDetail(detail: "thinking"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line:
|
||||
#"{"type":"state","id":2,"session_id":"s1","message_count":4,"is_processing":true}"#
|
||||
) == .state(id: 2, sessionID: "s1", messageCount: 4, isProcessing: true))
|
||||
}
|
||||
|
||||
@Test func decodesSessionEvents() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"session","session_id":"sess_1"}"#)
|
||||
== .sessionID(sessionID: "sess_1"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line:
|
||||
#"{"type":"session_renamed","session_id":"sess_1","display_title":"Fix bug"}"#)
|
||||
== .sessionRenamed(sessionID: "sess_1", displayTitle: "Fix bug"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"model_changed","id":5,"model":"gpt-5","provider_name":"openai"}"#)
|
||||
== .modelChanged(id: 5, model: "gpt-5", error: nil))
|
||||
}
|
||||
|
||||
@Test func decodesHistoryPayload() throws {
|
||||
let line = """
|
||||
{"type":"history","id":2,"session_id":"sess_9","messages":[\
|
||||
{"role":"user","content":"hi"},\
|
||||
{"role":"assistant","content":"hello!","tool_calls":["bash"]},\
|
||||
{"role":"assistant","content":"","tool_data":{"id":"t1","name":"read","input":"{}","output":"data"}}\
|
||||
],"provider_name":"anthropic","provider_model":"claude-sonnet-4",\
|
||||
"available_models":["claude-sonnet-4","claude-opus-4"],\
|
||||
"total_tokens":[1500,800],"all_sessions":["sess_9","sess_8"],\
|
||||
"server_version":"v0.26.11","display_title":"My chat"}
|
||||
"""
|
||||
guard case let .history(payload) = try ServerEvent.decode(line: line) else {
|
||||
Issue.record("expected history event")
|
||||
return
|
||||
}
|
||||
#expect(payload.sessionID == "sess_9")
|
||||
#expect(payload.messages.count == 3)
|
||||
#expect(payload.messages[0].role == "user")
|
||||
#expect(payload.messages[1].toolCalls == ["bash"])
|
||||
#expect(payload.messages[2].toolData?.name == "read")
|
||||
#expect(payload.providerModel == "claude-sonnet-4")
|
||||
#expect(payload.availableModels.count == 2)
|
||||
#expect(payload.totalTokens == .init(input: 1500, output: 800))
|
||||
#expect(payload.allSessions == ["sess_9", "sess_8"])
|
||||
#expect(payload.serverVersion == "v0.26.11")
|
||||
#expect(payload.displayTitle == "My chat")
|
||||
}
|
||||
|
||||
@Test func unknownEventTypesAreTolerated() throws {
|
||||
let event = try ServerEvent.decode(
|
||||
line: #"{"type":"some_future_event","payload":{"x":1}}"#)
|
||||
#expect(event == .unknown(type: "some_future_event"))
|
||||
}
|
||||
|
||||
@Test func decodesTurnLifecycleSignals() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"connection_phase","phase":"authenticating"}"#)
|
||||
== .connectionPhase(phase: "authenticating"))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"retry_rollback","attempt":2,"max":5}"#)
|
||||
== .retryRollback(attempt: 2, max: 5))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line:
|
||||
#"{"type":"soft_interrupt_injected","content":"also fix y","point":"C","tools_skipped":1}"#
|
||||
)
|
||||
== .softInterruptInjected(
|
||||
content: "also fix y", displayRole: nil, point: "C", toolsSkipped: 1))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line:
|
||||
#"{"type":"soft_interrupt_injected","content":"note","display_role":"system","point":"A"}"#
|
||||
)
|
||||
== .softInterruptInjected(
|
||||
content: "note", displayRole: "system", point: "A", toolsSkipped: nil))
|
||||
}
|
||||
|
||||
@Test func decodesEffortAndCompactResponses() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"reasoning_effort_changed","id":3,"effort":"high"}"#)
|
||||
== .reasoningEffortChanged(id: 3, effort: "high", error: nil))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"reasoning_effort_changed","id":4,"error":"unsupported"}"#)
|
||||
== .reasoningEffortChanged(id: 4, effort: nil, error: "unsupported"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"compact_result","id":5,"message":"Compaction started","success":true}"#
|
||||
)
|
||||
== .compactResult(id: 5, message: "Compaction started", success: true))
|
||||
}
|
||||
|
||||
@Test func decodesServerLifecycleEvents() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"reloading"}"#)
|
||||
== .reloading(newSocket: nil))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"reloading","new_socket":"/tmp/jcode.sock"}"#)
|
||||
== .reloading(newSocket: "/tmp/jcode.sock"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"session_close_requested","reason":"taken over"}"#)
|
||||
== .sessionCloseRequested(reason: "taken over"))
|
||||
}
|
||||
|
||||
@Test func historyCarriesReasoningEffort() throws {
|
||||
let line =
|
||||
#"{"type":"history","id":1,"session_id":"s","messages":[],"reasoning_effort":"medium"}"#
|
||||
guard case let .history(payload) = try ServerEvent.decode(line: line) else {
|
||||
Issue.record("expected history event")
|
||||
return
|
||||
}
|
||||
#expect(payload.reasoningEffort == "medium")
|
||||
}
|
||||
|
||||
@Test func malformedLinesThrow() {
|
||||
#expect(throws: WireError.self) {
|
||||
try ServerEvent.decode(line: "not json")
|
||||
}
|
||||
#expect(throws: WireError.self) {
|
||||
try ServerEvent.decode(line: #"{"no_type":true}"#)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
name: JCodeMobile
|
||||
options:
|
||||
bundleIdPrefix: com.jcode
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
generateEmptyDirectories: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
|
||||
packages:
|
||||
JCodeKit:
|
||||
path: .
|
||||
|
||||
targets:
|
||||
JCodeMobile:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Sources/JCodeMobile
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: Sources/JCodeMobile/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.jcode.mobile
|
||||
MARKETING_VERSION: "2.0.0"
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: TAS6ARKDN7
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
dependencies:
|
||||
- package: JCodeKit
|
||||
product: JCodeKit
|
||||
Reference in New Issue
Block a user