ui: render tool call cards
This commit is contained in:
@@ -159,12 +159,21 @@ private struct ChatMessageBody: View {
|
|||||||
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
|
let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(split.blocks) { block in
|
if self.isToolResultMessage {
|
||||||
switch block.kind {
|
if !text.isEmpty {
|
||||||
case .text:
|
ToolResultCard(
|
||||||
MarkdownTextView(text: block.text, textColor: textColor)
|
title: self.toolResultTitle,
|
||||||
case let .code(language):
|
text: text,
|
||||||
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
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)
|
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)
|
.textSelection(.enabled)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -214,7 +231,36 @@ private struct ChatMessageBody: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var inlineAttachments: [ClawdisChatMessageContent] {
|
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 {
|
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
|
@MainActor
|
||||||
struct ChatTypingIndicatorBubble: View {
|
struct ChatTypingIndicatorBubble: View {
|
||||||
let style: ClawdisChatView.Style
|
let style: ClawdisChatView.Style
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
|
|||||||
public let content: [ClawdisChatMessageContent]
|
public let content: [ClawdisChatMessageContent]
|
||||||
public let timestamp: Double?
|
public let timestamp: Double?
|
||||||
public let toolCallId: String?
|
public let toolCallId: String?
|
||||||
|
public let toolName: String?
|
||||||
public let usage: ClawdisChatUsage?
|
public let usage: ClawdisChatUsage?
|
||||||
public let stopReason: String?
|
public let stopReason: String?
|
||||||
|
|
||||||
@@ -150,6 +151,8 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
|
|||||||
case timestamp
|
case timestamp
|
||||||
case toolCallId
|
case toolCallId
|
||||||
case tool_call_id
|
case tool_call_id
|
||||||
|
case toolName
|
||||||
|
case tool_name
|
||||||
case usage
|
case usage
|
||||||
case stopReason
|
case stopReason
|
||||||
}
|
}
|
||||||
@@ -160,6 +163,7 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
|
|||||||
content: [ClawdisChatMessageContent],
|
content: [ClawdisChatMessageContent],
|
||||||
timestamp: Double?,
|
timestamp: Double?,
|
||||||
toolCallId: String? = nil,
|
toolCallId: String? = nil,
|
||||||
|
toolName: String? = nil,
|
||||||
usage: ClawdisChatUsage? = nil,
|
usage: ClawdisChatUsage? = nil,
|
||||||
stopReason: String? = nil)
|
stopReason: String? = nil)
|
||||||
{
|
{
|
||||||
@@ -168,6 +172,7 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
|
|||||||
self.content = content
|
self.content = content
|
||||||
self.timestamp = timestamp
|
self.timestamp = timestamp
|
||||||
self.toolCallId = toolCallId
|
self.toolCallId = toolCallId
|
||||||
|
self.toolName = toolName
|
||||||
self.usage = usage
|
self.usage = usage
|
||||||
self.stopReason = stopReason
|
self.stopReason = stopReason
|
||||||
}
|
}
|
||||||
@@ -179,6 +184,9 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
|
|||||||
self.toolCallId =
|
self.toolCallId =
|
||||||
try container.decodeIfPresent(String.self, forKey: .toolCallId) ??
|
try container.decodeIfPresent(String.self, forKey: .toolCallId) ??
|
||||||
container.decodeIfPresent(String.self, forKey: .tool_call_id)
|
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.usage = try container.decodeIfPresent(ClawdisChatUsage.self, forKey: .usage)
|
||||||
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
|
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.encode(self.role, forKey: .role)
|
||||||
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
|
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
|
||||||
try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId)
|
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.usage, forKey: .usage)
|
||||||
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
|
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
|
||||||
try container.encode(self.content, forKey: .content)
|
try container.encode(self.content, forKey: .content)
|
||||||
|
|||||||
@@ -19,16 +19,19 @@ enum ClawdisChatTheme {
|
|||||||
static var background: some View {
|
static var background: some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
ZStack {
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(nsColor: .windowBackgroundColor).opacity(0.85),
|
Color.white.opacity(0.12),
|
||||||
Color.black.opacity(0.92),
|
Color(nsColor: .windowBackgroundColor).opacity(0.35),
|
||||||
|
Color.black.opacity(0.35),
|
||||||
],
|
],
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing)
|
endPoint: .bottomTrailing)
|
||||||
RadialGradient(
|
RadialGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(nsColor: .systemOrange).opacity(0.18),
|
Color(nsColor: .systemOrange).opacity(0.14),
|
||||||
.clear,
|
.clear,
|
||||||
],
|
],
|
||||||
center: .topLeading,
|
center: .topLeading,
|
||||||
@@ -36,13 +39,13 @@ enum ClawdisChatTheme {
|
|||||||
endRadius: 320)
|
endRadius: 320)
|
||||||
RadialGradient(
|
RadialGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(nsColor: .systemTeal).opacity(0.16),
|
Color(nsColor: .systemTeal).opacity(0.12),
|
||||||
.clear,
|
.clear,
|
||||||
],
|
],
|
||||||
center: .topTrailing,
|
center: .topTrailing,
|
||||||
startRadius: 40,
|
startRadius: 40,
|
||||||
endRadius: 280)
|
endRadius: 280)
|
||||||
Color.black.opacity(0.12)
|
Color.black.opacity(0.08)
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
Color(uiColor: .systemBackground)
|
Color(uiColor: .systemBackground)
|
||||||
|
|||||||
Reference in New Issue
Block a user