mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Merge branch 'chat-interface' into main
Add rich chat interface with iMessage-style message bubbles, terminal toggle, session info bar, code block rendering with copy button, and tool call cards. Supports both terminal and rich chat display modes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -217,6 +217,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user