From 7d69c82c2b372e9fa9bc6f26af207c696281d0a6 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 6 Apr 2026 12:33:55 -0400 Subject: [PATCH] Add rich chat interface with iMessage-style bubbles and terminal toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Core/Services/HermesDataService.swift | 24 +++ .../Chat/ViewModels/ChatViewModel.swift | 50 +++++ .../Chat/ViewModels/RichChatViewModel.swift | 137 ++++++++++++ .../scarf/Features/Chat/Views/ChatView.swift | 50 ++++- .../Features/Chat/Views/CodeBlockView.swift | 62 ++++++ .../Chat/Views/RichChatInputBar.swift | 65 ++++++ .../Chat/Views/RichChatMessageList.swift | 115 +++++++++++ .../Features/Chat/Views/RichChatView.swift | 42 ++++ .../Chat/Views/RichMessageBubble.swift | 195 ++++++++++++++++++ .../Features/Chat/Views/SessionInfoBar.swift | 71 +++++++ .../Features/Chat/Views/ToolCallCard.swift | 134 ++++++++++++ 11 files changed, 943 insertions(+), 2 deletions(-) create mode 100644 scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift create mode 100644 scarf/scarf/Features/Chat/Views/CodeBlockView.swift create mode 100644 scarf/scarf/Features/Chat/Views/RichChatInputBar.swift create mode 100644 scarf/scarf/Features/Chat/Views/RichChatMessageList.swift create mode 100644 scarf/scarf/Features/Chat/Views/RichChatView.swift create mode 100644 scarf/scarf/Features/Chat/Views/RichMessageBubble.swift create mode 100644 scarf/scarf/Features/Chat/Views/SessionInfoBar.swift create mode 100644 scarf/scarf/Features/Chat/Views/ToolCallCard.swift diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift index 30ffe5c..a5aef9e 100644 --- a/scarf/scarf/Core/Services/HermesDataService.swift +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -191,6 +191,30 @@ actor HermesDataService { 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 struct SessionStats: Sendable { diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 7b70175..2867a69 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -14,6 +14,9 @@ final class ChatViewModel { var voiceEnabled = false var ttsEnabled = false var isRecording = false + var displayMode: ChatDisplayMode = .richChat + var activeSessionId: String? + let richChatViewModel = RichChatViewModel() private var coordinator: Coordinator? var hermesBinaryExists: Bool { @@ -24,21 +27,46 @@ final class ChatViewModel { voiceEnabled = false ttsEnabled = false isRecording = false + richChatViewModel.stopPolling() + activeSessionId = nil launchTerminal(arguments: ["chat"]) + Task { + try? await Task.sleep(for: .seconds(1.5)) + await discoverActiveSessionId() + } } func resumeSession(_ sessionId: String) { voiceEnabled = false ttsEnabled = false isRecording = false + richChatViewModel.stopPolling() + activeSessionId = sessionId launchTerminal(arguments: ["chat", "--resume", sessionId]) + richChatViewModel.startPolling(sessionId: sessionId) } func continueLastSession() { voiceEnabled = false ttsEnabled = false isRecording = false + richChatViewModel.stopPolling() + activeSessionId = nil 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 { @@ -82,6 +110,26 @@ final class ChatViewModel { 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) { let bytes = Array(text.utf8) tv.send(source: tv, data: bytes[0..? + 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 + } +} diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index b3170b9..9b94025 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -5,10 +5,11 @@ struct ChatView: View { @Environment(HermesFileWatcher.self) private var fileWatcher var body: some View { + @Bindable var vm = viewModel VStack(spacing: 0) { toolbar Divider() - terminalArea + chatArea } .navigationTitle("Chat") .task { await viewModel.loadRecentSessions() } @@ -19,7 +20,7 @@ struct ChatView: View { private var toolbar: some View { HStack(spacing: 12) { - Image(systemName: "terminal") + Image(systemName: viewModel.displayMode == .terminal ? "terminal" : "bubble.left.and.text.bubble.right") .foregroundStyle(.secondary) if viewModel.hasActiveProcess { @@ -44,6 +45,17 @@ struct ChatView: View { 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 { Label("Hermes binary not found", systemImage: "exclamationmark.triangle") .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 private var terminalArea: some View { if let terminal = viewModel.terminalView { @@ -157,4 +179,28 @@ struct ChatView: View { .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) + } + } + } } diff --git a/scarf/scarf/Features/Chat/Views/CodeBlockView.swift b/scarf/scarf/Features/Chat/Views/CodeBlockView.swift new file mode 100644 index 0000000..485a179 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/CodeBlockView.swift @@ -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") + } +} diff --git a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift new file mode 100644 index 0000000..efaa63d --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift @@ -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 = "" + } +} diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift new file mode 100644 index 0000000..155c219 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -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))" + } +} diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift new file mode 100644 index 0000000..1e07d9f --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -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() } + } + } +} diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift new file mode 100644 index 0000000..187600b --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -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 +} diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift new file mode 100644 index 0000000..957e230 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -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)" + } +} diff --git a/scarf/scarf/Features/Chat/Views/ToolCallCard.swift b/scarf/scarf/Features/Chat/Views/ToolCallCard.swift new file mode 100644 index 0000000..3499993 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/ToolCallCard.swift @@ -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) + } + } + } +}