feat: render native chat markdown via Textual

This commit is contained in:
Peter Steinberger
2026-01-16 09:02:21 +00:00
parent 6e53c061ff
commit 1791c1a765
9 changed files with 97 additions and 229 deletions

View File

@@ -2,6 +2,7 @@
## 2026.1.15 (unreleased) ## 2026.1.15 (unreleased)
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`. - **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings. - CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). - CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).

View File

@@ -26,7 +26,7 @@ Sources/Voice/VoiceTab.swift
Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakeManager.swift
Sources/Voice/VoiceWakePreferences.swift Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift

View File

@@ -2,7 +2,7 @@ name: Clawdbot
options: options:
bundleIdPrefix: com.clawdbot bundleIdPrefix: com.clawdbot
deploymentTarget: deploymentTarget:
iOS: "17.0" iOS: "18.0"
xcodeVersion: "16.0" xcodeVersion: "16.0"
settings: settings:

View File

@@ -395,7 +395,7 @@ extension InstancesSettings {
host: "phone", host: "phone",
ip: "10.0.0.3", ip: "10.0.0.3",
version: "2.0.0", version: "2.0.0",
platform: "iOS 17.2", platform: "iOS 18.0",
deviceFamily: "iPhone", deviceFamily: "iPhone",
modelIdentifier: nil, modelIdentifier: nil,
lastInputSeconds: 35, lastInputSeconds: 35,
@@ -446,7 +446,7 @@ extension InstancesSettings {
_ = view.platformIcon("watchOS 10") _ = view.platformIcon("watchOS 10")
_ = view.platformIcon("unknown 1.0") _ = view.platformIcon("unknown 1.0")
_ = view.prettyPlatform("macOS 14.2") _ = view.prettyPlatform("macOS 14.2")
_ = view.prettyPlatform("iOS 17") _ = view.prettyPlatform("iOS 18")
_ = view.prettyPlatform("ipados 17.1") _ = view.prettyPlatform("ipados 17.1")
_ = view.prettyPlatform("linux") _ = view.prettyPlatform("linux")
_ = view.prettyPlatform(" ") _ = view.prettyPlatform(" ")

View File

@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
host: "gateway", host: "gateway",
ip: "10.0.0.4", ip: "10.0.0.4",
version: "3.0.0", version: "3.0.0",
platform: "iOS 17", platform: "iOS 18",
deviceFamily: nil, deviceFamily: nil,
modelIdentifier: nil, modelIdentifier: nil,
lastInputSeconds: nil, lastInputSeconds: nil,

View File

@@ -5,7 +5,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "ClawdbotKit", name: "ClawdbotKit",
platforms: [ platforms: [
.iOS(.v17), .iOS(.v18),
.macOS(.v15), .macOS(.v15),
], ],
products: [ products: [
@@ -14,6 +14,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
], ],
targets: [ targets: [
.target( .target(
@@ -29,7 +30,13 @@ let package = Package(
]), ]),
.target( .target(
name: "ClawdbotChatUI", name: "ClawdbotChatUI",
dependencies: ["ClawdbotKit"], dependencies: [
"ClawdbotKit",
.product(
name: "Textual",
package: "textual",
condition: .when(platforms: [.macOS, .iOS])),
],
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),

View File

@@ -0,0 +1,51 @@
import Foundation
enum ChatMarkdownPreprocessor {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Result {
let cleaned: String
let images: [InlineImage]
}
static func preprocess(markdown raw: String) -> Result {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return Result(cleaned: raw, images: [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return Result(cleaned: raw, images: []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return Result(cleaned: normalized, images: images.reversed())
}
}

View File

@@ -1,114 +0,0 @@
import Foundation
enum ChatMarkdownSplitter {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Block: Identifiable {
enum Kind: Equatable {
case text
case code(language: String?)
}
let id = UUID()
let kind: Kind
let text: String
}
struct SplitResult {
let blocks: [Block]
let images: [InlineImage]
}
static func split(markdown raw: String) -> SplitResult {
let extracted = self.extractInlineImages(from: raw)
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
return SplitResult(blocks: blocks, images: extracted.images)
}
private static func splitCodeBlocks(from raw: String) -> [Block] {
var blocks: [Block] = []
var buffer: [String] = []
var inCode = false
var codeLang: String?
var codeLines: [String] = []
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.hasPrefix("```") {
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
inCode = false
codeLang = nil
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
buffer.removeAll(keepingCapacity: true)
inCode = true
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
if codeLang?.isEmpty == true { codeLang = nil }
}
continue
}
if inCode {
codeLines.append(line)
} else {
buffer.append(line)
}
}
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
}
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
}
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return (raw, [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return (raw, []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (normalized, images.reversed())
}
}

View File

@@ -1,6 +1,7 @@
import ClawdbotKit import ClawdbotKit
import Foundation import Foundation
import SwiftUI import SwiftUI
import Textual
private enum ChatUIConstants { private enum ChatUIConstants {
static let bubbleMaxWidth: CGFloat = 560 static let bubbleMaxWidth: CGFloat = 560
@@ -169,37 +170,7 @@ private struct ChatMessageBody: View {
isUser: self.isUser) isUser: self.isUser)
} }
} else if self.isUser { } else if self.isUser {
let split = ChatMarkdownSplitter.split(markdown: text) ChatMarkdownView(text: text, textColor: textColor, font: .system(size: 14))
ForEach(split.blocks) { block in
switch block.kind {
case .text:
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 {
ClawdbotPlatformImageFactory.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 { } else {
ChatAssistantTextBody(text: text) ChatAssistantTextBody(text: text)
} }
@@ -613,26 +584,22 @@ private struct TypingDots: View {
} }
@MainActor @MainActor
private struct MarkdownTextView: View { private struct ChatMarkdownView: View {
let text: String let text: String
let textColor: Color let textColor: Color
let font: Font let font: Font
var body: some View { var body: some View {
let normalized = self.text.replacingOccurrences( let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
of: "(?<!\\n)\\n(?!\\n)", VStack(alignment: .leading, spacing: 10) {
with: " ", StructuredText(markdown: processed.cleaned)
options: .regularExpression)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed)
.font(self.font)
.foregroundStyle(self.textColor)
} else {
Text(normalized)
.font(self.font) .font(self.font)
.foregroundStyle(self.textColor) .foregroundStyle(self.textColor)
.textual.textSelection(.enabled)
if !processed.images.isEmpty {
InlineImageList(images: processed.images)
}
} }
} }
} }
@@ -646,80 +613,36 @@ private struct ChatAssistantTextBody: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14) let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font) ChatMarkdownView(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
} }
} }
} }
} }
@MainActor @MainActor
private struct ChatMarkdownBody: View { private struct InlineImageList: View {
let text: String let images: [ChatMarkdownPreprocessor.InlineImage]
let textColor: Color
let font: Font
var body: some View { var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text) if images.isEmpty {
VStack(alignment: .leading, spacing: 10) { EmptyView()
ForEach(split.blocks) { block in } else {
switch block.kind { ForEach(images, id: \.id) { item in
case .text: if let img = item.image {
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font) ClawdbotPlatformImageFactory.image(img)
case let .code(language): .resizable()
CodeBlockView(code: block.text, language: language, isUser: false) .scaledToFit()
} .frame(maxHeight: 260)
} .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
if !split.images.isEmpty { RoundedRectangle(cornerRadius: 12, style: .continuous)
ForEach( .strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
split.images, } else {
id: \ChatMarkdownSplitter.InlineImage.id) Text(item.label.isEmpty ? "Image" : item.label)
{ (item: ChatMarkdownSplitter.InlineImage) in .font(.footnote)
if let img = item.image { .foregroundStyle(.secondary)
ClawdbotPlatformImageFactory.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)
}
} }
} }
} }
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String
let language: String?
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let language, !language.isEmpty {
Text(language)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Text(self.code)
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundStyle(self.isUser ? .white : .primary)
.textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
} }