mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
iOS port M0d: extract 6 portable ViewModels to ScarfCore
Fourth and final M0 sub-PR. Wraps up the ScarfCore extraction with the
ViewModels that have no dependency on Mac-target services or AppKit.
Views deliberately stay in the Mac target — see plan for rationale.
Moved (6 VMs):
ActivityViewModel.swift — HermesDataService consumer, SQLite3-gated
ConnectionStatusViewModel.swift — @MainActor heartbeat for remote SSH
InsightsViewModel.swift — HermesDataService aggregator, SQLite3-gated
(+ InsightsPeriod, ModelUsage, PlatformUsage,
ToolUsage, NotableSession types; exports
free functions formatDuration/formatTokens)
LogsViewModel.swift — HermesLogService consumer, fully portable
(+ nested LogFile / LogComponent enums)
ProjectsViewModel.swift — ProjectDashboardService wrapper, portable
RichChatViewModel.swift — ~700 lines of ACP-event + message-group
handling, SQLite3-gated
(+ ChatDisplayMode, MessageGroup types)
Reverted in-flight:
GatewayViewModel.swift — my audit missed that it calls
`context.runHermes(...)`, a Mac-target-only extension. Not portable
without moving HermesFileService too. Left in the Mac target.
Platform guards applied:
- `#if canImport(SQLite3)` wraps entire files for ActivityVM, InsightsVM,
and RichChatVM (they transitively depend on HermesDataService).
- `#if canImport(Darwin)` around LocalizedStringResource displayName
in LogsViewModel's nested LogFile and LogComponent enums.
- `#if canImport(os)` around the unused Logger in
ConnectionStatusViewModel (kept the field for future use).
Swift 6 / Observation notes:
- `import Observation` explicitly added to each @Observable file.
Mac target gets Observation via SwiftUI; ScarfCore doesn't import
SwiftUI, so it needs the explicit module import. Observation ships
in the Swift 5.9+ standard library on every platform.
- Nested enums' `var id: String { rawValue }` had to be manually
promoted to `public var id` since my sed only touches 4-space-indent
declarations and the nested enum's members are at 8-space indent.
- Two accidentally-publicized function-local `let` variables in
InsightsViewModel reverted back to internal.
- Sed adjustment: an earlier pattern was producing `@Observable public`
which is a Swift syntax error. Fixed post-hoc by stripping the
stray trailing `public` after the attribute; noted in the plan file
as a checklist item for M1+ sed work.
Consumer import sweeps:
4 Mac-target files gained `import ScarfCore` for the moved VM types:
ContentView.swift, ChatView.swift, RichChatView.swift, and
ConnectionStatusPill.swift.
Test coverage (M0dViewModelsTests): 14 new tests.
- ConnectionStatusViewModel: local-always-connected, remote idle-start,
Status Equatable pinning.
- LogsViewModel: init defaults, filteredEntries across level / search /
component filters, nested enum Identifiable ids and loggerPrefix.
- ProjectsViewModel: .local context binding.
- (SQLite3-gated, Apple-only):
ActivityVM construction, InsightsVM period defaults and sinceDate
ordering, ChatDisplayMode case coverage, RichChatVM empty-state
invariants, MessageGroup derived properties.
Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 51 / 51 passing on Linux
(M0a 16 + M0b 18 + M0c 8 + M0d 9 + smoke 1 − 5 SQLite3-gated).
Apple-target CI should see 56 / 56 with the 5 gated tests added in.
Updated scarf/docs/IOS_PORT_PLAN.md with M0d's shipped state, the
Views-stay-Mac-only scope decision, and the sed-gotcha checklist
future phases should watch for.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
+32
-26
@@ -1,26 +1,30 @@
|
|||||||
|
// Gated on `canImport(SQLite3)` — `HermesDataService` only exists on
|
||||||
|
// Apple platforms (SQLite3 isn't a system module on Linux swift-corelibs).
|
||||||
|
#if canImport(SQLite3)
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import ScarfCore
|
import Observation
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ActivityViewModel {
|
public final class ActivityViewModel {
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
private let dataService: HermesDataService
|
private let dataService: HermesDataService
|
||||||
|
|
||||||
init(context: ServerContext = .local) {
|
public init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.dataService = HermesDataService(context: context)
|
self.dataService = HermesDataService(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var toolMessages: [HermesMessage] = []
|
public var toolMessages: [HermesMessage] = []
|
||||||
var filterKind: ToolKind?
|
public var filterKind: ToolKind?
|
||||||
var filterSessionId: String?
|
public var filterSessionId: String?
|
||||||
var selectedEntry: ActivityEntry?
|
public var selectedEntry: ActivityEntry?
|
||||||
var toolResult: String?
|
public var toolResult: String?
|
||||||
var sessionPreviews: [String: String] = [:]
|
public var sessionPreviews: [String: String] = [:]
|
||||||
var isLoading = true
|
public var isLoading = true
|
||||||
|
|
||||||
var availableSessions: [(id: String, label: String)] {
|
public var availableSessions: [(id: String, label: String)] {
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
return toolMessages.compactMap { message in
|
return toolMessages.compactMap { message in
|
||||||
guard seen.insert(message.sessionId).inserted else { return nil }
|
guard seen.insert(message.sessionId).inserted else { return nil }
|
||||||
@@ -29,7 +33,7 @@ final class ActivityViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var filteredActivity: [ActivityEntry] {
|
public var filteredActivity: [ActivityEntry] {
|
||||||
let entries = toolMessages.flatMap { message in
|
let entries = toolMessages.flatMap { message in
|
||||||
message.toolCalls.map { call in
|
message.toolCalls.map { call in
|
||||||
ActivityEntry(
|
ActivityEntry(
|
||||||
@@ -51,7 +55,7 @@ final class ActivityViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
public func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
// refresh() = close + reopen, which forces a fresh snapshot pull on
|
// refresh() = close + reopen, which forces a fresh snapshot pull on
|
||||||
// remote contexts. Using open() here would short-circuit after the
|
// remote contexts. Using open() here would short-circuit after the
|
||||||
@@ -68,7 +72,7 @@ final class ActivityViewModel {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectEntry(_ entry: ActivityEntry?) async {
|
public func selectEntry(_ entry: ActivityEntry?) async {
|
||||||
selectedEntry = entry
|
selectedEntry = entry
|
||||||
if let entry {
|
if let entry {
|
||||||
toolResult = await dataService.fetchToolResult(callId: entry.id)
|
toolResult = await dataService.fetchToolResult(callId: entry.id)
|
||||||
@@ -77,22 +81,22 @@ final class ActivityViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanup() async {
|
public func cleanup() async {
|
||||||
await dataService.close()
|
await dataService.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ActivityEntry: Identifiable, Sendable {
|
public struct ActivityEntry: Identifiable, Sendable {
|
||||||
let id: String
|
public let id: String
|
||||||
let sessionId: String
|
public let sessionId: String
|
||||||
let toolName: String
|
public let toolName: String
|
||||||
let kind: ToolKind
|
public let kind: ToolKind
|
||||||
let summary: String
|
public let summary: String
|
||||||
let arguments: String
|
public let arguments: String
|
||||||
let messageContent: String
|
public let messageContent: String
|
||||||
let timestamp: Date?
|
public let timestamp: Date?
|
||||||
|
|
||||||
var prettyArguments: String {
|
public var prettyArguments: String {
|
||||||
guard let data = arguments.data(using: .utf8),
|
guard let data = arguments.data(using: .utf8),
|
||||||
let json = try? JSONSerialization.jsonObject(with: data, options: []),
|
let json = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||||
let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]),
|
let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]),
|
||||||
@@ -102,3 +106,5 @@ struct ActivityEntry: Identifiable, Sendable {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif // canImport(SQLite3)
|
||||||
+12
-8
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ScarfCore
|
import Observation
|
||||||
|
#if canImport(os)
|
||||||
import os
|
import os
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Tracks connection health for the current window's server. Remote contexts
|
/// Tracks connection health for the current window's server. Remote contexts
|
||||||
/// get a lightweight 15s heartbeat (a no-op `true` remote command) that
|
/// get a lightweight 15s heartbeat (a no-op `true` remote command) that
|
||||||
@@ -8,10 +10,12 @@ import os
|
|||||||
/// green since there's no connection to lose.
|
/// green since there's no connection to lose.
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ConnectionStatusViewModel {
|
public final class ConnectionStatusViewModel {
|
||||||
|
#if canImport(os)
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "ConnectionStatus")
|
private let logger = Logger(subsystem: "com.scarf", category: "ConnectionStatus")
|
||||||
|
#endif
|
||||||
|
|
||||||
enum Status: Equatable {
|
public enum Status: Equatable {
|
||||||
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||||
case connected
|
case connected
|
||||||
/// SSH connects but the follow-up read-access probe failed. Data
|
/// SSH connects but the follow-up read-access probe failed. Data
|
||||||
@@ -37,11 +41,11 @@ final class ConnectionStatusViewModel {
|
|||||||
private(set) var consecutiveFailures = 0
|
private(set) var consecutiveFailures = 0
|
||||||
private let consecutiveFailureThreshold = 2
|
private let consecutiveFailureThreshold = 2
|
||||||
|
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
private let transport: any ServerTransport
|
private let transport: any ServerTransport
|
||||||
private var probeTask: Task<Void, Never>?
|
private var probeTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(context: ServerContext) {
|
public init(context: ServerContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.transport = context.makeTransport()
|
self.transport = context.makeTransport()
|
||||||
if !context.isRemote {
|
if !context.isRemote {
|
||||||
@@ -54,7 +58,7 @@ final class ConnectionStatusViewModel {
|
|||||||
|
|
||||||
/// Kick off a background heartbeat loop. Safe to call multiple times;
|
/// Kick off a background heartbeat loop. Safe to call multiple times;
|
||||||
/// subsequent calls cancel the prior task and restart.
|
/// subsequent calls cancel the prior task and restart.
|
||||||
func startMonitoring() {
|
public func startMonitoring() {
|
||||||
guard context.isRemote else { return }
|
guard context.isRemote else { return }
|
||||||
probeTask?.cancel()
|
probeTask?.cancel()
|
||||||
probeTask = Task { [weak self] in
|
probeTask = Task { [weak self] in
|
||||||
@@ -65,13 +69,13 @@ final class ConnectionStatusViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopMonitoring() {
|
public func stopMonitoring() {
|
||||||
probeTask?.cancel()
|
probeTask?.cancel()
|
||||||
probeTask = nil
|
probeTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manual probe — also invoked by the toolbar "Retry" button on error.
|
/// Manual probe — also invoked by the toolbar "Retry" button on error.
|
||||||
func retry() {
|
public func retry() {
|
||||||
Task { await probeOnce() }
|
Task { await probeOnce() }
|
||||||
}
|
}
|
||||||
|
|
||||||
+70
-62
@@ -1,15 +1,21 @@
|
|||||||
import Foundation
|
// Gated on `canImport(SQLite3)` because every non-trivial code path calls
|
||||||
import ScarfCore
|
// into `HermesDataService`, which itself is only compiled on Apple
|
||||||
|
// platforms (SQLite3 is not a system module on Linux swift-corelibs).
|
||||||
|
// iOS + macOS compile this unchanged; Linux CI skips it.
|
||||||
|
#if canImport(SQLite3)
|
||||||
|
|
||||||
enum InsightsPeriod: String, CaseIterable, Identifiable {
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
public enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||||
case week = "7 Days"
|
case week = "7 Days"
|
||||||
case month = "30 Days"
|
case month = "30 Days"
|
||||||
case quarter = "90 Days"
|
case quarter = "90 Days"
|
||||||
case all = "All Time"
|
case all = "All Time"
|
||||||
|
|
||||||
var id: String { rawValue }
|
public var id: String { rawValue }
|
||||||
|
|
||||||
var displayName: LocalizedStringResource {
|
public var displayName: LocalizedStringResource {
|
||||||
switch self {
|
switch self {
|
||||||
case .week: return "7 Days"
|
case .week: return "7 Days"
|
||||||
case .month: return "30 Days"
|
case .month: return "30 Days"
|
||||||
@@ -18,7 +24,7 @@ enum InsightsPeriod: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var sinceDate: Date {
|
public var sinceDate: Date {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
switch self {
|
switch self {
|
||||||
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
||||||
@@ -29,78 +35,78 @@ enum InsightsPeriod: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ModelUsage: Identifiable {
|
public struct ModelUsage: Identifiable {
|
||||||
var id: String { model }
|
public var id: String { model }
|
||||||
let model: String
|
public let model: String
|
||||||
let sessions: Int
|
public let sessions: Int
|
||||||
let inputTokens: Int
|
public let inputTokens: Int
|
||||||
let outputTokens: Int
|
public let outputTokens: Int
|
||||||
let cacheReadTokens: Int
|
public let cacheReadTokens: Int
|
||||||
let cacheWriteTokens: Int
|
public let cacheWriteTokens: Int
|
||||||
let reasoningTokens: Int
|
public let reasoningTokens: Int
|
||||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
public var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlatformUsage: Identifiable {
|
public struct PlatformUsage: Identifiable {
|
||||||
var id: String { platform }
|
public var id: String { platform }
|
||||||
let platform: String
|
public let platform: String
|
||||||
let sessions: Int
|
public let sessions: Int
|
||||||
let messages: Int
|
public let messages: Int
|
||||||
let tokens: Int
|
public let tokens: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ToolUsage: Identifiable {
|
public struct ToolUsage: Identifiable {
|
||||||
var id: String { name }
|
public var id: String { name }
|
||||||
let name: String
|
public let name: String
|
||||||
let count: Int
|
public let count: Int
|
||||||
let percentage: Double
|
public let percentage: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NotableSession: Identifiable {
|
public struct NotableSession: Identifiable {
|
||||||
var id: String { "\(session.id)-\(label)" }
|
public var id: String { "\(session.id)-\(label)" }
|
||||||
let label: String
|
public let label: String
|
||||||
let value: String
|
public let value: String
|
||||||
let session: HermesSession
|
public let session: HermesSession
|
||||||
let preview: String
|
public let preview: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class InsightsViewModel {
|
public final class InsightsViewModel {
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
private let dataService: HermesDataService
|
private let dataService: HermesDataService
|
||||||
|
|
||||||
init(context: ServerContext = .local) {
|
public init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.dataService = HermesDataService(context: context)
|
self.dataService = HermesDataService(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var period: InsightsPeriod = .month
|
public var period: InsightsPeriod = .month
|
||||||
var isLoading = true
|
public var isLoading = true
|
||||||
|
|
||||||
var sessions: [HermesSession] = []
|
public var sessions: [HermesSession] = []
|
||||||
var sessionPreviews: [String: String] = [:]
|
public var sessionPreviews: [String: String] = [:]
|
||||||
var userMessageCount = 0
|
public var userMessageCount = 0
|
||||||
var totalMessages = 0
|
public var totalMessages = 0
|
||||||
var totalToolCalls = 0
|
public var totalToolCalls = 0
|
||||||
var totalInputTokens = 0
|
public var totalInputTokens = 0
|
||||||
var totalOutputTokens = 0
|
public var totalOutputTokens = 0
|
||||||
var totalCacheReadTokens = 0
|
public var totalCacheReadTokens = 0
|
||||||
var totalCacheWriteTokens = 0
|
public var totalCacheWriteTokens = 0
|
||||||
var totalReasoningTokens = 0
|
public var totalReasoningTokens = 0
|
||||||
var totalTokens = 0
|
public var totalTokens = 0
|
||||||
var totalCost: Double = 0
|
public var totalCost: Double = 0
|
||||||
var activeTime: TimeInterval = 0
|
public var activeTime: TimeInterval = 0
|
||||||
var avgSessionDuration: TimeInterval = 0
|
public var avgSessionDuration: TimeInterval = 0
|
||||||
|
|
||||||
var modelUsage: [ModelUsage] = []
|
public var modelUsage: [ModelUsage] = []
|
||||||
var platformUsage: [PlatformUsage] = []
|
public var platformUsage: [PlatformUsage] = []
|
||||||
var toolUsage: [ToolUsage] = []
|
public var toolUsage: [ToolUsage] = []
|
||||||
var hourlyActivity: [Int: Int] = [:]
|
public var hourlyActivity: [Int: Int] = [:]
|
||||||
var dailyActivity: [Int: Int] = [:]
|
public var dailyActivity: [Int: Int] = [:]
|
||||||
var notableSessions: [NotableSession] = []
|
public var notableSessions: [NotableSession] = []
|
||||||
|
|
||||||
func load() async {
|
public func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
// refresh() forces a fresh remote snapshot each load. On local it's
|
// refresh() forces a fresh remote snapshot each load. On local it's
|
||||||
// a cheap reopen of the live DB.
|
// a cheap reopen of the live DB.
|
||||||
@@ -128,7 +134,7 @@ final class InsightsViewModel {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewFor(_ session: HermesSession) -> String {
|
public func previewFor(_ session: HermesSession) -> String {
|
||||||
if let title = session.title, !title.isEmpty { return title }
|
if let title = session.title, !title.isEmpty { return title }
|
||||||
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
|
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
|
||||||
return session.id
|
return session.id
|
||||||
@@ -240,7 +246,7 @@ final class InsightsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatDuration(_ interval: TimeInterval) -> String {
|
public func formatDuration(_ interval: TimeInterval) -> String {
|
||||||
let hours = Int(interval) / 3600
|
let hours = Int(interval) / 3600
|
||||||
let minutes = (Int(interval) % 3600) / 60
|
let minutes = (Int(interval) % 3600) / 60
|
||||||
if hours > 0 {
|
if hours > 0 {
|
||||||
@@ -249,7 +255,7 @@ func formatDuration(_ interval: TimeInterval) -> String {
|
|||||||
return "\(minutes)m"
|
return "\(minutes)m"
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatTokens(_ count: Int) -> String {
|
public func formatTokens(_ count: Int) -> String {
|
||||||
if count >= 1_000_000 {
|
if count >= 1_000_000 {
|
||||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||||
} else if count >= 1_000 {
|
} else if count >= 1_000 {
|
||||||
@@ -257,3 +263,5 @@ func formatTokens(_ count: Int) -> String {
|
|||||||
}
|
}
|
||||||
return "\(count)"
|
return "\(count)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif // canImport(SQLite3)
|
||||||
+26
-22
@@ -1,37 +1,39 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ScarfCore
|
import Observation
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class LogsViewModel {
|
public final class LogsViewModel {
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
private let logService: HermesLogService
|
private let logService: HermesLogService
|
||||||
|
|
||||||
init(context: ServerContext = .local) {
|
public init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.logService = HermesLogService(context: context)
|
self.logService = HermesLogService(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
var entries: [LogEntry] = []
|
public var entries: [LogEntry] = []
|
||||||
var selectedLogFile: LogFile = .agent
|
public var selectedLogFile: LogFile = .agent
|
||||||
var filterLevel: LogEntry.LogLevel?
|
public var filterLevel: LogEntry.LogLevel?
|
||||||
var selectedComponent: LogComponent = .all
|
public var selectedComponent: LogComponent = .all
|
||||||
var searchText = ""
|
public var searchText = ""
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
|
|
||||||
enum LogFile: String, CaseIterable, Identifiable {
|
public enum LogFile: String, CaseIterable, Identifiable {
|
||||||
case agent = "agent.log"
|
case agent = "agent.log"
|
||||||
case errors = "errors.log"
|
case errors = "errors.log"
|
||||||
case gateway = "gateway.log"
|
case gateway = "gateway.log"
|
||||||
|
|
||||||
var id: String { rawValue }
|
public var id: String { rawValue }
|
||||||
|
|
||||||
var displayName: LocalizedStringResource {
|
#if canImport(Darwin)
|
||||||
|
public var displayName: LocalizedStringResource {
|
||||||
switch self {
|
switch self {
|
||||||
case .agent: return "Agent"
|
case .agent: return "Agent"
|
||||||
case .errors: return "Errors"
|
case .errors: return "Errors"
|
||||||
case .gateway: return "Gateway"
|
case .gateway: return "Gateway"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func path(for file: LogFile) -> String {
|
private func path(for file: LogFile) -> String {
|
||||||
@@ -42,7 +44,7 @@ final class LogsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogComponent: String, CaseIterable, Identifiable {
|
public enum LogComponent: String, CaseIterable, Identifiable {
|
||||||
case all = "All"
|
case all = "All"
|
||||||
case gateway = "Gateway"
|
case gateway = "Gateway"
|
||||||
case agent = "Agent"
|
case agent = "Agent"
|
||||||
@@ -50,9 +52,10 @@ final class LogsViewModel {
|
|||||||
case cli = "CLI"
|
case cli = "CLI"
|
||||||
case cron = "Cron"
|
case cron = "Cron"
|
||||||
|
|
||||||
var id: String { rawValue }
|
public var id: String { rawValue }
|
||||||
|
|
||||||
var displayName: LocalizedStringResource {
|
#if canImport(Darwin)
|
||||||
|
public var displayName: LocalizedStringResource {
|
||||||
switch self {
|
switch self {
|
||||||
case .all: return "All"
|
case .all: return "All"
|
||||||
case .gateway: return "Gateway"
|
case .gateway: return "Gateway"
|
||||||
@@ -62,8 +65,9 @@ final class LogsViewModel {
|
|||||||
case .cron: return "Cron"
|
case .cron: return "Cron"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
var loggerPrefix: String? {
|
public var loggerPrefix: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .all: return nil
|
case .all: return nil
|
||||||
case .gateway: return "gateway"
|
case .gateway: return "gateway"
|
||||||
@@ -75,7 +79,7 @@ final class LogsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var filteredEntries: [LogEntry] {
|
public var filteredEntries: [LogEntry] {
|
||||||
entries.filter { entry in
|
entries.filter { entry in
|
||||||
let levelOk = filterLevel == nil || entry.level == filterLevel
|
let levelOk = filterLevel == nil || entry.level == filterLevel
|
||||||
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
||||||
@@ -87,14 +91,14 @@ final class LogsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
public func load() async {
|
||||||
await logService.openLog(path: path(for: selectedLogFile))
|
await logService.openLog(path: path(for: selectedLogFile))
|
||||||
entries = await logService.readLastLines(count: 500)
|
entries = await logService.readLastLines(count: 500)
|
||||||
await logService.seekToEnd()
|
await logService.seekToEnd()
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchLogFile(_ file: LogFile) async {
|
public func switchLogFile(_ file: LogFile) async {
|
||||||
selectedLogFile = file
|
selectedLogFile = file
|
||||||
entries = []
|
entries = []
|
||||||
await logService.openLog(path: path(for: file))
|
await logService.openLog(path: path(for: file))
|
||||||
@@ -102,7 +106,7 @@ final class LogsViewModel {
|
|||||||
await logService.seekToEnd()
|
await logService.seekToEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startPolling() {
|
public func startPolling() {
|
||||||
pollTimer?.invalidate()
|
pollTimer?.invalidate()
|
||||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
pollTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -115,12 +119,12 @@ final class LogsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopPolling() {
|
public func stopPolling() {
|
||||||
pollTimer?.invalidate()
|
pollTimer?.invalidate()
|
||||||
pollTimer = nil
|
pollTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanup() async {
|
public func cleanup() async {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
await logService.closeLog()
|
await logService.closeLog()
|
||||||
}
|
}
|
||||||
+15
-16
@@ -1,26 +1,25 @@
|
|||||||
import Foundation
|
import Observation
|
||||||
import ScarfCore
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ProjectsViewModel {
|
public final class ProjectsViewModel {
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
private let service: ProjectDashboardService
|
private let service: ProjectDashboardService
|
||||||
|
|
||||||
init(context: ServerContext = .local) {
|
public init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.service = ProjectDashboardService(context: context)
|
self.service = ProjectDashboardService(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var projects: [ProjectEntry] = []
|
public var projects: [ProjectEntry] = []
|
||||||
var selectedProject: ProjectEntry?
|
public var selectedProject: ProjectEntry?
|
||||||
var dashboard: ProjectDashboard?
|
public var dashboard: ProjectDashboard?
|
||||||
var dashboardError: String?
|
public var dashboardError: String?
|
||||||
var isLoading = false
|
public var isLoading = false
|
||||||
|
|
||||||
func load() {
|
public func load() {
|
||||||
let registry = service.loadRegistry()
|
let registry = service.loadRegistry()
|
||||||
projects = registry.projects
|
projects = registry.projects
|
||||||
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
|
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
|
||||||
@@ -32,12 +31,12 @@ final class ProjectsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectProject(_ project: ProjectEntry) {
|
public func selectProject(_ project: ProjectEntry) {
|
||||||
selectedProject = project
|
selectedProject = project
|
||||||
loadDashboard(for: project)
|
loadDashboard(for: project)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addProject(name: String, path: String) {
|
public func addProject(name: String, path: String) {
|
||||||
var registry = service.loadRegistry()
|
var registry = service.loadRegistry()
|
||||||
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||||
let entry = ProjectEntry(name: name, path: path)
|
let entry = ProjectEntry(name: name, path: path)
|
||||||
@@ -59,7 +58,7 @@ final class ProjectsViewModel {
|
|||||||
selectProject(entry)
|
selectProject(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeProject(_ project: ProjectEntry) {
|
public func removeProject(_ project: ProjectEntry) {
|
||||||
var registry = service.loadRegistry()
|
var registry = service.loadRegistry()
|
||||||
registry.projects.removeAll { $0.name == project.name }
|
registry.projects.removeAll { $0.name == project.name }
|
||||||
do {
|
do {
|
||||||
@@ -74,12 +73,12 @@ final class ProjectsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshDashboard() {
|
public func refreshDashboard() {
|
||||||
guard let project = selectedProject else { return }
|
guard let project = selectedProject else { return }
|
||||||
loadDashboard(for: project)
|
loadDashboard(for: project)
|
||||||
}
|
}
|
||||||
|
|
||||||
var dashboardPaths: [String] {
|
public var dashboardPaths: [String] {
|
||||||
projects.map(\.dashboardPath)
|
projects.map(\.dashboardPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
+45
-38
@@ -1,48 +1,53 @@
|
|||||||
import Foundation
|
// Gated on `canImport(SQLite3)` — `RichChatViewModel` reads message
|
||||||
import ScarfCore
|
// history from `HermesDataService`, which is SQLite-gated. iOS + macOS
|
||||||
|
// compile this unchanged; Linux CI skips it.
|
||||||
|
#if canImport(SQLite3)
|
||||||
|
|
||||||
enum ChatDisplayMode: String, CaseIterable {
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
public enum ChatDisplayMode: String, CaseIterable {
|
||||||
case terminal
|
case terminal
|
||||||
case richChat
|
case richChat
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MessageGroup: Identifiable {
|
public struct MessageGroup: Identifiable {
|
||||||
let id: Int
|
public let id: Int
|
||||||
let userMessage: HermesMessage?
|
public let userMessage: HermesMessage?
|
||||||
let assistantMessages: [HermesMessage]
|
public let assistantMessages: [HermesMessage]
|
||||||
let toolResults: [String: HermesMessage]
|
public let toolResults: [String: HermesMessage]
|
||||||
|
|
||||||
var allMessages: [HermesMessage] {
|
public var allMessages: [HermesMessage] {
|
||||||
var result: [HermesMessage] = []
|
var result: [HermesMessage] = []
|
||||||
if let user = userMessage { result.append(user) }
|
if let user = userMessage { result.append(user) }
|
||||||
result.append(contentsOf: assistantMessages)
|
result.append(contentsOf: assistantMessages)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
var toolCallCount: Int {
|
public var toolCallCount: Int {
|
||||||
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
|
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class RichChatViewModel {
|
public final class RichChatViewModel {
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
private let dataService: HermesDataService
|
private let dataService: HermesDataService
|
||||||
|
|
||||||
init(context: ServerContext = .local) {
|
public init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.dataService = HermesDataService(context: context)
|
self.dataService = HermesDataService(context: context)
|
||||||
loadQuickCommands()
|
loadQuickCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var messages: [HermesMessage] = []
|
public var messages: [HermesMessage] = []
|
||||||
var currentSession: HermesSession?
|
public var currentSession: HermesSession?
|
||||||
var messageGroups: [MessageGroup] = []
|
public var messageGroups: [MessageGroup] = []
|
||||||
var isAgentWorking = false
|
public var isAgentWorking = false
|
||||||
var pendingPermission: PendingPermission?
|
public var pendingPermission: PendingPermission?
|
||||||
/// Mutated to trigger a scroll-to-bottom in the message list.
|
/// Mutated to trigger a scroll-to-bottom in the message list.
|
||||||
var scrollTrigger = UUID()
|
public var scrollTrigger = UUID()
|
||||||
|
|
||||||
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
|
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
|
||||||
private(set) var acpInputTokens = 0
|
private(set) var acpInputTokens = 0
|
||||||
@@ -56,20 +61,20 @@ final class RichChatViewModel {
|
|||||||
private(set) var quickCommands: [HermesSlashCommand] = []
|
private(set) var quickCommands: [HermesSlashCommand] = []
|
||||||
|
|
||||||
/// Merged list, ACP-first, de-duplicated by name.
|
/// Merged list, ACP-first, de-duplicated by name.
|
||||||
var availableCommands: [HermesSlashCommand] {
|
public var availableCommands: [HermesSlashCommand] {
|
||||||
let acpNames = Set(acpCommands.map(\.name))
|
let acpNames = Set(acpCommands.map(\.name))
|
||||||
return acpCommands + quickCommands.filter { !acpNames.contains($0.name) }
|
return acpCommands + quickCommands.filter { !acpNames.contains($0.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
|
public var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
|
||||||
|
|
||||||
/// True when the menu carries more than just `/compress` — used to hide
|
/// True when the menu carries more than just `/compress` — used to hide
|
||||||
/// the dedicated compress button in favor of the full slash menu.
|
/// the dedicated compress button in favor of the full slash menu.
|
||||||
var hasBroaderCommandMenu: Bool { availableCommands.count > 1 }
|
public var hasBroaderCommandMenu: Bool { availableCommands.count > 1 }
|
||||||
|
|
||||||
var hasMessages: Bool { !messages.isEmpty }
|
public var hasMessages: Bool { !messages.isEmpty }
|
||||||
|
|
||||||
func requestScrollToBottom() {
|
public func requestScrollToBottom() {
|
||||||
scrollTrigger = UUID()
|
scrollTrigger = UUID()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +94,7 @@ final class RichChatViewModel {
|
|||||||
private var userSendPending = false
|
private var userSendPending = false
|
||||||
private var activePollingTimer: Timer?
|
private var activePollingTimer: Timer?
|
||||||
|
|
||||||
struct PendingPermission {
|
public struct PendingPermission {
|
||||||
let requestId: Int
|
let requestId: Int
|
||||||
let title: String
|
let title: String
|
||||||
let kind: String
|
let kind: String
|
||||||
@@ -98,7 +103,7 @@ final class RichChatViewModel {
|
|||||||
|
|
||||||
// MARK: - Reset
|
// MARK: - Reset
|
||||||
|
|
||||||
func reset() {
|
public func reset() {
|
||||||
debounceTask?.cancel()
|
debounceTask?.cancel()
|
||||||
stopActivePolling()
|
stopActivePolling()
|
||||||
Task { await dataService.close() }
|
Task { await dataService.close() }
|
||||||
@@ -124,19 +129,19 @@ final class RichChatViewModel {
|
|||||||
loadQuickCommands()
|
loadQuickCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionId(_ id: String?) {
|
public func setSessionId(_ id: String?) {
|
||||||
sessionId = id
|
sessionId = id
|
||||||
lastKnownFingerprint = nil
|
lastKnownFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanup() async {
|
public func cleanup() async {
|
||||||
stopActivePolling()
|
stopActivePolling()
|
||||||
debounceTask?.cancel()
|
debounceTask?.cancel()
|
||||||
await dataService.close()
|
await dataService.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
||||||
func refreshSessionFromDB() async {
|
public func refreshSessionFromDB() async {
|
||||||
guard let sessionId else { return }
|
guard let sessionId else { return }
|
||||||
let opened = await dataService.open()
|
let opened = await dataService.open()
|
||||||
guard opened else { return }
|
guard opened else { return }
|
||||||
@@ -149,7 +154,7 @@ final class RichChatViewModel {
|
|||||||
// MARK: - ACP Event Handling
|
// MARK: - ACP Event Handling
|
||||||
|
|
||||||
/// Add a user message immediately (before DB write) for instant UI feedback.
|
/// Add a user message immediately (before DB write) for instant UI feedback.
|
||||||
func addUserMessage(text: String) {
|
public func addUserMessage(text: String) {
|
||||||
let id = nextLocalId
|
let id = nextLocalId
|
||||||
nextLocalId -= 1
|
nextLocalId -= 1
|
||||||
let message = HermesMessage(
|
let message = HermesMessage(
|
||||||
@@ -179,7 +184,7 @@ final class RichChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Process a streaming ACP event and update the message list.
|
/// Process a streaming ACP event and update the message list.
|
||||||
func handleACPEvent(_ event: ACPEvent) {
|
public func handleACPEvent(_ event: ACPEvent) {
|
||||||
switch event {
|
switch event {
|
||||||
case .messageChunk(_, let text):
|
case .messageChunk(_, let text):
|
||||||
appendMessageChunk(text: text)
|
appendMessageChunk(text: text)
|
||||||
@@ -233,7 +238,7 @@ final class RichChatViewModel {
|
|||||||
|
|
||||||
/// Load `quick_commands` from `config.yaml` off the main actor and publish
|
/// Load `quick_commands` from `config.yaml` off the main actor and publish
|
||||||
/// them as slash commands. Safe to call repeatedly — replaces the existing list.
|
/// them as slash commands. Safe to call repeatedly — replaces the existing list.
|
||||||
func loadQuickCommands() {
|
public func loadQuickCommands() {
|
||||||
let ctx = context
|
let ctx = context
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
|
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
|
||||||
@@ -439,7 +444,7 @@ final class RichChatViewModel {
|
|||||||
|
|
||||||
/// Finalize streaming state on disconnect, before reconnection attempts begin.
|
/// Finalize streaming state on disconnect, before reconnection attempts begin.
|
||||||
/// Saves partial content as a permanent message without adding a system message.
|
/// Saves partial content as a permanent message without adding a system message.
|
||||||
func finalizeOnDisconnect() {
|
public func finalizeOnDisconnect() {
|
||||||
finalizeStreamingMessage()
|
finalizeStreamingMessage()
|
||||||
isAgentWorking = false
|
isAgentWorking = false
|
||||||
pendingPermission = nil
|
pendingPermission = nil
|
||||||
@@ -449,7 +454,7 @@ final class RichChatViewModel {
|
|||||||
/// Reconcile in-memory messages with DB state after a successful reconnection.
|
/// Reconcile in-memory messages with DB state after a successful reconnection.
|
||||||
/// Merges DB-persisted messages with any local-only messages (e.g., user messages
|
/// Merges DB-persisted messages with any local-only messages (e.g., user messages
|
||||||
/// that the ACP process may not have persisted before crashing).
|
/// that the ACP process may not have persisted before crashing).
|
||||||
func reconcileWithDB(sessionId: String) async {
|
public func reconcileWithDB(sessionId: String) async {
|
||||||
let opened = await dataService.open()
|
let opened = await dataService.open()
|
||||||
guard opened else { return }
|
guard opened else { return }
|
||||||
|
|
||||||
@@ -497,7 +502,7 @@ final class RichChatViewModel {
|
|||||||
|
|
||||||
/// Load message history from the DB, optionally combining an origin session
|
/// Load message history from the DB, optionally combining an origin session
|
||||||
/// (e.g., CLI session) with the current ACP session.
|
/// (e.g., CLI session) with the current ACP session.
|
||||||
func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
public func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
// Force a fresh snapshot pull on remote contexts. An earlier open()
|
// Force a fresh snapshot pull on remote contexts. An earlier open()
|
||||||
// would have cached a stale copy — on resume we need whatever
|
// would have cached a stale copy — on resume we need whatever
|
||||||
@@ -530,13 +535,13 @@ final class RichChatViewModel {
|
|||||||
|
|
||||||
// MARK: - DB Polling (terminal mode fallback)
|
// MARK: - DB Polling (terminal mode fallback)
|
||||||
|
|
||||||
func markAgentWorking() {
|
public func markAgentWorking() {
|
||||||
isAgentWorking = true
|
isAgentWorking = true
|
||||||
userSendPending = true
|
userSendPending = true
|
||||||
startActivePolling()
|
startActivePolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleRefresh() {
|
public func scheduleRefresh() {
|
||||||
debounceTask?.cancel()
|
debounceTask?.cancel()
|
||||||
debounceTask = Task { @MainActor [weak self] in
|
debounceTask = Task { @MainActor [weak self] in
|
||||||
try? await Task.sleep(for: .milliseconds(100))
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
@@ -545,7 +550,7 @@ final class RichChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshMessages() async {
|
public func refreshMessages() async {
|
||||||
// Polling tick (terminal mode): pull a fresh snapshot so remote
|
// Polling tick (terminal mode): pull a fresh snapshot so remote
|
||||||
// reflects Hermes writes since the last tick. On local this is a
|
// reflects Hermes writes since the last tick. On local this is a
|
||||||
// cheap reopen of the live DB.
|
// cheap reopen of the live DB.
|
||||||
@@ -668,3 +673,5 @@ final class RichChatViewModel {
|
|||||||
messageGroups = groups
|
messageGroups = groups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif // canImport(SQLite3)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ScarfCore
|
||||||
|
|
||||||
|
/// Exercises the portable ViewModels moved in M0d.
|
||||||
|
///
|
||||||
|
/// Three of the six VMs (`ActivityViewModel`, `InsightsViewModel`,
|
||||||
|
/// `RichChatViewModel`) are gated on `#if canImport(SQLite3)` because they
|
||||||
|
/// depend on `HermesDataService`. Tests for those are inside the same gate
|
||||||
|
/// so Linux CI compiles without them; Apple-target CI covers them fully.
|
||||||
|
@Suite struct M0dViewModelsTests {
|
||||||
|
|
||||||
|
// MARK: - ConnectionStatusViewModel (no SQLite3 dep)
|
||||||
|
|
||||||
|
@Test @MainActor func connectionStatusLocalContextIsAlwaysConnected() {
|
||||||
|
let vm = ConnectionStatusViewModel(context: .local)
|
||||||
|
#expect(vm.status == .connected)
|
||||||
|
#expect(vm.lastSuccess != nil)
|
||||||
|
#expect(vm.context.id == ServerContext.local.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func connectionStatusRemoteStartsIdle() {
|
||||||
|
let ctx = ServerContext(
|
||||||
|
id: UUID(),
|
||||||
|
displayName: "r",
|
||||||
|
kind: .ssh(SSHConfig(host: "nonexistent.invalid"))
|
||||||
|
)
|
||||||
|
let vm = ConnectionStatusViewModel(context: ctx)
|
||||||
|
#expect(vm.status == .idle)
|
||||||
|
#expect(vm.lastSuccess == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func connectionStatusEquatable() {
|
||||||
|
// The pill's Equatable conformance on Status drives UI re-render
|
||||||
|
// suppression. Pin the expected behaviour.
|
||||||
|
let a: ConnectionStatusViewModel.Status = .connected
|
||||||
|
let b: ConnectionStatusViewModel.Status = .connected
|
||||||
|
#expect(a == b)
|
||||||
|
|
||||||
|
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x")
|
||||||
|
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x")
|
||||||
|
#expect(c == d)
|
||||||
|
|
||||||
|
let e: ConnectionStatusViewModel.Status = .idle
|
||||||
|
#expect(a != c)
|
||||||
|
#expect(a != e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LogsViewModel (HermesLogService dep — portable)
|
||||||
|
|
||||||
|
@Test @MainActor func logsViewModelInitsWithLocalContext() {
|
||||||
|
let vm = LogsViewModel(context: .local)
|
||||||
|
#expect(vm.context.id == ServerContext.local.id)
|
||||||
|
#expect(vm.entries.isEmpty)
|
||||||
|
#expect(vm.selectedLogFile == .agent)
|
||||||
|
#expect(vm.filterLevel == nil)
|
||||||
|
#expect(vm.selectedComponent == .all)
|
||||||
|
#expect(vm.searchText == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func logsViewModelFilteredEntriesByLevel() {
|
||||||
|
let vm = LogsViewModel(context: .local)
|
||||||
|
vm.entries = [
|
||||||
|
LogEntry(id: 1, timestamp: "t", level: .info, sessionId: nil, logger: "a", message: "m", raw: "r"),
|
||||||
|
LogEntry(id: 2, timestamp: "t", level: .error, sessionId: nil, logger: "a", message: "boom", raw: "r"),
|
||||||
|
LogEntry(id: 3, timestamp: "t", level: .debug, sessionId: nil, logger: "a", message: "d", raw: "r"),
|
||||||
|
]
|
||||||
|
vm.filterLevel = .error
|
||||||
|
let filtered = vm.filteredEntries
|
||||||
|
#expect(filtered.count == 1)
|
||||||
|
#expect(filtered.first?.level == .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func logsViewModelFilteredEntriesBySearch() {
|
||||||
|
let vm = LogsViewModel(context: .local)
|
||||||
|
vm.entries = [
|
||||||
|
LogEntry(id: 1, timestamp: "t", level: .info, sessionId: nil, logger: "a", message: "connecting to db", raw: "connecting to db"),
|
||||||
|
LogEntry(id: 2, timestamp: "t", level: .info, sessionId: nil, logger: "a", message: "starting agent", raw: "starting agent"),
|
||||||
|
]
|
||||||
|
vm.searchText = "agent"
|
||||||
|
#expect(vm.filteredEntries.count == 1)
|
||||||
|
#expect(vm.filteredEntries.first?.message.contains("agent") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func logsViewModelFilteredEntriesByComponent() {
|
||||||
|
let vm = LogsViewModel(context: .local)
|
||||||
|
vm.entries = [
|
||||||
|
LogEntry(id: 1, timestamp: "t", level: .info, sessionId: nil, logger: "gateway.main", message: "up", raw: "r"),
|
||||||
|
LogEntry(id: 2, timestamp: "t", level: .info, sessionId: nil, logger: "agent.loop", message: "tick", raw: "r"),
|
||||||
|
LogEntry(id: 3, timestamp: "t", level: .info, sessionId: nil, logger: "tools.compile", message: "done", raw: "r"),
|
||||||
|
]
|
||||||
|
vm.selectedComponent = .gateway
|
||||||
|
let gateway = vm.filteredEntries
|
||||||
|
#expect(gateway.count == 1)
|
||||||
|
#expect(gateway.first?.logger == "gateway.main")
|
||||||
|
|
||||||
|
vm.selectedComponent = .all
|
||||||
|
#expect(vm.filteredEntries.count == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func logsViewModelEnumsIdentifiable() {
|
||||||
|
for f in LogsViewModel.LogFile.allCases {
|
||||||
|
#expect(f.id == f.rawValue)
|
||||||
|
}
|
||||||
|
for c in LogsViewModel.LogComponent.allCases {
|
||||||
|
#expect(c.id == c.rawValue)
|
||||||
|
}
|
||||||
|
#expect(LogsViewModel.LogComponent.all.loggerPrefix == nil)
|
||||||
|
#expect(LogsViewModel.LogComponent.gateway.loggerPrefix == "gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ProjectsViewModel (ProjectDashboardService dep — portable)
|
||||||
|
|
||||||
|
@Test @MainActor func projectsViewModelInits() {
|
||||||
|
let vm = ProjectsViewModel(context: .local)
|
||||||
|
#expect(vm.context.id == ServerContext.local.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Activity / Insights / RichChat — only on Apple targets
|
||||||
|
|
||||||
|
#if canImport(SQLite3)
|
||||||
|
|
||||||
|
@Test @MainActor func activityViewModelInits() {
|
||||||
|
let vm = ActivityViewModel(context: .local)
|
||||||
|
#expect(vm.context.id == ServerContext.local.id)
|
||||||
|
#expect(vm.entries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func insightsViewModelInits() {
|
||||||
|
let vm = InsightsViewModel(context: .local)
|
||||||
|
#expect(vm.context.id == ServerContext.local.id)
|
||||||
|
#expect(vm.period == .month)
|
||||||
|
#expect(vm.isLoading == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func insightsPeriodSinceDateIsSane() {
|
||||||
|
let now = Date()
|
||||||
|
let week = InsightsPeriod.week.sinceDate
|
||||||
|
let month = InsightsPeriod.month.sinceDate
|
||||||
|
let quarter = InsightsPeriod.quarter.sinceDate
|
||||||
|
let all = InsightsPeriod.all.sinceDate
|
||||||
|
// Ordering: all < quarter < month < week < now.
|
||||||
|
#expect(all < quarter)
|
||||||
|
#expect(quarter < month)
|
||||||
|
#expect(month < week)
|
||||||
|
#expect(week < now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func chatDisplayModeCases() {
|
||||||
|
#expect(ChatDisplayMode.allCases.count == 2)
|
||||||
|
#expect(ChatDisplayMode.allCases.contains(.terminal))
|
||||||
|
#expect(ChatDisplayMode.allCases.contains(.richChat))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func richChatViewModelInitsEmpty() {
|
||||||
|
let vm = RichChatViewModel(context: .local)
|
||||||
|
#expect(vm.context.id == ServerContext.local.id)
|
||||||
|
#expect(vm.messages.isEmpty)
|
||||||
|
#expect(vm.isAgentWorking == false)
|
||||||
|
#expect(vm.hasMessages == false)
|
||||||
|
// supportsCompress defers to `availableCommands`, which is empty at
|
||||||
|
// start → false.
|
||||||
|
#expect(vm.supportsCompress == false)
|
||||||
|
#expect(vm.hasBroaderCommandMenu == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func messageGroupDerivedProperties() {
|
||||||
|
let userMsg = HermesMessage(
|
||||||
|
id: 1, sessionId: "s", role: "user", content: "hi",
|
||||||
|
toolCallId: nil, toolCalls: [], toolName: nil,
|
||||||
|
timestamp: nil, tokenCount: nil, finishReason: nil, reasoning: nil
|
||||||
|
)
|
||||||
|
let toolCall = HermesToolCall(callId: "c1", functionName: "read_file", arguments: "{}")
|
||||||
|
let asstMsg = HermesMessage(
|
||||||
|
id: 2, sessionId: "s", role: "assistant", content: "here",
|
||||||
|
toolCallId: nil, toolCalls: [toolCall], toolName: nil,
|
||||||
|
timestamp: nil, tokenCount: nil, finishReason: nil, reasoning: nil
|
||||||
|
)
|
||||||
|
let group = MessageGroup(
|
||||||
|
id: 1, userMessage: userMsg, assistantMessages: [asstMsg], toolResults: [:]
|
||||||
|
)
|
||||||
|
#expect(group.allMessages.count == 2)
|
||||||
|
#expect(group.toolCallCount == 1)
|
||||||
|
|
||||||
|
let emptyGroup = MessageGroup(id: 0, userMessage: nil, assistantMessages: [], toolResults: [:])
|
||||||
|
#expect(emptyGroup.allMessages.isEmpty)
|
||||||
|
#expect(emptyGroup.toolCallCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // canImport(SQLite3)
|
||||||
|
}
|
||||||
@@ -369,7 +369,47 @@ stderr patterns, and round-trip an actual local file through
|
|||||||
`ServerContext.local.id`. Test helpers in ScarfCoreTests lean on this
|
`ServerContext.local.id`. Test helpers in ScarfCoreTests lean on this
|
||||||
heavily.
|
heavily.
|
||||||
|
|
||||||
### M0d — pending
|
### M0d — shipped
|
||||||
|
|
||||||
|
**Scope decision:** ViewModels only; **Views stay in the Mac target** for now. SwiftUI Views have heavy cross-feature coupling (AppCoordinator navigation, sidebar integration), AppKit-dependent widgets (NSOpenPanel, NSWorkspace.open for "reveal in Finder"), and platform-specific layout idioms that iPhone should re-implement rather than inherit. The Mac target will keep its current Views; M3+ builds fresh iOS Views on top of the shared ViewModels.
|
||||||
|
|
||||||
|
**Moved (6 ViewModels):**
|
||||||
|
|
||||||
|
- `ActivityViewModel.swift` — wraps `HermesDataService.fetchToolCalls`. Gated on `#if canImport(SQLite3)`.
|
||||||
|
- `ConnectionStatusViewModel.swift` — heartbeat for remote SSH health; `@MainActor @Observable`.
|
||||||
|
- `InsightsViewModel.swift` — aggregates over sessions via `HermesDataService`. Also exports `InsightsPeriod`, `ModelUsage`, `PlatformUsage`, `ToolUsage`, `NotableSession` and the free functions `formatDuration(_:)` / `formatTokens(_:)`. Gated on `#if canImport(SQLite3)`.
|
||||||
|
- `LogsViewModel.swift` — log tail + filter state (level, component, search). Uses only `HermesLogService`; no SQLite3 gate needed. Exposes `LogFile` and `LogComponent` nested enums with `#if canImport(Darwin)`-guarded `LocalizedStringResource` display names.
|
||||||
|
- `ProjectsViewModel.swift` — wraps `ProjectDashboardService`. Fully portable.
|
||||||
|
- `RichChatViewModel.swift` — ~700 lines of ACP-event + message-group handling. Gated on `#if canImport(SQLite3)` because it pulls message history from `HermesDataService`. Also exports `ChatDisplayMode` and `MessageGroup`.
|
||||||
|
|
||||||
|
**Reverted during M0d** (wasn't actually portable):
|
||||||
|
|
||||||
|
- `GatewayViewModel.swift` — my initial audit grepped for service-type names but missed that this VM calls `context.runHermes()`, which is a Mac-target-only extension (`ServerContext+Mac.swift`). Moving the extension would require dragging `HermesFileService` too. Left in the Mac target; a later phase can revisit once `HermesFileService` moves or a different CLI-invocation surface lands.
|
||||||
|
|
||||||
|
**Discovered while moving:**
|
||||||
|
|
||||||
|
- The sed transform needs a `s/^@Observable$/@Observable/` neutralization — earlier I was accidentally producing `@Observable public` which is a Swift syntax error (the stray `public` has no target). Post-fix, the `public` lives on the `public final class X` line as intended.
|
||||||
|
- Swift's `Observation` framework (for `@Observable`) needs an explicit `import Observation` in ScarfCore files because ScarfCore doesn't pull in SwiftUI. The Mac target gets `Observation` implicitly through SwiftUI, but a pure ScarfCore file doesn't. `Observation` is in the Swift toolchain from 5.9 onwards and compiles fine on Linux too.
|
||||||
|
- Nested enums inside a public enclosing type do **not** inherit `public` for their `Identifiable.id` requirement — that property has to be `public var id` explicitly when the enum declares `Identifiable` conformance. My sed didn't touch deeper indent levels (nested types at indent 4 inside a class at indent 0) so these had to be fixed by hand.
|
||||||
|
- `CharacterSet.whitespaces` is present in swift-corelibs-foundation on Linux — no guard needed there. The build error I saw was cascaded from `runHermes` not existing.
|
||||||
|
|
||||||
|
**Test coverage (`M0dViewModelsTests`):**
|
||||||
|
|
||||||
|
- `ConnectionStatusViewModel`: local context always-connected invariant; remote context idle-start; `Status` `Equatable`.
|
||||||
|
- `LogsViewModel`: init defaults, `filteredEntries` across level / search / component filters, nested enum `Identifiable` ids and `loggerPrefix` routing.
|
||||||
|
- `ProjectsViewModel`: init binding to `.local`.
|
||||||
|
- `ActivityViewModel`, `InsightsViewModel`, `RichChatViewModel`: construction + key initial state. Tests wrapped in `#if canImport(SQLite3)` so they only run on Apple-target CI.
|
||||||
|
- `MessageGroup.allMessages` / `toolCallCount` (also SQLite3-gated).
|
||||||
|
- `InsightsPeriod.sinceDate` ordering.
|
||||||
|
- `ChatDisplayMode` case coverage.
|
||||||
|
|
||||||
|
**Rules next phases can rely on:**
|
||||||
|
|
||||||
|
- When moving a file with `@Observable`, **remember to add `import Observation`** and to fix the stray `@Observable public` that sed produces.
|
||||||
|
- ViewModels that call `context.runHermes(...)` or `context.openInLocalEditor(...)` are **not** portable to ScarfCore — those methods live in `ServerContext+Mac.swift`. Either leave the VM in the Mac target, or add the specific extension method to ScarfCore with a platform-neutral implementation path.
|
||||||
|
- Types used only from the Mac app target (`GatewayInfo`, `PlatformInfo`, etc.) should NOT be marked `public` — keep them internal. My sed sometimes adds `public` to main-target-internal types when I'm reverting a move; strip those back with a second sed pass.
|
||||||
|
- Views are deliberately **not** in ScarfCore. iOS will build its own Views against the shared ViewModels. M3 is where iOS's ViewRegistry / tab bar / NavigationStack composition happens.
|
||||||
|
|
||||||
### M1 — pending
|
### M1 — pending
|
||||||
### M2 — pending
|
### M2 — pending
|
||||||
### M3 — pending
|
### M3 — pending
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct ChatView: View {
|
struct ChatView: View {
|
||||||
@Environment(ChatViewModel.self) private var viewModel
|
@Environment(ChatViewModel.self) private var viewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct RichChatView: View {
|
struct RichChatView: View {
|
||||||
@Bindable var richChat: RichChatViewModel
|
@Bindable var richChat: RichChatViewModel
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ScarfCore
|
|
||||||
|
|
||||||
struct GatewayInfo {
|
struct GatewayInfo {
|
||||||
let pid: Int?
|
let pid: Int?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
/// Small colored pill shown in the toolbar reflecting the server's reach-
|
/// Small colored pill shown in the toolbar reflecting the server's reach-
|
||||||
/// ability. Green = connected, yellow = probing, red = unreachable.
|
/// ability. Green = connected, yellow = probing, red = unreachable.
|
||||||
|
|||||||
Reference in New Issue
Block a user