ui: render tool call cards

This commit is contained in:
Peter Steinberger
2025-12-24 14:29:40 +01:00
parent 816b784399
commit 25d2d7389f
3 changed files with 203 additions and 12 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)