From 46468890d53b4b83136661cf79b725aae517a577 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 10 Apr 2026 00:15:44 -0400 Subject: [PATCH] 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) --- .../Chat/ViewModels/ChatViewModel.swift | 2 + .../Chat/ViewModels/RichChatViewModel.swift | 39 +++++++++++++++++-- .../scarf/Features/Chat/Views/ChatView.swift | 6 ++- .../Chat/Views/RichChatMessageList.swift | 14 +++++++ .../Features/Chat/Views/RichChatView.swift | 8 +++- .../Features/Chat/Views/SessionInfoBar.swift | 13 +++++-- .../Dashboard/Views/DashboardView.swift | 3 ++ 7 files changed, 74 insertions(+), 11 deletions(-) diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 579f8f0..0a296b3 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -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 { diff --git a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift index 578185d..375db55 100644 --- a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift @@ -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() } diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index 93c4e9e..5e5e51d 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -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: { diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift index 8d6eee0..36e8d74 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -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) + } } } diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 20d90ef..d8d8b27 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -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 ) } diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index 6e25ee5..2c76826 100644 --- a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -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 { diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index 8c55cf7..d19c517 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -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)