diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift index 094785996..23c7b9684 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift @@ -159,12 +159,21 @@ private struct ChatMessageBody: View { let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText VStack(alignment: .leading, spacing: 10) { - ForEach(split.blocks) { block in - switch block.kind { - case .text: - MarkdownTextView(text: block.text, textColor: textColor) - case let .code(language): - CodeBlockView(code: block.text, language: language, isUser: self.isUser) + if self.isToolResultMessage { + if !text.isEmpty { + ToolResultCard( + title: self.toolResultTitle, + text: text, + isUser: self.isUser) + } + } else { + ForEach(split.blocks) { block in + switch block.kind { + case .text: + MarkdownTextView(text: block.text, textColor: textColor) + case let .code(language): + CodeBlockView(code: block.text, language: language, isUser: self.isUser) + } } } @@ -195,6 +204,14 @@ private struct ChatMessageBody: View { AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser) } } + + if !self.toolCalls.isEmpty { + ForEach(self.toolCalls.indices, id: \.self) { idx in + ToolCallCard( + content: self.toolCalls[idx], + isUser: self.isUser) + } + } } .textSelection(.enabled) .padding(.vertical, 10) @@ -214,7 +231,36 @@ private struct ChatMessageBody: View { } private var inlineAttachments: [ClawdisChatMessageContent] { - self.message.content.filter { ($0.type ?? "text") != "text" } + self.message.content.filter { content in + switch content.type ?? "text" { + case "file", "attachment": + return true + default: + return false + } + } + } + + private var toolCalls: [ClawdisChatMessageContent] { + self.message.content.filter { content in + let kind = (content.type ?? "").lowercased() + if ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) { + return true + } + return content.name != nil && content.arguments != nil + } + } + + private var isToolResultMessage: Bool { + let role = self.message.role.lowercased() + return role == "toolresult" || role == "tool_result" + } + + private var toolResultTitle: String { + if let name = self.message.toolName, !name.isEmpty { + return name + } + return "Tool result" } private var bubbleFillColor: Color { @@ -300,6 +346,139 @@ private struct AttachmentRow: View { } } +private struct ToolCallCard: View { + let content: ClawdisChatMessageContent + let isUser: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "hammer") + .imageScale(.small) + Text(self.toolName) + .font(.footnote.weight(.semibold)) + Spacer(minLength: 0) + } + + if let summary = self.summary, !summary.isEmpty { + Text(summary) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ClawdisChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) + } + + private var toolName: String { + self.content.name?.isEmpty == false ? (self.content.name ?? "Tool") : "Tool" + } + + private var summary: String? { + guard let args = self.content.arguments else { return nil } + if let dict = args.value as? [String: AnyCodable] { + if let command = dict["command"]?.value as? String { return command } + if let path = dict["path"]?.value as? String { return path } + if let pattern = dict["pattern"]?.value as? String { return pattern } + if let query = dict["query"]?.value as? String { return query } + if let url = dict["url"]?.value as? String { return url } + return Self.renderArgs(dict) + } + return Self.renderValue(args) + } + + private static func renderArgs(_ dict: [String: AnyCodable]) -> String? { + let keys = dict.keys.sorted() + let pairs = keys.prefix(6).compactMap { key -> String? in + guard let value = dict[key] else { return nil } + return "\(key)=\(renderValue(value) ?? "…")" + } + guard !pairs.isEmpty else { return nil } + return pairs.joined(separator: " ") + } + + private static func renderValue(_ value: AnyCodable) -> String? { + switch value.value { + case let str as String: + return str + case let num as Int: + return String(num) + case let num as Double: + return String(num) + case let bool as Bool: + return bool ? "true" : "false" + default: + if let data = try? JSONEncoder().encode(value), + let string = String(data: data, encoding: .utf8) + { + return string + } + return nil + } + } +} + +private struct ToolResultCard: View { + let title: String + let text: String + let isUser: Bool + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "terminal") + .imageScale(.small) + Text(self.title) + .font(.footnote.weight(.semibold)) + Spacer(minLength: 0) + } + + Text(self.displayText) + .font(.footnote.monospaced()) + .foregroundStyle(self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText) + .lineLimit(self.expanded ? nil : Self.previewLineLimit) + + if self.shouldShowToggle { + Button(self.expanded ? "Show less" : "Show full output") { + self.expanded.toggle() + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(ClawdisChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) + } + + private static let previewLineLimit = 8 + + private var lines: [Substring] { + self.text.components(separatedBy: .newlines).map { Substring($0) } + } + + private var displayText: String { + guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.text } + return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n…" + } + + private var shouldShowToggle: Bool { + self.lines.count > Self.previewLineLimit + } +} + @MainActor struct ChatTypingIndicatorBubble: View { let style: ClawdisChatView.Style diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift index 7b68e13ea..c180df516 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift @@ -141,6 +141,7 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable { public let content: [ClawdisChatMessageContent] public let timestamp: Double? public let toolCallId: String? + public let toolName: String? public let usage: ClawdisChatUsage? public let stopReason: String? @@ -150,6 +151,8 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable { case timestamp case toolCallId case tool_call_id + case toolName + case tool_name case usage case stopReason } @@ -160,6 +163,7 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable { content: [ClawdisChatMessageContent], timestamp: Double?, toolCallId: String? = nil, + toolName: String? = nil, usage: ClawdisChatUsage? = nil, stopReason: String? = nil) { @@ -168,6 +172,7 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable { self.content = content self.timestamp = timestamp self.toolCallId = toolCallId + self.toolName = toolName self.usage = usage self.stopReason = stopReason } @@ -179,6 +184,9 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable { self.toolCallId = try container.decodeIfPresent(String.self, forKey: .toolCallId) ?? container.decodeIfPresent(String.self, forKey: .tool_call_id) + self.toolName = + try container.decodeIfPresent(String.self, forKey: .toolName) ?? + container.decodeIfPresent(String.self, forKey: .tool_name) self.usage = try container.decodeIfPresent(ClawdisChatUsage.self, forKey: .usage) self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason) @@ -213,6 +221,7 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable { try container.encode(self.role, forKey: .role) try container.encodeIfPresent(self.timestamp, forKey: .timestamp) try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId) + try container.encodeIfPresent(self.toolName, forKey: .toolName) try container.encodeIfPresent(self.usage, forKey: .usage) try container.encodeIfPresent(self.stopReason, forKey: .stopReason) try container.encode(self.content, forKey: .content) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift index 107f25970..c676efd58 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift @@ -19,16 +19,19 @@ enum ClawdisChatTheme { static var background: some View { #if os(macOS) ZStack { + Rectangle() + .fill(.ultraThinMaterial) LinearGradient( colors: [ - Color(nsColor: .windowBackgroundColor).opacity(0.85), - Color.black.opacity(0.92), + Color.white.opacity(0.12), + Color(nsColor: .windowBackgroundColor).opacity(0.35), + Color.black.opacity(0.35), ], startPoint: .topLeading, endPoint: .bottomTrailing) RadialGradient( colors: [ - Color(nsColor: .systemOrange).opacity(0.18), + Color(nsColor: .systemOrange).opacity(0.14), .clear, ], center: .topLeading, @@ -36,13 +39,13 @@ enum ClawdisChatTheme { endRadius: 320) RadialGradient( colors: [ - Color(nsColor: .systemTeal).opacity(0.16), + Color(nsColor: .systemTeal).opacity(0.12), .clear, ], center: .topTrailing, startRadius: 40, endRadius: 280) - Color.black.opacity(0.12) + Color.black.opacity(0.08) } #else Color(uiColor: .systemBackground)