Compare commits

..

3 Commits

Author SHA1 Message Date
Alan Wizemann 8672ed1e6c chore: Bump version to 1.5.2 and add universal release binary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:53:21 -04:00
Alan Wizemann 46468890d5 feat: Track ACP token usage, improve chat scroll behavior, and show session costs
Add cumulative token tracking from ACP prompt results with fallback
display when DB has no data yet. Improve scroll-to-bottom reliability
with an external trigger for "Return to Active Session" and onAppear
auto-scroll. Show per-session cost in the dashboard session list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:15:44 -04:00
Alan Wizemann cd503378e2 fix: Move Tools subprocess calls off main thread to fix toggle rendering
Synchronous Process.run()/waitUntilExit() calls on the main thread blocked
SwiftUI's render loop, causing toggle controls to appear as solid blue
rectangles instead of proper switches. All hermes subprocess and file I/O
calls are now async via Task.detached, toggle uses optimistic state update
for immediate visual feedback, and pipe file handles are properly closed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:16:52 -04:00
11 changed files with 146 additions and 56 deletions
Binary file not shown.
+4 -4
View File
@@ -407,7 +407,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -422,7 +422,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.6; MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf; PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -444,7 +444,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -459,7 +459,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.6; MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf; PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -187,6 +187,8 @@ final class ChatViewModel {
richChatViewModel.handleACPEvent( richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result) .promptComplete(sessionId: sessionId, response: result)
) )
// Re-fetch session from DB to pick up cost/token data Hermes may have written
await richChatViewModel.refreshSessionFromDB()
} catch is CancellationError { } catch is CancellationError {
acpStatus = "Cancelled" acpStatus = "Cancelled"
} catch { } catch {
@@ -32,9 +32,21 @@ final class RichChatViewModel {
var messageGroups: [MessageGroup] = [] var messageGroups: [MessageGroup] = []
var isAgentWorking = false var isAgentWorking = false
var pendingPermission: PendingPermission? var pendingPermission: PendingPermission?
/// Mutated to trigger a scroll-to-bottom in the message list.
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
var hasMessages: Bool { !messages.isEmpty } var hasMessages: Bool { !messages.isEmpty }
func requestScrollToBottom() {
scrollTrigger = UUID()
}
private(set) var sessionId: String? private(set) var sessionId: String?
/// The original CLI session ID when resuming a CLI session via ACP. /// The original CLI session ID when resuming a CLI session via ACP.
/// Used to combine old CLI messages with new ACP messages. /// Used to combine old CLI messages with new ACP messages.
@@ -77,6 +89,10 @@ final class RichChatViewModel {
streamingAssistantText = "" streamingAssistantText = ""
streamingThinkingText = "" streamingThinkingText = ""
streamingToolCalls = [] streamingToolCalls = []
acpInputTokens = 0
acpOutputTokens = 0
acpThoughtTokens = 0
acpCachedReadTokens = 0
pendingPermission = nil pendingPermission = nil
} }
@@ -91,6 +107,17 @@ final class RichChatViewModel {
await dataService.close() await dataService.close()
} }
/// Re-fetch session metadata from DB to pick up cost/token updates.
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 // 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.
@@ -136,8 +163,8 @@ final class RichChatViewModel {
kind: request.toolCallKind, kind: request.toolCallKind,
options: request.options options: request.options
) )
case .promptComplete: case .promptComplete(_, let response):
handlePromptComplete() handlePromptComplete(response: response)
case .connectionLost(let reason): case .connectionLost(let reason):
handleConnectionLost(reason: reason) handleConnectionLost(reason: reason)
case .availableCommands, .unknown: case .availableCommands, .unknown:
@@ -188,9 +215,13 @@ final class RichChatViewModel {
buildMessageGroups() buildMessageGroups()
} }
private func handlePromptComplete() { private func handlePromptComplete(response: ACPPromptResult) {
// Finalize any remaining streaming content
finalizeStreamingMessage() finalizeStreamingMessage()
// Accumulate token usage from this prompt
acpInputTokens += response.inputTokens
acpOutputTokens += response.outputTokens
acpThoughtTokens += response.thoughtTokens
acpCachedReadTokens += response.cachedReadTokens
isAgentWorking = false isAgentWorking = false
buildMessageGroups() buildMessageGroups()
} }
@@ -91,9 +91,8 @@ struct ChatView: View {
Menu { Menu {
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId { if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
Button("Return to Active Session (\(activeId.prefix(8))...)") { Button("Return to Active Session (\(activeId.prefix(8))...)") {
// Already active just ensure we're showing it viewModel.richChatViewModel.requestScrollToBottom()
} }
.disabled(true)
Divider() Divider()
} }
Button("New Session") { Button("New Session") {
@@ -105,6 +104,8 @@ struct ChatView: View {
if !viewModel.recentSessions.isEmpty { if !viewModel.recentSessions.isEmpty {
Divider() Divider()
Text("Resume Session") Text("Resume Session")
let activeSessionId = viewModel.richChatViewModel.sessionId
let originSessionId = viewModel.richChatViewModel.originSessionId
ForEach(viewModel.recentSessions) { session in ForEach(viewModel.recentSessions) { session in
Button { Button {
viewModel.resumeSession(session.id) viewModel.resumeSession(session.id)
@@ -120,6 +121,7 @@ struct ChatView: View {
} }
} }
} }
.disabled(session.id == activeSessionId || session.id == originSessionId)
} }
} }
} label: { } label: {
@@ -3,6 +3,8 @@ import SwiftUI
struct RichChatMessageList: View { struct RichChatMessageList: View {
let groups: [MessageGroup] let groups: [MessageGroup]
let isWorking: Bool let isWorking: Bool
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
var scrollTrigger: UUID = UUID()
/// Track the last group's assistant content length to detect streaming updates. /// Track the last group's assistant content length to detect streaming updates.
private var scrollAnchor: String { private var scrollAnchor: String {
@@ -30,6 +32,14 @@ struct RichChatMessageList: View {
.padding() .padding()
} }
.defaultScrollAnchor(.bottom) .defaultScrollAnchor(.bottom)
// Scroll to bottom when view first appears with content
.onAppear {
if !groups.isEmpty {
DispatchQueue.main.async {
scrollToBottom(proxy: proxy, animated: false)
}
}
}
// Scroll on new groups // Scroll on new groups
.onChange(of: groups.count) { .onChange(of: groups.count) {
scrollToBottom(proxy: proxy) scrollToBottom(proxy: proxy)
@@ -50,6 +60,10 @@ struct RichChatMessageList: View {
.onChange(of: groups.last?.toolCallCount ?? 0) { .onChange(of: groups.last?.toolCallCount ?? 0) {
scrollToBottom(proxy: proxy) scrollToBottom(proxy: proxy)
} }
// Scroll on external trigger (e.g., "Return to Active Session" button)
.onChange(of: scrollTrigger) {
scrollToBottom(proxy: proxy)
}
} }
} }
@@ -14,7 +14,10 @@ struct RichChatView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
SessionInfoBar( SessionInfoBar(
session: richChat.currentSession, session: richChat.currentSession,
isWorking: richChat.isAgentWorking isWorking: richChat.isAgentWorking,
acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens
) )
Divider() Divider()
@@ -28,7 +31,8 @@ struct RichChatView: View {
} else { } else {
RichChatMessageList( RichChatMessageList(
groups: richChat.messageGroups, groups: richChat.messageGroups,
isWorking: richChat.isAgentWorking isWorking: richChat.isAgentWorking,
scrollTrigger: richChat.scrollTrigger
) )
} }
@@ -3,6 +3,10 @@ import SwiftUI
struct SessionInfoBar: View { struct SessionInfoBar: View {
let session: HermesSession? let session: HermesSession?
let isWorking: Bool let isWorking: Bool
/// Fallback token counts from ACP prompt results (DB may have zeros for ACP sessions).
var acpInputTokens: Int = 0
var acpOutputTokens: Int = 0
var acpThoughtTokens: Int = 0
var body: some View { var body: some View {
HStack(spacing: 16) { HStack(spacing: 16) {
@@ -30,11 +34,14 @@ struct SessionInfoBar: View {
Label(model, systemImage: "cpu") Label(model, systemImage: "cpu")
} }
Label("\(formatTokens(session.inputTokens)) in / \(formatTokens(session.outputTokens)) out", systemImage: "number") let inputToks = session.inputTokens > 0 ? session.inputTokens : acpInputTokens
let outputToks = session.outputTokens > 0 ? session.outputTokens : acpOutputTokens
Label("\(formatTokens(inputToks)) in / \(formatTokens(outputToks)) out", systemImage: "number")
.contentTransition(.numericText()) .contentTransition(.numericText())
if session.reasoningTokens > 0 { let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
Label("\(formatTokens(session.reasoningTokens)) reasoning", systemImage: "brain") if reasonToks > 0 {
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
} }
if let cost = session.displayCostUSD { if let cost = session.displayCostUSD {
@@ -164,6 +164,9 @@ struct SessionRow: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Label("\(session.messageCount)", systemImage: "bubble.left") Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench") Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -1,40 +1,56 @@
import Foundation import Foundation
import os
@Observable @Observable
final class ToolsViewModel { final class ToolsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = [] var toolsets: [HermesToolset] = []
var mcpStatus: String = "" var mcpStatus: String = ""
var isLoading = false var isLoading = false
var availablePlatforms: [HermesToolPlatform] = [] var availablePlatforms: [HermesToolPlatform] = []
func load() { @MainActor
loadPlatforms() func load() async {
loadTools(for: selectedPlatform) isLoading = true
loadMCPStatus() await loadPlatforms()
await loadTools(for: selectedPlatform)
await loadMCPStatus()
isLoading = false
} }
func switchPlatform(_ platform: HermesToolPlatform) { @MainActor
func switchPlatform(_ platform: HermesToolPlatform) async {
selectedPlatform = platform selectedPlatform = platform
loadTools(for: platform) await loadTools(for: platform)
} }
func toggleTool(_ tool: HermesToolset) { @MainActor
let action = tool.enabled ? "disable" : "enable" func toggleTool(_ tool: HermesToolset) async {
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name]) guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
if result.exitCode == 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled.toggle() toolsets[idx].enabled.toggle()
let newEnabled = toolsets[idx].enabled
let action = newEnabled ? "enable" : "disable"
let result = await runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode != 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled = !newEnabled
} }
} }
} }
private func loadPlatforms() { @MainActor
private func loadPlatforms() async {
let config: String let config: String
do { do {
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) config = try await Task.detached {
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
}.value
} catch { } catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)") logger.error("Failed to read config.yaml: \(error.localizedDescription)")
config = "" config = ""
} }
var platforms: [HermesToolPlatform] = [] var platforms: [HermesToolPlatform] = []
@@ -67,15 +83,15 @@ final class ToolsViewModel {
} }
} }
private func loadTools(for platform: HermesToolPlatform) { @MainActor
isLoading = true private func loadTools(for platform: HermesToolPlatform) async {
let result = runHermes(["tools", "list", "--platform", platform.name]) let result = await runHermes(["tools", "list", "--platform", platform.name])
toolsets = parseToolsList(result.output) toolsets = parseToolsList(result.output)
isLoading = false
} }
private func loadMCPStatus() { @MainActor
let result = runHermes(["mcp", "list"]) private func loadMCPStatus() async {
let result = await runHermes(["mcp", "list"])
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines) mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
} }
@@ -121,21 +137,32 @@ final class ToolsViewModel {
return "🔧" return "🔧"
} }
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
await Task.detached {
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments process.arguments = arguments
let pipe = Pipe() let stdoutPipe = Pipe()
process.standardOutput = pipe let stderrPipe = Pipe()
process.standardError = Pipe() process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do { do {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? "" let output = String(data: data, encoding: .utf8) ?? ""
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return (output, process.terminationStatus) return (output, process.terminationStatus)
} catch { } catch {
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return ("", -1) return ("", -1)
} }
}.value
} }
} }
@@ -14,7 +14,7 @@ struct ToolsView: View {
} }
} }
.navigationTitle("Tools") .navigationTitle("Tools")
.onAppear { viewModel.load() } .task { await viewModel.load() }
} }
private var platformPicker: some View { private var platformPicker: some View {
@@ -23,7 +23,7 @@ struct ToolsView: View {
get: { viewModel.selectedPlatform.name }, get: { viewModel.selectedPlatform.name },
set: { name in set: { name in
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) { if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
viewModel.switchPlatform(platform) Task { await viewModel.switchPlatform(platform) }
} }
} }
)) { )) {
@@ -46,7 +46,7 @@ struct ToolsView: View {
LazyVStack(spacing: 1) { LazyVStack(spacing: 1) {
ForEach(viewModel.toolsets) { tool in ForEach(viewModel.toolsets) { tool in
ToolRow(tool: tool) { ToolRow(tool: tool) {
viewModel.toggleTool(tool) await viewModel.toggleTool(tool)
} }
} }
} }
@@ -78,7 +78,7 @@ struct ToolsView: View {
struct ToolRow: View { struct ToolRow: View {
let tool: HermesToolset let tool: HermesToolset
let onToggle: () -> Void let onToggle: () async -> Void
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -95,7 +95,7 @@ struct ToolRow: View {
Spacer() Spacer()
Toggle("", isOn: Binding( Toggle("", isOn: Binding(
get: { tool.enabled }, get: { tool.enabled },
set: { _ in onToggle() } set: { _ in Task { await onToggle() } }
)) ))
.toggleStyle(.switch) .toggleStyle(.switch)
.labelsHidden() .labelsHidden()