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>
This commit is contained in:
Alan Wizemann
2026-04-10 00:15:44 -04:00
parent cd503378e2
commit 46468890d5
7 changed files with 74 additions and 11 deletions
@@ -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)