mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user