Add rich chat interface with iMessage-style bubbles and terminal toggle

Introduce a new structured chat view as an alternative to the SwiftTerm
terminal. Users can switch between raw terminal and rich chat modes via a
segmented picker in the toolbar. The rich view polls state.db for messages
and renders them as conversation bubbles with markdown, code blocks,
expandable tool call cards, reasoning sections, and a live session info bar
showing tokens, cost, and model. The terminal process stays alive in both
modes — in rich mode it runs hidden while user input from the text field is
piped to its stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-06 12:33:55 -04:00
parent ae2872e08f
commit 7d69c82c2b
11 changed files with 943 additions and 2 deletions
@@ -191,6 +191,30 @@ actor HermesDataService {
return previews return previews
} }
// MARK: - Single-Row Queries
func fetchMessageCount(sessionId: String) -> Int {
guard let db else { return 0 }
let sql = "SELECT COUNT(*) FROM messages WHERE session_id = ?"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int(stmt, 0))
}
func fetchSession(id: String) -> HermesSession? {
guard let db else { return nil }
let sql = "SELECT \(sessionColumns) FROM sessions WHERE id = ? LIMIT 1"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, id, -1, sqliteTransient)
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
return sessionFromRow(stmt!)
}
// MARK: - Stats // MARK: - Stats
struct SessionStats: Sendable { struct SessionStats: Sendable {
@@ -14,6 +14,9 @@ final class ChatViewModel {
var voiceEnabled = false var voiceEnabled = false
var ttsEnabled = false var ttsEnabled = false
var isRecording = false var isRecording = false
var displayMode: ChatDisplayMode = .richChat
var activeSessionId: String?
let richChatViewModel = RichChatViewModel()
private var coordinator: Coordinator? private var coordinator: Coordinator?
var hermesBinaryExists: Bool { var hermesBinaryExists: Bool {
@@ -24,21 +27,46 @@ final class ChatViewModel {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.stopPolling()
activeSessionId = nil
launchTerminal(arguments: ["chat"]) launchTerminal(arguments: ["chat"])
Task {
try? await Task.sleep(for: .seconds(1.5))
await discoverActiveSessionId()
}
} }
func resumeSession(_ sessionId: String) { func resumeSession(_ sessionId: String) {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.stopPolling()
activeSessionId = sessionId
launchTerminal(arguments: ["chat", "--resume", sessionId]) launchTerminal(arguments: ["chat", "--resume", sessionId])
richChatViewModel.startPolling(sessionId: sessionId)
} }
func continueLastSession() { func continueLastSession() {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.stopPolling()
activeSessionId = nil
launchTerminal(arguments: ["chat", "--continue"]) launchTerminal(arguments: ["chat", "--continue"])
if let mostRecent = recentSessions.first {
activeSessionId = mostRecent.id
richChatViewModel.startPolling(sessionId: mostRecent.id)
} else {
Task {
try? await Task.sleep(for: .seconds(1.5))
await discoverActiveSessionId()
}
}
}
func sendText(_ text: String) {
guard let tv = terminalView else { return }
sendToTerminal(tv, text: text + "\r")
} }
func loadRecentSessions() async { func loadRecentSessions() async {
@@ -82,6 +110,26 @@ final class ChatViewModel {
isRecording.toggle() isRecording.toggle()
} }
private func discoverActiveSessionId() async {
// Capture the session that existed before launch so we can detect the new one
let previousSessionId = recentSessions.first?.id
for _ in 0..<8 {
let opened = await dataService.open()
guard opened else {
try? await Task.sleep(for: .seconds(1))
continue
}
let sessions = await dataService.fetchSessions(limit: 1)
await dataService.close()
if let newest = sessions.first, newest.id != previousSessionId {
activeSessionId = newest.id
richChatViewModel.startPolling(sessionId: newest.id)
return
}
try? await Task.sleep(for: .seconds(1))
}
}
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) { private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
let bytes = Array(text.utf8) let bytes = Array(text.utf8)
tv.send(source: tv, data: bytes[0..<bytes.count]) tv.send(source: tv, data: bytes[0..<bytes.count])
@@ -102,6 +150,8 @@ final class ChatViewModel {
self?.hasActiveProcess = false self?.hasActiveProcess = false
self?.voiceEnabled = false self?.voiceEnabled = false
self?.isRecording = false self?.isRecording = false
self?.richChatViewModel.stopPolling()
Task { await self?.richChatViewModel.refreshMessages() }
}) })
terminal.processDelegate = coord terminal.processDelegate = coord
self.coordinator = coord self.coordinator = coord
@@ -0,0 +1,137 @@
import Foundation
enum ChatDisplayMode: String, CaseIterable {
case terminal
case richChat
}
struct MessageGroup: Identifiable {
let id: Int
let userMessage: HermesMessage?
let assistantMessages: [HermesMessage]
let toolResults: [String: HermesMessage]
var allMessages: [HermesMessage] {
var result: [HermesMessage] = []
if let user = userMessage { result.append(user) }
result.append(contentsOf: assistantMessages)
return result
}
var toolCallCount: Int {
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
}
}
@Observable
final class RichChatViewModel {
private let dataService = HermesDataService()
var messages: [HermesMessage] = []
var currentSession: HermesSession?
var messageGroups: [MessageGroup] = []
var isAgentWorking = false
private var lastKnownCount = 0
private var pollingTask: Task<Void, Never>?
private var sessionId: String?
func startPolling(sessionId: String) {
self.sessionId = sessionId
lastKnownCount = 0
messages = []
messageGroups = []
isAgentWorking = false
pollingTask?.cancel()
pollingTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refreshMessages()
try? await Task.sleep(for: .milliseconds(750))
}
}
}
func stopPolling() {
pollingTask?.cancel()
pollingTask = nil
isAgentWorking = false
}
func markAgentWorking() {
isAgentWorking = true
}
func refreshMessages() async {
guard let sessionId else { return }
let opened = await dataService.open()
guard opened else { return }
let count = await dataService.fetchMessageCount(sessionId: sessionId)
if count != lastKnownCount {
let fetched = await dataService.fetchMessages(sessionId: sessionId)
let session = await dataService.fetchSession(id: sessionId)
lastKnownCount = count
messages = fetched
currentSession = session
buildMessageGroups()
if let last = fetched.last {
if last.isAssistant && last.toolCalls.isEmpty {
isAgentWorking = false
} else if last.isUser {
isAgentWorking = false
}
}
} else {
let session = await dataService.fetchSession(id: sessionId)
currentSession = session
}
await dataService.close()
}
private func buildMessageGroups() {
var groups: [MessageGroup] = []
var currentUser: HermesMessage?
var currentAssistant: [HermesMessage] = []
var currentToolResults: [String: HermesMessage] = [:]
func flushGroup() {
if currentUser != nil || !currentAssistant.isEmpty {
groups.append(MessageGroup(
id: currentUser?.id ?? currentAssistant.first?.id ?? groups.count,
userMessage: currentUser,
assistantMessages: currentAssistant,
toolResults: currentToolResults
))
}
currentUser = nil
currentAssistant = []
currentToolResults = [:]
}
for message in messages {
if message.isUser {
flushGroup()
currentUser = message
} else if message.isToolResult {
if let callId = message.toolCallId {
currentToolResults[callId] = message
}
currentAssistant.append(message)
} else {
if currentUser == nil && !currentAssistant.isEmpty && message.isAssistant {
flushGroup()
}
currentAssistant.append(message)
}
}
flushGroup()
messageGroups = groups
}
}
+48 -2
View File
@@ -5,10 +5,11 @@ struct ChatView: View {
@Environment(HermesFileWatcher.self) private var fileWatcher @Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View { var body: some View {
@Bindable var vm = viewModel
VStack(spacing: 0) { VStack(spacing: 0) {
toolbar toolbar
Divider() Divider()
terminalArea chatArea
} }
.navigationTitle("Chat") .navigationTitle("Chat")
.task { await viewModel.loadRecentSessions() } .task { await viewModel.loadRecentSessions() }
@@ -19,7 +20,7 @@ struct ChatView: View {
private var toolbar: some View { private var toolbar: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "terminal") Image(systemName: viewModel.displayMode == .terminal ? "terminal" : "bubble.left.and.text.bubble.right")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if viewModel.hasActiveProcess { if viewModel.hasActiveProcess {
@@ -44,6 +45,17 @@ struct ChatView: View {
voiceControls voiceControls
} }
Picker("View", selection: Bindable(viewModel).displayMode) {
Image(systemName: "terminal")
.help("Terminal")
.tag(ChatDisplayMode.terminal)
Image(systemName: "bubble.left.and.text.bubble.right")
.help("Rich Chat")
.tag(ChatDisplayMode.richChat)
}
.pickerStyle(.segmented)
.fixedSize()
if !viewModel.hermesBinaryExists { if !viewModel.hermesBinaryExists {
Label("Hermes binary not found", systemImage: "exclamationmark.triangle") Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
.font(.caption) .font(.caption)
@@ -137,6 +149,16 @@ struct ChatView: View {
} }
} }
@ViewBuilder
private var chatArea: some View {
switch viewModel.displayMode {
case .terminal:
terminalArea
case .richChat:
richChatArea
}
}
@ViewBuilder @ViewBuilder
private var terminalArea: some View { private var terminalArea: some View {
if let terminal = viewModel.terminalView { if let terminal = viewModel.terminalView {
@@ -157,4 +179,28 @@ struct ChatView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
@ViewBuilder
private var richChatArea: some View {
ZStack {
// Keep terminal alive in background for process hosting
if let terminal = viewModel.terminalView {
PersistentTerminalView(terminalView: terminal)
.frame(width: 0, height: 0)
.opacity(0)
.allowsHitTesting(false)
}
if viewModel.hermesBinaryExists {
RichChatView()
} else {
ContentUnavailableView(
"Hermes Not Found",
systemImage: "terminal",
description: Text("Expected at \(HermesPaths.hermesBinary)")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
} }
@@ -0,0 +1,62 @@
import SwiftUI
import AppKit
struct CodeBlockView: View {
let code: String
let language: String?
@State private var copied = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let language, !language.isEmpty {
HStack {
Text(language)
.font(.caption2.bold())
.foregroundStyle(.secondary)
Spacer()
copyButton
}
.padding(.horizontal, 10)
.padding(.top, 6)
.padding(.bottom, 2)
} else {
HStack {
Spacer()
copyButton
}
.padding(.horizontal, 10)
.padding(.top, 6)
}
ScrollView(.horizontal, showsIndicators: false) {
Text(code)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)))
.textSelection(.enabled)
.padding(.horizontal, 10)
.padding(.bottom, 8)
.padding(.top, 4)
}
}
.background(Color(nsColor: NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0)))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var copyButton: some View {
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(code, forType: .string)
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
copied = false
}
} label: {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.caption2)
.foregroundStyle(copied ? .green : .secondary)
}
.buttonStyle(.plain)
.help("Copy code")
}
}
@@ -0,0 +1,65 @@
import SwiftUI
struct RichChatInputBar: View {
let onSend: (String) -> Void
let isEnabled: Bool
@State private var text = ""
@FocusState private var isFocused: Bool
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
TextEditor(text: $text)
.font(.body)
.scrollContentBackground(.hidden)
.focused($isFocused)
.frame(minHeight: 28, maxHeight: 120)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Message Hermes...")
.foregroundStyle(.tertiary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.allowsHitTesting(false)
}
}
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.shift) {
return .ignored
}
send()
return .handled
}
Button {
send()
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(canSend ? Color.accentColor : .secondary)
}
.buttonStyle(.plain)
.disabled(!canSend)
.help("Send message (Enter)")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
}
private var canSend: Bool {
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func send() {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, isEnabled else { return }
onSend(trimmed)
text = ""
}
}
@@ -0,0 +1,115 @@
import SwiftUI
struct RichChatMessageList: View {
let groups: [MessageGroup]
let isWorking: Bool
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
ForEach(groups) { group in
MessageGroupView(group: group)
}
if isWorking {
typingIndicator
.id("typing-indicator")
}
}
.padding()
}
.onChange(of: groups.count) {
withAnimation(.easeOut(duration: 0.2)) {
if isWorking {
proxy.scrollTo("typing-indicator", anchor: .bottom)
} else if let last = groups.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.onChange(of: isWorking) {
if isWorking {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo("typing-indicator", anchor: .bottom)
}
}
}
}
}
private var typingIndicator: some View {
HStack {
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(.secondary)
.frame(width: 6, height: 6)
.opacity(0.6)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
Spacer(minLength: 80)
}
.symbolEffect(.pulse)
}
}
struct MessageGroupView: View {
let group: MessageGroup
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let user = group.userMessage {
RichMessageBubble(message: user, toolResults: [:])
}
ForEach(group.assistantMessages.filter(\.isAssistant)) { message in
RichMessageBubble(message: message, toolResults: group.toolResults)
}
if group.toolCallCount > 1 {
toolSummary
}
}
.id(group.id)
}
@ViewBuilder
private var toolSummary: some View {
let kinds = toolKindCounts
if !kinds.isEmpty {
HStack(spacing: 4) {
Image(systemName: "wrench")
.font(.caption2)
Text(summaryText(kinds))
.font(.caption2)
}
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 2)
}
}
private var toolKindCounts: [ToolKind: Int] {
var counts: [ToolKind: Int] = [:]
for msg in group.assistantMessages where msg.isAssistant {
for call in msg.toolCalls {
counts[call.toolKind, default: 0] += 1
}
}
return counts
}
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
let total = kinds.values.reduce(0, +)
let parts = kinds.sorted(by: { $0.value > $1.value })
.map { "\($0.value) \($0.key.rawValue)" }
.joined(separator: ", ")
return "Used \(total) tools (\(parts))"
}
}
@@ -0,0 +1,42 @@
import SwiftUI
struct RichChatView: View {
@Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View {
VStack(spacing: 0) {
SessionInfoBar(
session: viewModel.richChatViewModel.currentSession,
isWorking: viewModel.richChatViewModel.isAgentWorking
)
Divider()
if viewModel.richChatViewModel.messageGroups.isEmpty && !viewModel.richChatViewModel.isAgentWorking {
ContentUnavailableView(
"Chat Messages",
systemImage: "bubble.left.and.text.bubble.right",
description: Text("Messages will appear here as the conversation progresses.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
RichChatMessageList(
groups: viewModel.richChatViewModel.messageGroups,
isWorking: viewModel.richChatViewModel.isAgentWorking
)
}
Divider()
RichChatInputBar(
onSend: { text in
viewModel.sendText(text)
viewModel.richChatViewModel.markAgentWorking()
},
isEnabled: viewModel.hasActiveProcess
)
}
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.richChatViewModel.refreshMessages() }
}
}
}
@@ -0,0 +1,195 @@
import SwiftUI
struct RichMessageBubble: View {
let message: HermesMessage
let toolResults: [String: HermesMessage]
var body: some View {
if message.isUser {
userBubble
} else if message.isAssistant {
assistantBubble
}
// Tool result messages are rendered inline in ToolCallCard, not as standalone bubbles
}
// MARK: - User Bubble
private var userBubble: some View {
VStack(alignment: .trailing, spacing: 2) {
HStack {
Spacer(minLength: 80)
Text(message.content)
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
if let time = message.timestamp {
Text(time, style: .time)
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.trailing, 4)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
// MARK: - Assistant Bubble
private var assistantBubble: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
VStack(alignment: .leading, spacing: 8) {
if message.hasReasoning {
reasoningSection
}
if !message.content.isEmpty {
contentView
}
if !message.toolCalls.isEmpty {
toolCallsSection
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
Spacer(minLength: 40)
}
metadataFooter
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Content Rendering
@ViewBuilder
private var contentView: some View {
let blocks = parseContentBlocks(message.content)
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
switch block {
case .text(let text):
if let attributed = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(attributed)
.textSelection(.enabled)
} else {
Text(text)
.textSelection(.enabled)
}
case .code(let code, let language):
CodeBlockView(code: code, language: language)
}
}
}
}
// MARK: - Reasoning
private var reasoningSection: some View {
DisclosureGroup {
Text(message.reasoning ?? "")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
} label: {
HStack(spacing: 4) {
Text("Reasoning")
if let tokens = message.tokenCount, tokens > 0 {
Text("(\(tokens) tokens)")
.foregroundStyle(.tertiary)
}
}
}
.font(.caption.bold())
.foregroundStyle(.orange)
}
// MARK: - Tool Calls
private var toolCallsSection: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(message.toolCalls) { call in
ToolCallCard(
call: call,
result: toolResults[call.callId]
)
}
}
}
// MARK: - Metadata Footer
private var metadataFooter: some View {
HStack(spacing: 8) {
if let tokens = message.tokenCount, tokens > 0 {
Text("\(tokens) tokens")
}
if let reason = message.finishReason, !reason.isEmpty {
Text(reason)
}
if let time = message.timestamp {
Text(time, style: .time)
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.leading, 4)
}
}
// MARK: - Content Block Parsing
private enum ContentBlock {
case text(String)
case code(String, String?)
}
private func parseContentBlocks(_ content: String) -> [ContentBlock] {
var blocks: [ContentBlock] = []
let lines = content.components(separatedBy: "\n")
var currentText: [String] = []
var currentCode: [String] = []
var codeLanguage: String?
var inCode = false
for line in lines {
if !inCode && line.hasPrefix("```") {
if !currentText.isEmpty {
blocks.append(.text(currentText.joined(separator: "\n")))
currentText = []
}
inCode = true
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
codeLanguage = lang.isEmpty ? nil : lang
} else if inCode && line.hasPrefix("```") {
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
currentCode = []
codeLanguage = nil
inCode = false
} else if inCode {
currentCode.append(line)
} else {
currentText.append(line)
}
}
if inCode && !currentCode.isEmpty {
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
}
if !currentText.isEmpty {
let text = currentText.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(.text(text))
}
}
return blocks
}
@@ -0,0 +1,71 @@
import SwiftUI
struct SessionInfoBar: View {
let session: HermesSession?
let isWorking: Bool
var body: some View {
HStack(spacing: 16) {
if let session {
HStack(spacing: 4) {
Circle()
.fill(isWorking ? .green : .secondary)
.frame(width: 6, height: 6)
.opacity(isWorking ? 1 : 0.6)
if isWorking {
Text("Working")
.font(.caption)
.foregroundStyle(.green)
}
}
if let model = session.model {
Label(model, systemImage: "cpu")
}
Label("\(formatTokens(session.inputTokens)) in / \(formatTokens(session.outputTokens)) out", systemImage: "number")
.contentTransition(.numericText())
if session.reasoningTokens > 0 {
Label("\(formatTokens(session.reasoningTokens)) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
.contentTransition(.numericText())
}
if let start = session.startedAt {
Label {
Text(start, style: .relative)
.monospacedDigit()
} icon: {
Image(systemName: "clock")
}
}
Spacer()
Label(session.source, systemImage: session.sourceIcon)
} else {
Text("No active session")
.foregroundStyle(.tertiary)
Spacer()
}
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.vertical, 6)
.background(.bar)
}
private func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
}
}
@@ -0,0 +1,134 @@
import SwiftUI
struct ToolCallCard: View {
let call: HermesToolCall
let result: HermesMessage?
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
} label: {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 1)
.fill(toolColor)
.frame(width: 3, height: 16)
Image(systemName: call.toolKind.icon)
.font(.caption)
.foregroundStyle(toolColor)
Text(call.functionName)
.font(.caption.monospaced().bold())
.foregroundStyle(.primary)
Text(call.argumentsSummary)
.font(.caption.monospaced())
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if result != nil {
Image(systemName: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
} else {
ProgressView()
.controlSize(.mini)
}
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
.padding(.vertical, 4)
.padding(.horizontal, 8)
if expanded {
VStack(alignment: .leading, spacing: 6) {
if !call.arguments.isEmpty && call.arguments != "{}" {
Text("Arguments")
.font(.caption2.bold())
.foregroundStyle(.tertiary)
Text(formatJSON(call.arguments))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
if let result, !result.content.isEmpty {
Text("Result")
.font(.caption2.bold())
.foregroundStyle(.tertiary)
ToolResultContent(content: result.content)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 6)
}
}
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var toolColor: Color {
switch call.toolKind {
case .read: return .green
case .edit: return .blue
case .execute: return .orange
case .fetch: return .purple
case .browser: return .indigo
case .other: return .secondary
}
}
private func formatJSON(_ raw: String) -> String {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
let str = String(data: pretty, encoding: .utf8) else {
return raw
}
return str
}
}
struct ToolResultContent: View {
let content: String
@State private var showAll = false
private var lines: [String] { content.components(separatedBy: "\n") }
private var isLong: Bool { lines.count > 8 }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(showAll ? content : lines.prefix(8).joined(separator: "\n"))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
if isLong {
Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
withAnimation { showAll.toggle() }
}
.font(.caption2)
.foregroundStyle(Color.accentColor)
}
}
}
}