mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +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:
@@ -0,0 +1,110 @@
|
||||
// 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 Observation
|
||||
|
||||
@Observable
|
||||
public final class ActivityViewModel {
|
||||
public let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
}
|
||||
|
||||
|
||||
public var toolMessages: [HermesMessage] = []
|
||||
public var filterKind: ToolKind?
|
||||
public var filterSessionId: String?
|
||||
public var selectedEntry: ActivityEntry?
|
||||
public var toolResult: String?
|
||||
public var sessionPreviews: [String: String] = [:]
|
||||
public var isLoading = true
|
||||
|
||||
public var availableSessions: [(id: String, label: String)] {
|
||||
var seen = Set<String>()
|
||||
return toolMessages.compactMap { message in
|
||||
guard seen.insert(message.sessionId).inserted else { return nil }
|
||||
let label = sessionPreviews[message.sessionId] ?? message.sessionId
|
||||
return (id: message.sessionId, label: label)
|
||||
}
|
||||
}
|
||||
|
||||
public var filteredActivity: [ActivityEntry] {
|
||||
let entries = toolMessages.flatMap { message in
|
||||
message.toolCalls.map { call in
|
||||
ActivityEntry(
|
||||
id: call.callId,
|
||||
sessionId: message.sessionId,
|
||||
toolName: call.functionName,
|
||||
kind: call.toolKind,
|
||||
summary: call.argumentsSummary,
|
||||
arguments: call.arguments,
|
||||
messageContent: message.content,
|
||||
timestamp: message.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
return entries.filter { entry in
|
||||
let kindOk = filterKind == nil || entry.kind == filterKind
|
||||
let sessionOk = filterSessionId == nil || entry.sessionId == filterSessionId
|
||||
return kindOk && sessionOk
|
||||
}
|
||||
}
|
||||
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
// refresh() = close + reopen, which forces a fresh snapshot pull on
|
||||
// remote contexts. Using open() here would short-circuit after the
|
||||
// first load and show stale data for the view's lifetime. The DB
|
||||
// stays open after load() returns so selectEntry() can read tool
|
||||
// results without re-opening — cleanup() closes on disappear.
|
||||
let opened = await dataService.refresh()
|
||||
guard opened else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
toolMessages = await dataService.fetchRecentToolCalls(limit: 200)
|
||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 200)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
public func selectEntry(_ entry: ActivityEntry?) async {
|
||||
selectedEntry = entry
|
||||
if let entry {
|
||||
toolResult = await dataService.fetchToolResult(callId: entry.id)
|
||||
} else {
|
||||
toolResult = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func cleanup() async {
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
|
||||
public struct ActivityEntry: Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let sessionId: String
|
||||
public let toolName: String
|
||||
public let kind: ToolKind
|
||||
public let summary: String
|
||||
public let arguments: String
|
||||
public let messageContent: String
|
||||
public let timestamp: Date?
|
||||
|
||||
public var prettyArguments: String {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]),
|
||||
let str = String(data: pretty, encoding: .utf8) else {
|
||||
return arguments
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Tracks connection health for the current window's server. Remote contexts
|
||||
/// get a lightweight 15s heartbeat (a no-op `true` remote command) that
|
||||
/// flips the status between green / yellow / red. Local contexts are always
|
||||
/// green since there's no connection to lose.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class ConnectionStatusViewModel {
|
||||
#if canImport(os)
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ConnectionStatus")
|
||||
#endif
|
||||
|
||||
public enum Status: Equatable {
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||
case connected
|
||||
/// SSH connects but the follow-up read-access probe failed. Data
|
||||
/// views will be empty until this is resolved. `reason` is shown
|
||||
/// in the pill tooltip; users click the pill to open diagnostics.
|
||||
case degraded(reason: String)
|
||||
/// No probe yet or the previous probe timed out but we haven't
|
||||
/// confirmed failure. Shown as yellow to tell the user "checking…".
|
||||
case idle
|
||||
/// Last probe failed. `message` is a terse human summary; `stderr`
|
||||
/// is the raw diagnostic text for a disclosure panel.
|
||||
case error(message: String, stderr: String)
|
||||
}
|
||||
|
||||
private(set) var status: Status = .idle
|
||||
/// Timestamp of the last successful probe. Used by the UI to show how
|
||||
/// fresh the status indicator is ("just now", "2m ago"…).
|
||||
private(set) var lastSuccess: Date?
|
||||
/// Number of consecutive probe failures. Surfaced as a yellow "Reconnecting…"
|
||||
/// state for the first failure (silent retry), then promoted to red after
|
||||
/// `consecutiveFailureThreshold` failures so flaky connections don't
|
||||
/// flap the indicator on every dropped packet.
|
||||
private(set) var consecutiveFailures = 0
|
||||
private let consecutiveFailureThreshold = 2
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
private var probeTask: Task<Void, Never>?
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
if !context.isRemote {
|
||||
// Local contexts are always considered connected — no network
|
||||
// or auth can fail.
|
||||
self.status = .connected
|
||||
self.lastSuccess = Date()
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off a background heartbeat loop. Safe to call multiple times;
|
||||
/// subsequent calls cancel the prior task and restart.
|
||||
public func startMonitoring() {
|
||||
guard context.isRemote else { return }
|
||||
probeTask?.cancel()
|
||||
probeTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.probeOnce()
|
||||
try? await Task.sleep(nanoseconds: 15_000_000_000) // 15s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stopMonitoring() {
|
||||
probeTask?.cancel()
|
||||
probeTask = nil
|
||||
}
|
||||
|
||||
/// Manual probe — also invoked by the toolbar "Retry" button on error.
|
||||
public func retry() {
|
||||
Task { await probeOnce() }
|
||||
}
|
||||
|
||||
private func probeOnce() async {
|
||||
let snapshot = transport
|
||||
let hermesHome = context.paths.home
|
||||
// Two-tier probe in one SSH round-trip:
|
||||
// tier 1: `true` — raw connectivity / auth / ControlMaster path
|
||||
// tier 2: `test -r $HERMESHOME/config.yaml` — can we actually
|
||||
// read the file Dashboard reads on every tick? Green pill
|
||||
// only if both pass; yellow "degraded" if tier 1 passes
|
||||
// but tier 2 fails (the exact symptom in issue #19).
|
||||
// Script emits two lines: TIER1:<exitcode> and TIER2:<exitcode>.
|
||||
let homeArg: String
|
||||
if hermesHome.hasPrefix("~/") {
|
||||
homeArg = "\"$HOME/\(hermesHome.dropFirst(2))\""
|
||||
} else if hermesHome == "~" {
|
||||
homeArg = "\"$HOME\""
|
||||
} else {
|
||||
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
|
||||
}
|
||||
let script = """
|
||||
echo TIER1:0
|
||||
H=\(homeArg)
|
||||
if [ -r "$H/config.yaml" ]; then echo TIER2:0; else echo TIER2:1; fi
|
||||
"""
|
||||
|
||||
enum ProbeOutcome {
|
||||
case connected
|
||||
case degraded(reason: String)
|
||||
case failure(TransportError)
|
||||
}
|
||||
|
||||
let outcome: ProbeOutcome = await Task.detached {
|
||||
do {
|
||||
let probe = try snapshot.runProcess(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", script],
|
||||
stdin: nil,
|
||||
timeout: 10
|
||||
)
|
||||
guard probe.exitCode == 0 else {
|
||||
return .failure(.commandFailed(exitCode: probe.exitCode, stderr: probe.stderrString))
|
||||
}
|
||||
let out = probe.stdoutString
|
||||
let tier1 = out.contains("TIER1:0")
|
||||
let tier2 = out.contains("TIER2:0")
|
||||
if !tier1 {
|
||||
// The script itself didn't reach tier 1 — treat as connection failure.
|
||||
return .failure(.commandFailed(exitCode: 1, stderr: out))
|
||||
}
|
||||
if tier2 {
|
||||
return .connected
|
||||
}
|
||||
// Connected but can't read config.yaml — the core issue #19
|
||||
// symptom. Give the pill a short reason; the full story goes
|
||||
// into Remote Diagnostics.
|
||||
return .degraded(reason: "can't read ~/.hermes/config.yaml")
|
||||
} catch let e as TransportError {
|
||||
return .failure(e)
|
||||
} catch {
|
||||
return .failure(.other(message: error.localizedDescription))
|
||||
}
|
||||
}.value
|
||||
|
||||
switch outcome {
|
||||
case .connected:
|
||||
status = .connected
|
||||
lastSuccess = Date()
|
||||
consecutiveFailures = 0
|
||||
case .degraded(let reason):
|
||||
status = .degraded(reason: reason)
|
||||
lastSuccess = Date() // SSH itself is fine, reset failure count
|
||||
consecutiveFailures = 0
|
||||
case .failure(let err):
|
||||
consecutiveFailures += 1
|
||||
// First failure → silent yellow "Reconnecting…" while we try
|
||||
// again on the next 15s tick. Only flip to red after we've
|
||||
// failed `consecutiveFailureThreshold` times in a row, so a
|
||||
// single dropped packet (laptop sleep/wake, transient WiFi)
|
||||
// doesn't visually scare the user.
|
||||
if consecutiveFailures < consecutiveFailureThreshold {
|
||||
status = .idle
|
||||
// Try again sooner than the regular tick — gives the
|
||||
// typical "WiFi reconnected within 5s" case a chance to
|
||||
// self-heal before the next 15s heartbeat.
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if self?.consecutiveFailures ?? 0 > 0 {
|
||||
await self?.probeOnce()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status = .error(
|
||||
message: err.errorDescription ?? "Unreachable",
|
||||
stderr: err.diagnosticStderr
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Gated on `canImport(SQLite3)` because every non-trivial code path calls
|
||||
// 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)
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
public enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||
case week = "7 Days"
|
||||
case month = "30 Days"
|
||||
case quarter = "90 Days"
|
||||
case all = "All Time"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .week: return "7 Days"
|
||||
case .month: return "30 Days"
|
||||
case .quarter: return "90 Days"
|
||||
case .all: return "All Time"
|
||||
}
|
||||
}
|
||||
|
||||
public var sinceDate: Date {
|
||||
let calendar = Calendar.current
|
||||
switch self {
|
||||
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
||||
case .month: return calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date()
|
||||
case .quarter: return calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date()
|
||||
case .all: return Date(timeIntervalSince1970: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelUsage: Identifiable {
|
||||
public var id: String { model }
|
||||
public let model: String
|
||||
public let sessions: Int
|
||||
public let inputTokens: Int
|
||||
public let outputTokens: Int
|
||||
public let cacheReadTokens: Int
|
||||
public let cacheWriteTokens: Int
|
||||
public let reasoningTokens: Int
|
||||
public var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||
}
|
||||
|
||||
public struct PlatformUsage: Identifiable {
|
||||
public var id: String { platform }
|
||||
public let platform: String
|
||||
public let sessions: Int
|
||||
public let messages: Int
|
||||
public let tokens: Int
|
||||
}
|
||||
|
||||
public struct ToolUsage: Identifiable {
|
||||
public var id: String { name }
|
||||
public let name: String
|
||||
public let count: Int
|
||||
public let percentage: Double
|
||||
}
|
||||
|
||||
public struct NotableSession: Identifiable {
|
||||
public var id: String { "\(session.id)-\(label)" }
|
||||
public let label: String
|
||||
public let value: String
|
||||
public let session: HermesSession
|
||||
public let preview: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
public final class InsightsViewModel {
|
||||
public let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
}
|
||||
|
||||
|
||||
public var period: InsightsPeriod = .month
|
||||
public var isLoading = true
|
||||
|
||||
public var sessions: [HermesSession] = []
|
||||
public var sessionPreviews: [String: String] = [:]
|
||||
public var userMessageCount = 0
|
||||
public var totalMessages = 0
|
||||
public var totalToolCalls = 0
|
||||
public var totalInputTokens = 0
|
||||
public var totalOutputTokens = 0
|
||||
public var totalCacheReadTokens = 0
|
||||
public var totalCacheWriteTokens = 0
|
||||
public var totalReasoningTokens = 0
|
||||
public var totalTokens = 0
|
||||
public var totalCost: Double = 0
|
||||
public var activeTime: TimeInterval = 0
|
||||
public var avgSessionDuration: TimeInterval = 0
|
||||
|
||||
public var modelUsage: [ModelUsage] = []
|
||||
public var platformUsage: [PlatformUsage] = []
|
||||
public var toolUsage: [ToolUsage] = []
|
||||
public var hourlyActivity: [Int: Int] = [:]
|
||||
public var dailyActivity: [Int: Int] = [:]
|
||||
public var notableSessions: [NotableSession] = []
|
||||
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
// refresh() forces a fresh remote snapshot each load. On local it's
|
||||
// a cheap reopen of the live DB.
|
||||
let opened = await dataService.refresh()
|
||||
guard opened else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let since = period.sinceDate
|
||||
sessions = await dataService.fetchSessionsInPeriod(since: since)
|
||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
||||
userMessageCount = await dataService.fetchUserMessageCount(since: since)
|
||||
let tools = await dataService.fetchToolUsage(since: since)
|
||||
hourlyActivity = await dataService.fetchSessionStartHours(since: since)
|
||||
dailyActivity = await dataService.fetchSessionDaysOfWeek(since: since)
|
||||
|
||||
await dataService.close()
|
||||
|
||||
computeAggregates()
|
||||
computeModelBreakdown()
|
||||
computePlatformBreakdown()
|
||||
computeToolBreakdown(tools)
|
||||
computeNotableSessions()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
public func previewFor(_ session: HermesSession) -> String {
|
||||
if let title = session.title, !title.isEmpty { return title }
|
||||
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
|
||||
return session.id
|
||||
}
|
||||
|
||||
private func computeAggregates() {
|
||||
totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
|
||||
totalToolCalls = sessions.reduce(0) { $0 + $1.toolCallCount }
|
||||
totalInputTokens = sessions.reduce(0) { $0 + $1.inputTokens }
|
||||
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
||||
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
||||
totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens }
|
||||
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens
|
||||
totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) }
|
||||
|
||||
var total: TimeInterval = 0
|
||||
var count = 0
|
||||
for session in sessions {
|
||||
if let dur = session.duration, dur > 0 {
|
||||
total += dur
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
activeTime = total
|
||||
avgSessionDuration = count > 0 ? total / Double(count) : 0
|
||||
}
|
||||
|
||||
private func computeModelBreakdown() {
|
||||
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:]
|
||||
for s in sessions {
|
||||
let model = s.model ?? "unknown"
|
||||
var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)]
|
||||
entry.sessions += 1
|
||||
entry.input += s.inputTokens
|
||||
entry.output += s.outputTokens
|
||||
entry.cacheRead += s.cacheReadTokens
|
||||
entry.cacheWrite += s.cacheWriteTokens
|
||||
entry.reasoning += s.reasoningTokens
|
||||
grouped[model] = entry
|
||||
}
|
||||
modelUsage = grouped.map { key, val in
|
||||
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
||||
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
||||
cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
|
||||
}.sorted { $0.totalTokens > $1.totalTokens }
|
||||
}
|
||||
|
||||
private func computePlatformBreakdown() {
|
||||
var grouped: [String: (sessions: Int, messages: Int, tokens: Int)] = [:]
|
||||
for s in sessions {
|
||||
var entry = grouped[s.source, default: (0, 0, 0)]
|
||||
entry.sessions += 1
|
||||
entry.messages += s.messageCount
|
||||
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
|
||||
grouped[s.source] = entry
|
||||
}
|
||||
platformUsage = grouped.map { key, val in
|
||||
PlatformUsage(platform: key, sessions: val.sessions, messages: val.messages, tokens: val.tokens)
|
||||
}.sorted { $0.sessions > $1.sessions }
|
||||
}
|
||||
|
||||
private func computeToolBreakdown(_ tools: [(name: String, count: Int)]) {
|
||||
let total = tools.reduce(0) { $0 + $1.count }
|
||||
toolUsage = tools.map { tool in
|
||||
ToolUsage(name: tool.name, count: tool.count,
|
||||
percentage: total > 0 ? Double(tool.count) / Double(total) * 100 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func computeNotableSessions() {
|
||||
notableSessions = []
|
||||
|
||||
if let longest = sessions.filter({ $0.duration != nil }).max(by: { ($0.duration ?? 0) < ($1.duration ?? 0) }) {
|
||||
notableSessions.append(NotableSession(
|
||||
label: "Longest Session",
|
||||
value: formatDuration(longest.duration ?? 0),
|
||||
session: longest,
|
||||
preview: previewFor(longest)
|
||||
))
|
||||
}
|
||||
|
||||
if let mostMsgs = sessions.max(by: { $0.messageCount < $1.messageCount }), mostMsgs.messageCount > 0 {
|
||||
notableSessions.append(NotableSession(
|
||||
label: "Most Messages",
|
||||
value: "\(mostMsgs.messageCount) msgs",
|
||||
session: mostMsgs,
|
||||
preview: previewFor(mostMsgs)
|
||||
))
|
||||
}
|
||||
|
||||
if let mostTokens = sessions.max(by: { $0.totalTokens < $1.totalTokens }), mostTokens.totalTokens > 0 {
|
||||
notableSessions.append(NotableSession(
|
||||
label: "Most Tokens",
|
||||
value: formatTokens(mostTokens.totalTokens),
|
||||
session: mostTokens,
|
||||
preview: previewFor(mostTokens)
|
||||
))
|
||||
}
|
||||
|
||||
if let mostTools = sessions.max(by: { $0.toolCallCount < $1.toolCallCount }), mostTools.toolCallCount > 0 {
|
||||
notableSessions.append(NotableSession(
|
||||
label: "Most Tool Calls",
|
||||
value: "\(mostTools.toolCallCount) calls",
|
||||
session: mostTools,
|
||||
preview: previewFor(mostTools)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func formatDuration(_ interval: TimeInterval) -> String {
|
||||
let hours = Int(interval) / 3600
|
||||
let minutes = (Int(interval) % 3600) / 60
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(minutes)m"
|
||||
}
|
||||
return "\(minutes)m"
|
||||
}
|
||||
|
||||
public func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
return String(format: "%.1fK", Double(count) / 1_000)
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
public final class LogsViewModel {
|
||||
public let context: ServerContext
|
||||
private let logService: HermesLogService
|
||||
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.logService = HermesLogService(context: context)
|
||||
}
|
||||
|
||||
public var entries: [LogEntry] = []
|
||||
public var selectedLogFile: LogFile = .agent
|
||||
public var filterLevel: LogEntry.LogLevel?
|
||||
public var selectedComponent: LogComponent = .all
|
||||
public var searchText = ""
|
||||
private var pollTimer: Timer?
|
||||
|
||||
public enum LogFile: String, CaseIterable, Identifiable {
|
||||
case agent = "agent.log"
|
||||
case errors = "errors.log"
|
||||
case gateway = "gateway.log"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
#if canImport(Darwin)
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .agent: return "Agent"
|
||||
case .errors: return "Errors"
|
||||
case .gateway: return "Gateway"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func path(for file: LogFile) -> String {
|
||||
switch file {
|
||||
case .agent: return context.paths.agentLog
|
||||
case .errors: return context.paths.errorsLog
|
||||
case .gateway: return context.paths.gatewayLog
|
||||
}
|
||||
}
|
||||
|
||||
public enum LogComponent: String, CaseIterable, Identifiable {
|
||||
case all = "All"
|
||||
case gateway = "Gateway"
|
||||
case agent = "Agent"
|
||||
case tools = "Tools"
|
||||
case cli = "CLI"
|
||||
case cron = "Cron"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
#if canImport(Darwin)
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .all: return "All"
|
||||
case .gateway: return "Gateway"
|
||||
case .agent: return "Agent"
|
||||
case .tools: return "Tools"
|
||||
case .cli: return "CLI"
|
||||
case .cron: return "Cron"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public var loggerPrefix: String? {
|
||||
switch self {
|
||||
case .all: return nil
|
||||
case .gateway: return "gateway"
|
||||
case .agent: return "agent"
|
||||
case .tools: return "tools"
|
||||
case .cli: return "cli"
|
||||
case .cron: return "cron"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var filteredEntries: [LogEntry] {
|
||||
entries.filter { entry in
|
||||
let levelOk = filterLevel == nil || entry.level == filterLevel
|
||||
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
||||
let componentOk: Bool = {
|
||||
guard let prefix = selectedComponent.loggerPrefix else { return true }
|
||||
return entry.logger.hasPrefix(prefix)
|
||||
}()
|
||||
return levelOk && searchOk && componentOk
|
||||
}
|
||||
}
|
||||
|
||||
public func load() async {
|
||||
await logService.openLog(path: path(for: selectedLogFile))
|
||||
entries = await logService.readLastLines(count: 500)
|
||||
await logService.seekToEnd()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
public func switchLogFile(_ file: LogFile) async {
|
||||
selectedLogFile = file
|
||||
entries = []
|
||||
await logService.openLog(path: path(for: file))
|
||||
entries = await logService.readLastLines(count: 500)
|
||||
await logService.seekToEnd()
|
||||
}
|
||||
|
||||
public func startPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
let newEntries = await self.logService.readNewLines()
|
||||
if !newEntries.isEmpty {
|
||||
self.entries.append(contentsOf: newEntries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stopPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
public func cleanup() async {
|
||||
stopPolling()
|
||||
await logService.closeLog()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Observation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
public final class ProjectsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
||||
public let context: ServerContext
|
||||
private let service: ProjectDashboardService
|
||||
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.service = ProjectDashboardService(context: context)
|
||||
}
|
||||
|
||||
|
||||
public var projects: [ProjectEntry] = []
|
||||
public var selectedProject: ProjectEntry?
|
||||
public var dashboard: ProjectDashboard?
|
||||
public var dashboardError: String?
|
||||
public var isLoading = false
|
||||
|
||||
public func load() {
|
||||
let registry = service.loadRegistry()
|
||||
projects = registry.projects
|
||||
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
|
||||
selectedProject = nil
|
||||
dashboard = nil
|
||||
}
|
||||
if let selected = selectedProject {
|
||||
loadDashboard(for: selected)
|
||||
}
|
||||
}
|
||||
|
||||
public func selectProject(_ project: ProjectEntry) {
|
||||
selectedProject = project
|
||||
loadDashboard(for: project)
|
||||
}
|
||||
|
||||
public func addProject(name: String, path: String) {
|
||||
var registry = service.loadRegistry()
|
||||
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||
let entry = ProjectEntry(name: name, path: path)
|
||||
registry.projects.append(entry)
|
||||
// saveRegistry throws now. The VM doesn't currently have a
|
||||
// surface for user-visible errors (there's no alert/toast in
|
||||
// the Projects view), so log at error level to the unified
|
||||
// log and keep the in-memory state consistent with whatever
|
||||
// landed on disk. If the write fails, the added entry won't
|
||||
// persist across launches — the user sees it appear + work
|
||||
// this session, then it's gone at relaunch. Not ideal, but
|
||||
// matches today's UX and flagged for a proper alert later.
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("addProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
projects = registry.projects
|
||||
selectProject(entry)
|
||||
}
|
||||
|
||||
public func removeProject(_ project: ProjectEntry) {
|
||||
var registry = service.loadRegistry()
|
||||
registry.projects.removeAll { $0.name == project.name }
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("removeProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
projects = registry.projects
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = nil
|
||||
dashboard = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshDashboard() {
|
||||
guard let project = selectedProject else { return }
|
||||
loadDashboard(for: project)
|
||||
}
|
||||
|
||||
public var dashboardPaths: [String] {
|
||||
projects.map(\.dashboardPath)
|
||||
}
|
||||
|
||||
private func loadDashboard(for project: ProjectEntry) {
|
||||
dashboardError = nil
|
||||
if !service.dashboardExists(for: project) {
|
||||
dashboard = nil
|
||||
dashboardError = "No dashboard found at \(project.dashboardPath)"
|
||||
return
|
||||
}
|
||||
if let loaded = service.loadDashboard(for: project) {
|
||||
dashboard = loaded
|
||||
} else {
|
||||
dashboard = nil
|
||||
dashboardError = "Failed to parse dashboard JSON"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
// Gated on `canImport(SQLite3)` — `RichChatViewModel` reads message
|
||||
// history from `HermesDataService`, which is SQLite-gated. iOS + macOS
|
||||
// compile this unchanged; Linux CI skips it.
|
||||
#if canImport(SQLite3)
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
public enum ChatDisplayMode: String, CaseIterable {
|
||||
case terminal
|
||||
case richChat
|
||||
}
|
||||
|
||||
public struct MessageGroup: Identifiable {
|
||||
public let id: Int
|
||||
public let userMessage: HermesMessage?
|
||||
public let assistantMessages: [HermesMessage]
|
||||
public let toolResults: [String: HermesMessage]
|
||||
|
||||
public var allMessages: [HermesMessage] {
|
||||
var result: [HermesMessage] = []
|
||||
if let user = userMessage { result.append(user) }
|
||||
result.append(contentsOf: assistantMessages)
|
||||
return result
|
||||
}
|
||||
|
||||
public var toolCallCount: Int {
|
||||
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
public final class RichChatViewModel {
|
||||
public let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
loadQuickCommands()
|
||||
}
|
||||
|
||||
|
||||
public var messages: [HermesMessage] = []
|
||||
public var currentSession: HermesSession?
|
||||
public var messageGroups: [MessageGroup] = []
|
||||
public var isAgentWorking = false
|
||||
public var pendingPermission: PendingPermission?
|
||||
/// Mutated to trigger a scroll-to-bottom in the message list.
|
||||
public var scrollTrigger = UUID()
|
||||
|
||||
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
|
||||
private(set) var acpInputTokens = 0
|
||||
private(set) var acpOutputTokens = 0
|
||||
private(set) var acpThoughtTokens = 0
|
||||
private(set) var acpCachedReadTokens = 0
|
||||
|
||||
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
||||
private(set) var acpCommands: [HermesSlashCommand] = []
|
||||
/// User-defined commands parsed from `config.yaml` `quick_commands`.
|
||||
private(set) var quickCommands: [HermesSlashCommand] = []
|
||||
|
||||
/// Merged list, ACP-first, de-duplicated by name.
|
||||
public var availableCommands: [HermesSlashCommand] {
|
||||
let acpNames = Set(acpCommands.map(\.name))
|
||||
return acpCommands + quickCommands.filter { !acpNames.contains($0.name) }
|
||||
}
|
||||
|
||||
public var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
|
||||
|
||||
/// True when the menu carries more than just `/compress` — used to hide
|
||||
/// the dedicated compress button in favor of the full slash menu.
|
||||
public var hasBroaderCommandMenu: Bool { availableCommands.count > 1 }
|
||||
|
||||
public var hasMessages: Bool { !messages.isEmpty }
|
||||
|
||||
public func requestScrollToBottom() {
|
||||
scrollTrigger = UUID()
|
||||
}
|
||||
|
||||
private(set) var sessionId: String?
|
||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||
/// Used to combine old CLI messages with new ACP messages.
|
||||
private(set) var originSessionId: String?
|
||||
private var nextLocalId = -1
|
||||
private var streamingAssistantText = ""
|
||||
private var streamingThinkingText = ""
|
||||
private var streamingToolCalls: [HermesToolCall] = []
|
||||
|
||||
// DB polling state (used in terminal mode fallback)
|
||||
private var lastKnownFingerprint: HermesDataService.MessageFingerprint?
|
||||
private var debounceTask: Task<Void, Never>?
|
||||
private var resetTimestamp: Date?
|
||||
private var userSendPending = false
|
||||
private var activePollingTimer: Timer?
|
||||
|
||||
public struct PendingPermission {
|
||||
let requestId: Int
|
||||
let title: String
|
||||
let kind: String
|
||||
let options: [(optionId: String, name: String)]
|
||||
}
|
||||
|
||||
// MARK: - Reset
|
||||
|
||||
public func reset() {
|
||||
debounceTask?.cancel()
|
||||
stopActivePolling()
|
||||
Task { await dataService.close() }
|
||||
messages = []
|
||||
messageGroups = []
|
||||
currentSession = nil
|
||||
lastKnownFingerprint = nil
|
||||
sessionId = nil
|
||||
originSessionId = nil
|
||||
isAgentWorking = false
|
||||
userSendPending = false
|
||||
resetTimestamp = Date()
|
||||
nextLocalId = -1
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
acpInputTokens = 0
|
||||
acpOutputTokens = 0
|
||||
acpThoughtTokens = 0
|
||||
acpCachedReadTokens = 0
|
||||
acpCommands = []
|
||||
pendingPermission = nil
|
||||
loadQuickCommands()
|
||||
}
|
||||
|
||||
public func setSessionId(_ id: String?) {
|
||||
sessionId = id
|
||||
lastKnownFingerprint = nil
|
||||
}
|
||||
|
||||
public func cleanup() async {
|
||||
stopActivePolling()
|
||||
debounceTask?.cancel()
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
||||
public func refreshSessionFromDB() async {
|
||||
guard let sessionId else { return }
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
if let session = await dataService.fetchSession(id: sessionId) {
|
||||
currentSession = session
|
||||
}
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
// MARK: - ACP Event Handling
|
||||
|
||||
/// Add a user message immediately (before DB write) for instant UI feedback.
|
||||
public func addUserMessage(text: String) {
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
let message = HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "user",
|
||||
content: text,
|
||||
toolCallId: nil,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
)
|
||||
messages.append(message)
|
||||
isAgentWorking = true
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
buildMessageGroups()
|
||||
// User just submitted — jump to the bottom so they see their message
|
||||
// and the incoming response. `.defaultScrollAnchor(.bottom)` handles
|
||||
// slow streaming fine, but rapid responses (slash commands especially)
|
||||
// arrive faster than the anchor can track.
|
||||
requestScrollToBottom()
|
||||
}
|
||||
|
||||
/// Process a streaming ACP event and update the message list.
|
||||
public func handleACPEvent(_ event: ACPEvent) {
|
||||
switch event {
|
||||
case .messageChunk(_, let text):
|
||||
appendMessageChunk(text: text)
|
||||
case .thoughtChunk(_, let text):
|
||||
appendThoughtChunk(text: text)
|
||||
case .toolCallStart(_, let call):
|
||||
handleToolCallStart(call)
|
||||
case .toolCallUpdate(_, let update):
|
||||
handleToolCallComplete(update)
|
||||
case .permissionRequest(_, let requestId, let request):
|
||||
pendingPermission = PendingPermission(
|
||||
requestId: requestId,
|
||||
title: request.toolCallTitle,
|
||||
kind: request.toolCallKind,
|
||||
options: request.options
|
||||
)
|
||||
case .promptComplete(_, let response):
|
||||
handlePromptComplete(response: response)
|
||||
case .connectionLost(let reason):
|
||||
handleConnectionLost(reason: reason)
|
||||
case .availableCommands(_, let commands):
|
||||
acpCommands = parseACPCommands(commands)
|
||||
case .unknown:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func parseACPCommands(_ commands: [[String: Any]]) -> [HermesSlashCommand] {
|
||||
var result: [HermesSlashCommand] = []
|
||||
for entry in commands {
|
||||
guard let rawName = entry["name"] as? String else { continue }
|
||||
// Hermes sends names either as "compress" or "/compress"
|
||||
let name = rawName.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard !name.isEmpty else { continue }
|
||||
let description = (entry["description"] as? String) ?? ""
|
||||
var hint: String? = nil
|
||||
if let input = entry["input"] as? [String: Any],
|
||||
let h = input["hint"] as? String,
|
||||
!h.isEmpty {
|
||||
hint = h
|
||||
}
|
||||
result.append(HermesSlashCommand(
|
||||
name: name,
|
||||
description: description,
|
||||
argumentHint: hint,
|
||||
source: .acp
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Load `quick_commands` from `config.yaml` off the main actor and publish
|
||||
/// them as slash commands. Safe to call repeatedly — replaces the existing list.
|
||||
public func loadQuickCommands() {
|
||||
let ctx = context
|
||||
Task.detached { [weak self] in
|
||||
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
|
||||
let mapped = loaded.map { qc -> HermesSlashCommand in
|
||||
let truncated = qc.command.count > 60
|
||||
? String(qc.command.prefix(60)) + "…"
|
||||
: qc.command
|
||||
return HermesSlashCommand(
|
||||
name: qc.name,
|
||||
description: "Run: \(truncated)",
|
||||
argumentHint: nil,
|
||||
source: .quickCommand
|
||||
)
|
||||
}
|
||||
await MainActor.run { [weak self] in
|
||||
self?.quickCommands = mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendMessageChunk(text: String) {
|
||||
streamingAssistantText += text
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func appendThoughtChunk(text: String) {
|
||||
streamingThinkingText += text
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func handleToolCallStart(_ call: ACPToolCallEvent) {
|
||||
let toolCall = HermesToolCall(
|
||||
callId: call.toolCallId,
|
||||
functionName: call.functionName,
|
||||
arguments: call.argumentsJSON
|
||||
)
|
||||
streamingToolCalls.append(toolCall)
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func handleToolCallComplete(_ update: ACPToolCallUpdateEvent) {
|
||||
// Finalize the streaming assistant message (with its tool calls) as a permanent message
|
||||
finalizeStreamingMessage()
|
||||
|
||||
// Add tool result message
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages.append(HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "tool",
|
||||
content: update.rawOutput ?? update.content,
|
||||
toolCallId: update.toolCallId,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
))
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
private func handlePromptComplete(response: ACPPromptResult) {
|
||||
// Detect a failed prompt that produced no assistant output — e.g.
|
||||
// Hermes returning `stopReason: "refusal"` when the session was
|
||||
// silently garbage-collected, or `"error"` when the ACP call itself
|
||||
// threw. Without surfacing this, the user sees their prompt sitting
|
||||
// alone under "Agent working…" that never completes with any text.
|
||||
let hadAssistantOutput = streamingAssistantText.isEmpty == false
|
||||
|| messages.last?.isAssistant == true
|
||||
finalizeStreamingMessage()
|
||||
|
||||
if !hadAssistantOutput, response.stopReason != "end_turn" {
|
||||
let reason: String
|
||||
switch response.stopReason {
|
||||
case "refusal":
|
||||
reason = "The agent refused to respond (the session may have been cleared on the server). Try starting a new session from the Session menu."
|
||||
case "error":
|
||||
reason = "The prompt failed — check the ACP error banner above for details."
|
||||
case "max_tokens":
|
||||
reason = "The response was cut off before the agent could produce any output (max_tokens reached before any tokens were emitted)."
|
||||
default:
|
||||
reason = "The prompt ended without a response (stopReason: \(response.stopReason))."
|
||||
}
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages.append(HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "system",
|
||||
content: reason,
|
||||
toolCallId: nil,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: response.stopReason,
|
||||
reasoning: nil
|
||||
))
|
||||
}
|
||||
|
||||
// Accumulate token usage from this prompt
|
||||
acpInputTokens += response.inputTokens
|
||||
acpOutputTokens += response.outputTokens
|
||||
acpThoughtTokens += response.thoughtTokens
|
||||
acpCachedReadTokens += response.cachedReadTokens
|
||||
isAgentWorking = false
|
||||
buildMessageGroups()
|
||||
// Final position after the prompt settles. Catches fast responses
|
||||
// (slash commands, short replies) where `.defaultScrollAnchor(.bottom)`
|
||||
// didn't quite track the abrupt content growth.
|
||||
requestScrollToBottom()
|
||||
}
|
||||
|
||||
private func handleConnectionLost(reason: String) {
|
||||
finalizeStreamingMessage()
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages.append(HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "system",
|
||||
content: "Connection lost: \(reason). Use the Session menu to start or resume a session.",
|
||||
toolCallId: nil,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
))
|
||||
isAgentWorking = false
|
||||
pendingPermission = nil
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Streaming Message Management
|
||||
|
||||
private static let streamingId = 0
|
||||
|
||||
/// Insert or update the in-progress streaming assistant message (id=0).
|
||||
private func upsertStreamingMessage() {
|
||||
let msg = HermesMessage(
|
||||
id: Self.streamingId,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "assistant",
|
||||
content: streamingAssistantText,
|
||||
toolCallId: nil,
|
||||
toolCalls: streamingToolCalls,
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
|
||||
)
|
||||
|
||||
if let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) {
|
||||
messages[idx] = msg
|
||||
} else {
|
||||
messages.append(msg)
|
||||
}
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
|
||||
private func finalizeStreamingMessage() {
|
||||
guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return }
|
||||
|
||||
// Only finalize if there's actual content
|
||||
let hasContent = !streamingAssistantText.isEmpty
|
||||
|| !streamingThinkingText.isEmpty
|
||||
|| !streamingToolCalls.isEmpty
|
||||
|
||||
if hasContent {
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages[idx] = HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "assistant",
|
||||
content: streamingAssistantText,
|
||||
toolCallId: nil,
|
||||
toolCalls: streamingToolCalls,
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: streamingToolCalls.isEmpty ? "stop" : nil,
|
||||
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
|
||||
)
|
||||
} else {
|
||||
// Remove empty streaming placeholder
|
||||
messages.remove(at: idx)
|
||||
}
|
||||
|
||||
// Reset streaming state for next chunk
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
}
|
||||
|
||||
// MARK: - Disconnect Recovery
|
||||
|
||||
/// Finalize streaming state on disconnect, before reconnection attempts begin.
|
||||
/// Saves partial content as a permanent message without adding a system message.
|
||||
public func finalizeOnDisconnect() {
|
||||
finalizeStreamingMessage()
|
||||
isAgentWorking = false
|
||||
pendingPermission = nil
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
/// Reconcile in-memory messages with DB state after a successful reconnection.
|
||||
/// Merges DB-persisted messages with any local-only messages (e.g., user messages
|
||||
/// that the ACP process may not have persisted before crashing).
|
||||
public func reconcileWithDB(sessionId: String) async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
|
||||
// If we have an origin session (CLI session continued via ACP),
|
||||
// include those messages too
|
||||
if let origin = originSessionId, origin != sessionId {
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin)
|
||||
if !originMessages.isEmpty {
|
||||
dbMessages = originMessages + dbMessages
|
||||
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
}
|
||||
}
|
||||
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
await dataService.close()
|
||||
|
||||
// Find local-only user messages not yet in DB.
|
||||
// Local messages have negative IDs; DB messages have positive IDs.
|
||||
let dbUserContents = Set(dbMessages.filter(\.isUser).map(\.content))
|
||||
let localOnlyMessages = messages.filter { msg in
|
||||
msg.id < 0 && msg.isUser && !dbUserContents.contains(msg.content)
|
||||
}
|
||||
|
||||
// Build reconciled list: DB messages + unmatched local user messages
|
||||
var reconciled = dbMessages
|
||||
for localMsg in localOnlyMessages {
|
||||
if let ts = localMsg.timestamp,
|
||||
let insertIdx = reconciled.firstIndex(where: { ($0.timestamp ?? .distantPast) > ts }) {
|
||||
reconciled.insert(localMsg, at: insertIdx)
|
||||
} else {
|
||||
reconciled.append(localMsg)
|
||||
}
|
||||
}
|
||||
|
||||
messages = reconciled
|
||||
currentSession = session
|
||||
let minId = reconciled.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Load History from DB (for resumed sessions)
|
||||
|
||||
/// Load message history from the DB, optionally combining an origin session
|
||||
/// (e.g., CLI session) with the current ACP session.
|
||||
public func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
||||
self.sessionId = sessionId
|
||||
// Force a fresh snapshot pull on remote contexts. An earlier open()
|
||||
// would have cached a stale copy — on resume we need whatever
|
||||
// Hermes has actually persisted since then, or the resumed session
|
||||
// will show only history up to the moment the snapshot was taken.
|
||||
let opened = await dataService.refresh()
|
||||
guard opened else { return }
|
||||
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
|
||||
// If the ACP session is different from the origin, load its messages too
|
||||
// and combine them chronologically
|
||||
if let acpId = acpSessionId, acpId != sessionId {
|
||||
originSessionId = sessionId
|
||||
self.sessionId = acpId
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId)
|
||||
if !acpMessages.isEmpty {
|
||||
allMessages.append(contentsOf: acpMessages)
|
||||
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
}
|
||||
}
|
||||
|
||||
messages = allMessages
|
||||
currentSession = session
|
||||
let minId = allMessages.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - DB Polling (terminal mode fallback)
|
||||
|
||||
public func markAgentWorking() {
|
||||
isAgentWorking = true
|
||||
userSendPending = true
|
||||
startActivePolling()
|
||||
}
|
||||
|
||||
public func scheduleRefresh() {
|
||||
debounceTask?.cancel()
|
||||
debounceTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.refreshMessages()
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshMessages() async {
|
||||
// Polling tick (terminal mode): pull a fresh snapshot so remote
|
||||
// reflects Hermes writes since the last tick. On local this is a
|
||||
// cheap reopen of the live DB.
|
||||
let opened = await dataService.refresh()
|
||||
guard opened else { return }
|
||||
|
||||
if sessionId == nil {
|
||||
if let resetTime = resetTimestamp {
|
||||
if let candidate = await dataService.fetchMostRecentlyStartedSessionId(after: resetTime) {
|
||||
sessionId = candidate
|
||||
}
|
||||
}
|
||||
if sessionId == nil {
|
||||
sessionId = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
}
|
||||
}
|
||||
|
||||
guard let sessionId else { return }
|
||||
|
||||
let fingerprint = await dataService.fetchMessageFingerprint(sessionId: sessionId)
|
||||
|
||||
if fingerprint != lastKnownFingerprint {
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
lastKnownFingerprint = fingerprint
|
||||
|
||||
messages = fetched
|
||||
currentSession = session
|
||||
buildMessageGroups()
|
||||
|
||||
let derivedWorking = deriveAgentWorking(from: fetched)
|
||||
if userSendPending {
|
||||
if fetched.last?.isUser == true {
|
||||
userSendPending = false
|
||||
}
|
||||
isAgentWorking = true
|
||||
} else {
|
||||
let wasWorking = isAgentWorking
|
||||
isAgentWorking = derivedWorking
|
||||
if wasWorking && !derivedWorking {
|
||||
stopActivePolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startActivePolling() {
|
||||
stopActivePolling()
|
||||
activePollingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.refreshMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopActivePolling() {
|
||||
activePollingTimer?.invalidate()
|
||||
activePollingTimer = nil
|
||||
}
|
||||
|
||||
private func deriveAgentWorking(from fetched: [HermesMessage]) -> Bool {
|
||||
guard let last = fetched.last else { return false }
|
||||
if last.isUser { return true }
|
||||
if last.isToolResult { return true }
|
||||
if last.isAssistant {
|
||||
if !last.toolCalls.isEmpty {
|
||||
let allCallIds = Set(last.toolCalls.map(\.callId))
|
||||
let resultCallIds = Set(fetched.compactMap { $0.isToolResult ? $0.toolCallId : nil })
|
||||
return !allCallIds.subtracting(resultCallIds).isEmpty
|
||||
}
|
||||
return last.finishReason == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Message Grouping
|
||||
|
||||
private func buildMessageGroups() {
|
||||
var groups: [MessageGroup] = []
|
||||
var currentUser: HermesMessage?
|
||||
var currentAssistant: [HermesMessage] = []
|
||||
var currentToolResults: [String: HermesMessage] = [:]
|
||||
var groupIndex = 0
|
||||
|
||||
func flushGroup() {
|
||||
if currentUser != nil || !currentAssistant.isEmpty {
|
||||
// Use stable sequential IDs so SwiftUI doesn't re-create views
|
||||
// when streaming messages finalize (id changes from 0 to -N)
|
||||
groups.append(MessageGroup(
|
||||
id: groupIndex,
|
||||
userMessage: currentUser,
|
||||
assistantMessages: currentAssistant,
|
||||
toolResults: currentToolResults
|
||||
))
|
||||
groupIndex += 1
|
||||
}
|
||||
currentUser = nil
|
||||
currentAssistant = []
|
||||
currentToolResults = [:]
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
if message.isUser {
|
||||
flushGroup()
|
||||
currentUser = message
|
||||
} else if message.isToolResult {
|
||||
if let callId = message.toolCallId {
|
||||
currentToolResults[callId] = message
|
||||
}
|
||||
currentAssistant.append(message)
|
||||
} else {
|
||||
if currentUser == nil && !currentAssistant.isEmpty && message.isAssistant {
|
||||
flushGroup()
|
||||
}
|
||||
currentAssistant.append(message)
|
||||
}
|
||||
}
|
||||
flushGroup()
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user