diff --git a/CHANGELOG.md b/CHANGELOG.md index 30000a003..cadf4315b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp. - Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna. - Control UI: stream live tool output cards in Chat (agent events include sessionKey). +- Chat UI: render assistant ``/`` markup as italic thinking text in history + streaming instead of showing raw tags. - Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference. - Agent: route embedded run lifecycle logs through subsystem console formatting and reduce log noise. - Agent tools: scope the Discord tool to Discord surface runs. diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/AssistantTextParser.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/AssistantTextParser.swift new file mode 100644 index 000000000..ffa672eba --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/AssistantTextParser.swift @@ -0,0 +1,139 @@ +import Foundation + +struct AssistantTextSegment: Identifiable { + enum Kind { + case thinking + case response + } + + let id = UUID() + let kind: Kind + let text: String +} + +enum AssistantTextParser { + static func segments(from raw: String) -> [AssistantTextSegment] { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + guard raw.contains("<") else { + return [AssistantTextSegment(kind: .response, text: trimmed)] + } + + var segments: [AssistantTextSegment] = [] + var cursor = raw.startIndex + var currentKind: AssistantTextSegment.Kind = .response + var matchedTag = false + + while let match = self.nextTag(in: raw, from: cursor) { + matchedTag = true + if match.range.lowerBound > cursor { + self.appendSegment(kind: currentKind, text: raw[cursor..", range: match.range.upperBound.. Bool { + !self.segments(from: raw).isEmpty + } + + private enum TagKind { + case think + case final + } + + private struct TagMatch { + let kind: TagKind + let closing: Bool + let range: Range + } + + private static func nextTag(in text: String, from start: String.Index) -> TagMatch? { + let candidates: [TagMatch] = [ + self.findTagStart(tag: "think", closing: false, in: text, from: start).map { + TagMatch(kind: .think, closing: false, range: $0) + }, + self.findTagStart(tag: "think", closing: true, in: text, from: start).map { + TagMatch(kind: .think, closing: true, range: $0) + }, + self.findTagStart(tag: "final", closing: false, in: text, from: start).map { + TagMatch(kind: .final, closing: false, range: $0) + }, + self.findTagStart(tag: "final", closing: true, in: text, from: start).map { + TagMatch(kind: .final, closing: true, range: $0) + }, + ].compactMap { $0 } + + return candidates.min { $0.range.lowerBound < $1.range.lowerBound } + } + + private static func findTagStart( + tag: String, + closing: Bool, + in text: String, + from start: String.Index) -> Range? + { + let token = closing ? "" || boundary.isWhitespace || (!closing && boundary == "/") + if isBoundary { + return range + } + searchRange = boundaryIndex..) -> Bool { + var cursor = tagEnd.lowerBound + while cursor > text.startIndex { + cursor = text.index(before: cursor) + let char = text[cursor] + if char.isWhitespace { continue } + return char == "/" + } + return false + } + + private static func appendSegment( + kind: AssistantTextSegment.Kind, + text: Substring, + to segments: inout [AssistantTextSegment]) + { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + segments.append(AssistantTextSegment(kind: kind, text: trimmed)) + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift index dcb2fee23..be18f936a 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift @@ -158,7 +158,6 @@ private struct ChatMessageBody: View { var body: some View { let text = self.primaryText - let split = ChatMarkdownSplitter.split(markdown: text) let textColor = self.isUser ? ClawdisChatTheme.userText : ClawdisChatTheme.assistantText VStack(alignment: .leading, spacing: 10) { @@ -169,37 +168,40 @@ private struct ChatMessageBody: View { text: text, isUser: self.isUser) } - } else { + } else if self.isUser { + let split = ChatMarkdownSplitter.split(markdown: text) ForEach(split.blocks) { block in switch block.kind { case .text: - MarkdownTextView(text: block.text, textColor: textColor) + MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14)) case let .code(language): CodeBlockView(code: block.text, language: language, isUser: self.isUser) } } - } - if !split.images.isEmpty { - ForEach( - split.images, - id: \ChatMarkdownSplitter.InlineImage.id) - { (item: ChatMarkdownSplitter.InlineImage) in - if let img = item.image { - ClawdisPlatformImageFactory.image(img) - .resizable() - .scaledToFit() - .frame(maxHeight: 260) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) - } else { - Text(item.label.isEmpty ? "Image" : item.label) - .font(.footnote) - .foregroundStyle(.secondary) + if !split.images.isEmpty { + ForEach( + split.images, + id: \ChatMarkdownSplitter.InlineImage.id) + { (item: ChatMarkdownSplitter.InlineImage) in + if let img = item.image { + ClawdisPlatformImageFactory.image(img) + .resizable() + .scaledToFit() + .frame(maxHeight: 260) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) + } else { + Text(item.label.isEmpty ? "Image" : item.label) + .font(.footnote) + .foregroundStyle(.secondary) + } } } + } else { + ChatAssistantTextBody(text: text) } if !self.inlineAttachments.isEmpty { @@ -506,7 +508,7 @@ struct ChatStreamingAssistantBubble: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - ChatMarkdownBody(text: self.text, textColor: ClawdisChatTheme.assistantText) + ChatAssistantTextBody(text: self.text) } .padding(12) .background( @@ -614,6 +616,7 @@ private struct TypingDots: View { private struct MarkdownTextView: View { let text: String let textColor: Color + let font: Font var body: some View { let normalized = self.text.replacingOccurrences( @@ -624,20 +627,36 @@ private struct MarkdownTextView: View { interpretedSyntax: .inlineOnlyPreservingWhitespace) if let attributed = try? AttributedString(markdown: normalized, options: options) { Text(attributed) - .font(.system(size: 14)) + .font(self.font) .foregroundStyle(self.textColor) } else { Text(normalized) - .font(.system(size: 14)) + .font(self.font) .foregroundStyle(self.textColor) } } } +@MainActor +private struct ChatAssistantTextBody: View { + let text: String + + var body: some View { + let segments = AssistantTextParser.segments(from: self.text) + VStack(alignment: .leading, spacing: 10) { + ForEach(segments) { segment in + let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14) + ChatMarkdownBody(text: segment.text, textColor: ClawdisChatTheme.assistantText, font: font) + } + } + } +} + @MainActor private struct ChatMarkdownBody: View { let text: String let textColor: Color + let font: Font var body: some View { let split = ChatMarkdownSplitter.split(markdown: self.text) @@ -645,7 +664,7 @@ private struct ChatMarkdownBody: View { ForEach(split.blocks) { block in switch block.kind { case .text: - MarkdownTextView(text: block.text, textColor: self.textColor) + MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font) case let .code(language): CodeBlockView(code: block.text, language: language, isUser: false) } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index 4bb862486..68534f320 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -169,7 +169,7 @@ public struct ClawdisChatView: View { .frame(maxWidth: .infinity, alignment: .leading) } - if let text = self.viewModel.streamingAssistantText, !text.isEmpty { + if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) { ChatStreamingAssistantBubble(text: text) .frame(maxWidth: .infinity, alignment: .leading) } @@ -246,7 +246,7 @@ public struct ClawdisChatView: View { return true } if let text = self.viewModel.streamingAssistantText, - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + AssistantTextParser.hasVisibleContent(in: text) { return true } @@ -261,7 +261,7 @@ public struct ClawdisChatView: View { private var showsEmptyState: Bool { self.viewModel.messages.isEmpty && - (self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) && + !(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) && self.viewModel.pendingRunCount == 0 && self.viewModel.pendingToolCalls.isEmpty } diff --git a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/AssistantTextParserTests.swift b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/AssistantTextParserTests.swift new file mode 100644 index 000000000..9b715c2d2 --- /dev/null +++ b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/AssistantTextParserTests.swift @@ -0,0 +1,37 @@ +@testable import ClawdisChatUI +import Testing + +@Suite struct AssistantTextParserTests { + @Test func splitsThinkAndFinalSegments() { + let segments = AssistantTextParser.segments( + from: "internal\n\nHello there") + + #expect(segments.count == 2) + #expect(segments[0].kind == .thinking) + #expect(segments[0].text == "internal") + #expect(segments[1].kind == .response) + #expect(segments[1].text == "Hello there") + } + + @Test func keepsTextWithoutTags() { + let segments = AssistantTextParser.segments(from: "Just text.") + + #expect(segments.count == 1) + #expect(segments[0].kind == .response) + #expect(segments[0].text == "Just text.") + } + + @Test func ignoresThinkingLikeTags() { + let raw = "example\nKeep this." + let segments = AssistantTextParser.segments(from: raw) + + #expect(segments.count == 1) + #expect(segments[0].kind == .response) + #expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + @Test func dropsEmptyTaggedContent() { + let segments = AssistantTextParser.segments(from: "") + #expect(segments.isEmpty) + } +}