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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.build/
.build-ios/
JCodeMobile.xcodeproj/
DerivedData/
.DS_Store
# Python harness caches
TestHarness/__pycache__/
+23
View File
@@ -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"]
),
]
)
+209
View File
@@ -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)
}
}
+151
View File
@@ -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 }
}
}
}
+75
View File
@@ -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)
}
}
+77
View File
@@ -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
}
}
+528
View File
@@ -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()
}
}
}
}
+98
View File
@@ -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
}
+435
View File
@@ -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
}
}
+195
View File
@@ -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
}
}
+55
View File
@@ -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)
}
}
+103
View File
@@ -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)
}
}
+77
View File
@@ -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)
}
}
+110
View File
@@ -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.
+542
View File
@@ -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
+241
View File
@@ -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.
+138
View File
@@ -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.
View File
+224
View File
@@ -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()
+108
View File
@@ -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))
+114
View File
@@ -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),
},
)
+106
View File
@@ -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),
},
)
+98
View File
@@ -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),
},
)
+121
View File
@@ -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),
},
)
+274
View File
@@ -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()
+40
View File
@@ -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,
)
+91
View File
@@ -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"
+183
View File
@@ -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()
+323
View File
@@ -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()
+322
View File
@@ -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)
}
+272
View File
@@ -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}"#)
}
}
+37
View File
@@ -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