From 1791c1a765280790b63d996441ff008ddec76584 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 09:02:21 +0000 Subject: [PATCH] feat: render native chat markdown via Textual --- CHANGELOG.md | 1 + apps/ios/SwiftSources.input.xcfilelist | 2 +- apps/ios/project.yml | 2 +- .../Sources/Clawdbot/InstancesSettings.swift | 4 +- .../InstancesSettingsSmokeTests.swift | 2 +- apps/shared/ClawdbotKit/Package.swift | 11 +- .../ChatMarkdownPreprocessor.swift | 51 +++++++ .../ClawdbotChatUI/ChatMarkdownSplitter.swift | 114 -------------- .../ClawdbotChatUI/ChatMessageViews.swift | 139 ++++-------------- 9 files changed, 97 insertions(+), 229 deletions(-) create mode 100644 apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift delete mode 100644 apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ede5d9d6..2d827a08e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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`. - CLI: set process titles to `clawdbot-` for clearer process listings. - CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 7be850a59..2a267bb0e 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -26,7 +26,7 @@ Sources/Voice/VoiceTab.swift Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakePreferences.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/ChatModels.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 8341cde48..d1b0c2fee 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -2,7 +2,7 @@ name: Clawdbot options: bundleIdPrefix: com.clawdbot deploymentTarget: - iOS: "17.0" + iOS: "18.0" xcodeVersion: "16.0" settings: diff --git a/apps/macos/Sources/Clawdbot/InstancesSettings.swift b/apps/macos/Sources/Clawdbot/InstancesSettings.swift index e1486b08e..92246e973 100644 --- a/apps/macos/Sources/Clawdbot/InstancesSettings.swift +++ b/apps/macos/Sources/Clawdbot/InstancesSettings.swift @@ -395,7 +395,7 @@ extension InstancesSettings { host: "phone", ip: "10.0.0.3", version: "2.0.0", - platform: "iOS 17.2", + platform: "iOS 18.0", deviceFamily: "iPhone", modelIdentifier: nil, lastInputSeconds: 35, @@ -446,7 +446,7 @@ extension InstancesSettings { _ = view.platformIcon("watchOS 10") _ = view.platformIcon("unknown 1.0") _ = view.prettyPlatform("macOS 14.2") - _ = view.prettyPlatform("iOS 17") + _ = view.prettyPlatform("iOS 18") _ = view.prettyPlatform("ipados 17.1") _ = view.prettyPlatform("linux") _ = view.prettyPlatform(" ") diff --git a/apps/macos/Tests/ClawdbotIPCTests/InstancesSettingsSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/InstancesSettingsSmokeTests.swift index aafd3ab43..cdd875160 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/InstancesSettingsSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/InstancesSettingsSmokeTests.swift @@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests { host: "gateway", ip: "10.0.0.4", version: "3.0.0", - platform: "iOS 17", + platform: "iOS 18", deviceFamily: nil, modelIdentifier: nil, lastInputSeconds: nil, diff --git a/apps/shared/ClawdbotKit/Package.swift b/apps/shared/ClawdbotKit/Package.swift index d92cb9525..b094e0a1c 100644 --- a/apps/shared/ClawdbotKit/Package.swift +++ b/apps/shared/ClawdbotKit/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "ClawdbotKit", platforms: [ - .iOS(.v17), + .iOS(.v18), .macOS(.v15), ], products: [ @@ -14,6 +14,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), + .package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"), ], targets: [ .target( @@ -29,7 +30,13 @@ let package = Package( ]), .target( name: "ClawdbotChatUI", - dependencies: ["ClawdbotKit"], + dependencies: [ + "ClawdbotKit", + .product( + name: "Textual", + package: "textual", + condition: .when(platforms: [.macOS, .iOS])), + ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift new file mode 100644 index 000000000..6c2ff1c32 --- /dev/null +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift @@ -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.. 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..