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)