From 8878fd30280d71cc7f1ae32e9c0300fb4f396cf9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 24 Dec 2025 14:38:43 +0100 Subject: [PATCH] ui: merge tool call results --- .../ClawdisChatUI/ChatMessageViews.swift | 23 ++++- .../Sources/ClawdisChatUI/ChatView.swift | 95 ++++++++++++++++++- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift index 4830dbfc2..d1fd00517 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift @@ -213,6 +213,16 @@ private struct ChatMessageBody: View { isUser: self.isUser) } } + + if !self.inlineToolResults.isEmpty { + ForEach(self.inlineToolResults.indices, id: \.self) { idx in + let toolResult = self.inlineToolResults[idx] + ToolResultCard( + title: toolResult.name ?? "Tool result", + text: toolResult.text ?? "", + isUser: self.isUser) + } + } } .textSelection(.enabled) .padding(.vertical, 10) @@ -227,7 +237,11 @@ private struct ChatMessageBody: View { } private var primaryText: String { - let parts = self.message.content.compactMap(\.text) + let parts = self.message.content.compactMap { content -> String? in + let kind = (content.type ?? "text").lowercased() + guard kind == "text" || kind.isEmpty else { return nil } + return content.text + } return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } @@ -252,6 +266,13 @@ private struct ChatMessageBody: View { } } + private var inlineToolResults: [ClawdisChatMessageContent] { + self.message.content.filter { content in + let kind = (content.type ?? "").lowercased() + return kind == "toolresult" || kind == "tool_result" + } + } + private var isToolResultMessage: Bool { let role = self.message.role.lowercased() return role == "toolresult" || role == "tool_result" diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index 4c6f95d5a..c329a7914 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -133,9 +133,96 @@ public struct ClawdisChatView: View { } private var visibleMessages: [ClawdisChatMessage] { - guard self.style == .onboarding else { return self.viewModel.messages } - guard let first = self.viewModel.messages.first else { return [] } - guard first.role.lowercased() == "user" else { return self.viewModel.messages } - return Array(self.viewModel.messages.dropFirst()) + let base: [ClawdisChatMessage] + if self.style == .onboarding { + guard let first = self.viewModel.messages.first else { return [] } + base = first.role.lowercased() == "user" ? Array(self.viewModel.messages.dropFirst()) : self.viewModel.messages + } else { + base = self.viewModel.messages + } + return self.mergeToolResults(in: base) + } + + private func mergeToolResults(in messages: [ClawdisChatMessage]) -> [ClawdisChatMessage] { + var result: [ClawdisChatMessage] = [] + result.reserveCapacity(messages.count) + + for message in messages { + guard self.isToolResultMessage(message) else { + result.append(message) + continue + } + + guard let toolCallId = message.toolCallId, + let last = result.last, + self.toolCallIds(in: last).contains(toolCallId) + else { + result.append(message) + continue + } + + let toolText = self.toolResultText(from: message) + if toolText.isEmpty { + continue + } + + var content = last.content + content.append( + ClawdisChatMessageContent( + type: "tool_result", + text: toolText, + thinking: nil, + thinkingSignature: nil, + mimeType: nil, + fileName: nil, + content: nil, + id: toolCallId, + name: message.toolName, + arguments: nil)) + + let merged = ClawdisChatMessage( + id: last.id, + role: last.role, + content: content, + timestamp: last.timestamp, + toolCallId: last.toolCallId, + toolName: last.toolName, + usage: last.usage, + stopReason: last.stopReason) + result[result.count - 1] = merged + } + + return result + } + + private func isToolResultMessage(_ message: ClawdisChatMessage) -> Bool { + let role = message.role.lowercased() + return role == "toolresult" || role == "tool_result" + } + + private func toolCallIds(in message: ClawdisChatMessage) -> Set { + var ids = Set() + for content in message.content { + let kind = (content.type ?? "").lowercased() + let isTool = + ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) || + (content.name != nil && content.arguments != nil) + if isTool, let id = content.id { + ids.insert(id) + } + } + if let toolCallId = message.toolCallId { + ids.insert(toolCallId) + } + return ids + } + + private func toolResultText(from message: ClawdisChatMessage) -> String { + let parts = message.content.compactMap { content -> String? in + let kind = (content.type ?? "text").lowercased() + guard kind == "text" || kind.isEmpty else { return nil } + return content.text + } + return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } }