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_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -422,7 +422,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -444,7 +444,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -459,7 +459,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -187,6 +187,8 @@ final class ChatViewModel {
richChatViewModel.handleACPEvent(
.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 {
acpStatus = "Cancelled"
} catch {
@@ -32,9 +32,21 @@ final class RichChatViewModel {
var messageGroups: [MessageGroup] = []
var isAgentWorking = false
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 }
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.
@@ -77,6 +89,10 @@ final class RichChatViewModel {
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
acpInputTokens = 0
acpOutputTokens = 0
acpThoughtTokens = 0
acpCachedReadTokens = 0
pendingPermission = nil
}
@@ -91,6 +107,17 @@ final class RichChatViewModel {
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
/// Add a user message immediately (before DB write) for instant UI feedback.
@@ -136,8 +163,8 @@ final class RichChatViewModel {
kind: request.toolCallKind,
options: request.options
)
case .promptComplete:
handlePromptComplete()
case .promptComplete(_, let response):
handlePromptComplete(response: response)
case .connectionLost(let reason):
handleConnectionLost(reason: reason)
case .availableCommands, .unknown:
@@ -188,9 +215,13 @@ final class RichChatViewModel {
buildMessageGroups()
}
private func handlePromptComplete() {
// Finalize any remaining streaming content
private func handlePromptComplete(response: ACPPromptResult) {
finalizeStreamingMessage()
// Accumulate token usage from this prompt
acpInputTokens += response.inputTokens
acpOutputTokens += response.outputTokens
acpThoughtTokens += response.thoughtTokens
acpCachedReadTokens += response.cachedReadTokens
isAgentWorking = false
buildMessageGroups()
}
@@ -91,9 +91,8 @@ struct ChatView: View {
Menu {
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
Button("Return to Active Session (\(activeId.prefix(8))...)") {
// Already active just ensure we're showing it
viewModel.richChatViewModel.requestScrollToBottom()
}
.disabled(true)
Divider()
}
Button("New Session") {
@@ -105,6 +104,8 @@ struct ChatView: View {
if !viewModel.recentSessions.isEmpty {
Divider()
Text("Resume Session")
let activeSessionId = viewModel.richChatViewModel.sessionId
let originSessionId = viewModel.richChatViewModel.originSessionId
ForEach(viewModel.recentSessions) { session in
Button {
viewModel.resumeSession(session.id)
@@ -120,6 +121,7 @@ struct ChatView: View {
}
}
}
.disabled(session.id == activeSessionId || session.id == originSessionId)
}
}
} label: {
@@ -3,6 +3,8 @@ import SwiftUI
struct RichChatMessageList: View {
let groups: [MessageGroup]
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.
private var scrollAnchor: String {
@@ -30,6 +32,14 @@ struct RichChatMessageList: View {
.padding()
}
.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
.onChange(of: groups.count) {
scrollToBottom(proxy: proxy)
@@ -50,6 +60,10 @@ struct RichChatMessageList: View {
.onChange(of: groups.last?.toolCallCount ?? 0) {
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) {
SessionInfoBar(
session: richChat.currentSession,
isWorking: richChat.isAgentWorking
isWorking: richChat.isAgentWorking,
acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens
)
Divider()
@@ -28,7 +31,8 @@ struct RichChatView: View {
} else {
RichChatMessageList(
groups: richChat.messageGroups,
isWorking: richChat.isAgentWorking
isWorking: richChat.isAgentWorking,
scrollTrigger: richChat.scrollTrigger
)
}
@@ -3,6 +3,10 @@ import SwiftUI
struct SessionInfoBar: View {
let session: HermesSession?
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 {
HStack(spacing: 16) {
@@ -30,11 +34,14 @@ struct SessionInfoBar: View {
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())
if session.reasoningTokens > 0 {
Label("\(formatTokens(session.reasoningTokens)) reasoning", systemImage: "brain")
let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
if reasonToks > 0 {
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
@@ -164,6 +164,9 @@ struct SessionRow: View {
HStack(spacing: 12) {
Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
}
.font(.caption)
.foregroundStyle(.secondary)
@@ -1,40 +1,56 @@
import Foundation
import os
@Observable
final class ToolsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = []
var mcpStatus: String = ""
var isLoading = false
var availablePlatforms: [HermesToolPlatform] = []
func load() {
loadPlatforms()
loadTools(for: selectedPlatform)
loadMCPStatus()
@MainActor
func load() async {
isLoading = true
await loadPlatforms()
await loadTools(for: selectedPlatform)
await loadMCPStatus()
isLoading = false
}
func switchPlatform(_ platform: HermesToolPlatform) {
@MainActor
func switchPlatform(_ platform: HermesToolPlatform) async {
selectedPlatform = platform
loadTools(for: platform)
await loadTools(for: platform)
}
func toggleTool(_ tool: HermesToolset) {
let action = tool.enabled ? "disable" : "enable"
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode == 0 {
@MainActor
func toggleTool(_ tool: HermesToolset) async {
guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
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.toggle()
toolsets[idx].enabled = !newEnabled
}
}
}
private func loadPlatforms() {
@MainActor
private func loadPlatforms() async {
let config: String
do {
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
config = try await Task.detached {
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
}.value
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
config = ""
}
var platforms: [HermesToolPlatform] = []
@@ -67,15 +83,15 @@ final class ToolsViewModel {
}
}
private func loadTools(for platform: HermesToolPlatform) {
isLoading = true
let result = runHermes(["tools", "list", "--platform", platform.name])
@MainActor
private func loadTools(for platform: HermesToolPlatform) async {
let result = await runHermes(["tools", "list", "--platform", platform.name])
toolsets = parseToolsList(result.output)
isLoading = false
}
private func loadMCPStatus() {
let result = runHermes(["mcp", "list"])
@MainActor
private func loadMCPStatus() async {
let result = await runHermes(["mcp", "list"])
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
@@ -121,21 +137,32 @@ final class ToolsViewModel {
return "🔧"
}
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (output, process.terminationStatus)
} catch {
return ("", -1)
}
private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
await Task.detached {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
process.waitUntilExit()
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
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)
} catch {
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return ("", -1)
}
}.value
}
}
@@ -14,7 +14,7 @@ struct ToolsView: View {
}
}
.navigationTitle("Tools")
.onAppear { viewModel.load() }
.task { await viewModel.load() }
}
private var platformPicker: some View {
@@ -23,7 +23,7 @@ struct ToolsView: View {
get: { viewModel.selectedPlatform.name },
set: { name in
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) {
ForEach(viewModel.toolsets) { tool in
ToolRow(tool: tool) {
viewModel.toggleTool(tool)
await viewModel.toggleTool(tool)
}
}
}
@@ -78,7 +78,7 @@ struct ToolsView: View {
struct ToolRow: View {
let tool: HermesToolset
let onToggle: () -> Void
let onToggle: () async -> Void
var body: some View {
HStack(spacing: 12) {
@@ -95,7 +95,7 @@ struct ToolRow: View {
Spacer()
Toggle("", isOn: Binding(
get: { tool.enabled },
set: { _ in onToggle() }
set: { _ in Task { await onToggle() } }
))
.toggleStyle(.switch)
.labelsHidden()