diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 95a5ac3e584..145e17f3b7b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -180,10 +180,12 @@ struct OpenClawChatComposer: View { VStack(alignment: .leading, spacing: 8) { self.editorOverlay - Rectangle() - .fill(OpenClawChatTheme.divider) - .frame(height: 1) - .padding(.horizontal, 2) + if !self.isComposerCompacted { + Rectangle() + .fill(OpenClawChatTheme.divider) + .frame(height: 1) + .padding(.horizontal, 2) + } HStack(alignment: .center, spacing: 8) { if self.showsConnectionPill { @@ -308,7 +310,7 @@ struct OpenClawChatComposer: View { } private var showsToolbar: Bool { - self.style == .standard + self.style == .standard && !self.isComposerCompacted } private var showsAttachments: Bool { @@ -316,15 +318,15 @@ struct OpenClawChatComposer: View { } private var showsConnectionPill: Bool { - self.style == .standard + self.style == .standard && !self.isComposerCompacted } private var composerPadding: CGFloat { - self.style == .onboarding ? 5 : 6 + self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) } private var editorPadding: CGFloat { - self.style == .onboarding ? 5 : 6 + self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) } private var textMinHeight: CGFloat { @@ -335,6 +337,14 @@ struct OpenClawChatComposer: View { self.style == .onboarding ? 52 : 64 } + private var isComposerCompacted: Bool { + #if os(macOS) + false + #else + self.style == .standard && self.isFocused + #endif + } + #if os(macOS) private func pickFilesMac() { let panel = NSOpenPanel() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift index f435eab2dca..1dc1edda778 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift @@ -1,6 +1,15 @@ import Foundation enum ChatMarkdownPreprocessor { + private static let inboundContextHeaders = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", + ] + struct InlineImage: Identifiable { let id = UUID() let label: String @@ -13,17 +22,21 @@ enum ChatMarkdownPreprocessor { } static func preprocess(markdown raw: String) -> Result { + let withoutContextBlocks = self.stripInboundContextBlocks(raw) + let withoutTimestamps = self.stripPrefixedTimestamps(withoutContextBlocks) let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"# guard let re = try? NSRegularExpression(pattern: pattern) else { - return Result(cleaned: raw, images: []) + return Result(cleaned: self.normalize(withoutTimestamps), 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: []) } + let ns = withoutTimestamps as NSString + let matches = re.matches( + in: withoutTimestamps, + range: NSRange(location: 0, length: ns.length)) + if matches.isEmpty { return Result(cleaned: self.normalize(withoutTimestamps), images: []) } var images: [InlineImage] = [] - var cleaned = raw + var cleaned = withoutTimestamps for match in matches.reversed() { guard match.numberOfRanges >= 3 else { continue } @@ -43,9 +56,32 @@ enum ChatMarkdownPreprocessor { cleaned.replaceSubrange(start.. String { + var output = raw + for header in self.inboundContextHeaders { + let escaped = NSRegularExpression.escapedPattern(for: header) + let pattern = "(?ms)^" + escaped + "\\n```json\\n.*?\\n```\\n?" + output = output.replacingOccurrences( + of: pattern, + with: "", + options: .regularExpression) + } + return output + } + + private static func stripPrefixedTimestamps(_ raw: String) -> String { + let pattern = #"(?m)^\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(?:GMT|UTC)[+-]?\d{0,2}\]\s*"# + return raw.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + } + + private static func normalize(_ raw: String) -> String { + var output = raw + output = output.replacingOccurrences(of: "\r\n", with: "\n") + output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n") + output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n") + return output.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index baa790dbf74..22f28517d64 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -173,7 +173,8 @@ private struct ChatMessageBody: View { ToolResultCard( title: self.toolResultTitle, text: text, - isUser: self.isUser) + isUser: self.isUser, + toolName: self.message.toolName) } } else if self.isUser { ChatMarkdownRenderer( @@ -207,7 +208,8 @@ private struct ChatMessageBody: View { ToolResultCard( title: "\(display.emoji) \(display.title)", text: toolResult.text ?? "", - isUser: self.isUser) + isUser: self.isUser, + toolName: toolResult.name) } } } @@ -402,47 +404,54 @@ private struct ToolResultCard: View { let title: String let text: String let isUser: Bool + let toolName: String? @State private var expanded = false var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Text(self.title) - .font(.footnote.weight(.semibold)) - Spacer(minLength: 0) - } - - Text(self.displayText) - .font(.footnote.monospaced()) - .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText) - .lineLimit(self.expanded ? nil : Self.previewLineLimit) - - if self.shouldShowToggle { - Button(self.expanded ? "Show less" : "Show full output") { - self.expanded.toggle() + if !self.displayContent.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(self.title) + .font(.footnote.weight(.semibold)) + Spacer(minLength: 0) + } + + Text(self.displayText) + .font(.footnote.monospaced()) + .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.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) } - .buttonStyle(.plain) - .font(.caption) - .foregroundStyle(.secondary) } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OpenClawChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(OpenClawChatTheme.subtleCard) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) } private static let previewLineLimit = 8 + private var displayContent: String { + ToolResultTextFormatter.format(text: self.text, toolName: self.toolName) + } + private var lines: [Substring] { - self.text.components(separatedBy: .newlines).map { Substring($0) } + self.displayContent.components(separatedBy: .newlines).map { Substring($0) } } private var displayText: String { - guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.text } + guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.displayContent } return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n…" } @@ -458,12 +467,7 @@ struct ChatTypingIndicatorBubble: View { var body: some View { HStack(spacing: 10) { TypingDots() - if self.style == .standard { - Text("OpenClaw is thinking…") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } + Spacer(minLength: 0) } .padding(.vertical, self.style == .standard ? 12 : 10) .padding(.horizontal, self.style == .standard ? 12 : 14) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 68f9ae2f311..0675ffc2139 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -1,4 +1,7 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#endif @MainActor public struct OpenClawChatView: View { @@ -105,6 +108,9 @@ public struct OpenClawChatView: View { .padding(.top, Layout.messageListPaddingTop) .padding(.horizontal, Layout.messageListPaddingHorizontal) } + #if !os(macOS) + .scrollDismissesKeyboard(.interactively) + #endif // Keep the scroll pinned to the bottom for new messages. .scrollPosition(id: self.$scrollPosition, anchor: .bottom) .onChange(of: self.scrollPosition) { _, position in @@ -123,6 +129,10 @@ public struct OpenClawChatView: View { // Ensure the message list claims vertical space on the first layout pass. .frame(maxHeight: .infinity, alignment: .top) .layoutPriority(1) + .simultaneousGesture( + TapGesture().onEnded { + self.dismissKeyboardIfNeeded() + }) .onChange(of: self.viewModel.isLoading) { _, isLoading in guard !isLoading, !self.hasPerformedInitialScroll else { return } self.scrollPosition = self.scrollerBottomID @@ -406,6 +416,16 @@ public struct OpenClawChatView: View { } return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } + + private func dismissKeyboardIfNeeded() { + #if canImport(UIKit) + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil) + #endif + } } private struct ChatNoticeCard: View { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift new file mode 100644 index 00000000000..719e82cdf15 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift @@ -0,0 +1,157 @@ +import Foundation + +enum ToolResultTextFormatter { + static func format(text: String, toolName: String?) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + guard self.looksLikeJSON(trimmed), + let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) + else { + return trimmed + } + + let normalizedTool = toolName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.renderJSON(json, toolName: normalizedTool) + } + + private static func looksLikeJSON(_ value: String) -> Bool { + guard let first = value.first else { return false } + return first == "{" || first == "[" + } + + private static func renderJSON(_ json: Any, toolName: String?) -> String { + if let dict = json as? [String: Any] { + return self.renderDictionary(dict, toolName: toolName) + } + if let array = json as? [Any] { + if array.isEmpty { return "No items." } + return "\(array.count) item\(array.count == 1 ? "" : "s")." + } + return "" + } + + private static func renderDictionary(_ dict: [String: Any], toolName: String?) -> String { + let status = (dict["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let errorText = self.firstString(in: dict, keys: ["error", "reason"]) + let messageText = self.firstString(in: dict, keys: ["message", "result", "detail"]) + + if status?.lowercased() == "error" || errorText != nil { + if let errorText { + return "Error: \(self.sanitizeError(errorText))" + } + if let messageText { + return "Error: \(self.sanitizeError(messageText))" + } + return "Error" + } + + if toolName == "nodes", let summary = self.renderNodesSummary(dict) { + return summary + } + + if let message = messageText { + return message + } + + if let status, !status.isEmpty { + return "Status: \(status)" + } + + return "" + } + + private static func renderNodesSummary(_ dict: [String: Any]) -> String? { + if let nodes = dict["nodes"] as? [[String: Any]] { + if nodes.isEmpty { return "No nodes found." } + var lines: [String] = [] + lines.append("\(nodes.count) node\(nodes.count == 1 ? "" : "s") found.") + + for node in nodes.prefix(3) { + let label = self.firstString(in: node, keys: ["displayName", "name", "nodeId"]) ?? "Node" + var details: [String] = [] + + if let connected = node["connected"] as? Bool { + details.append(connected ? "connected" : "offline") + } + if let platform = self.firstString(in: node, keys: ["platform"]) { + details.append(platform) + } + if let version = self.firstString(in: node, keys: ["osVersion", "appVersion", "version"]) { + details.append(version) + } + if let pairing = self.pairingDetail(node) { + details.append(pairing) + } + + if details.isEmpty { + lines.append("• \(label)") + } else { + lines.append("• \(label) - \(details.joined(separator: ", "))") + } + } + + let extra = nodes.count - 3 + if extra > 0 { + lines.append("... +\(extra) more") + } + return lines.joined(separator: "\n") + } + + if let pending = dict["pending"] as? [Any], let paired = dict["paired"] as? [Any] { + return "Pairing requests: \(pending.count) pending, \(paired.count) paired." + } + + if let pending = dict["pending"] as? [Any] { + if pending.isEmpty { return "No pending pairing requests." } + return "\(pending.count) pending pairing request\(pending.count == 1 ? "" : "s")." + } + + return nil + } + + private static func pairingDetail(_ node: [String: Any]) -> String? { + if let paired = node["paired"] as? Bool, !paired { + return "pairing required" + } + + for key in ["status", "state", "deviceStatus"] { + if let raw = node[key] as? String, raw.lowercased().contains("pairing required") { + return "pairing required" + } + } + return nil + } + + private static func firstString(in dict: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + private static func sanitizeError(_ raw: String) -> String { + var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.contains("agent="), + cleaned.contains("action="), + let marker = cleaned.range(of: ": ") + { + cleaned = String(cleaned[marker.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + if let firstLine = cleaned.split(separator: "\n").first { + cleaned = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) + } + + if cleaned.count > 220 { + cleaned = String(cleaned.prefix(217)) + "..." + } + return cleaned + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift index 808f74af64f..00e0e4ea3ec 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift @@ -17,4 +17,39 @@ struct ChatMarkdownPreprocessorTests { #expect(result.images.count == 1) #expect(result.images.first?.image != nil) } + + @Test func stripsInboundUntrustedContextBlocks() { + let markdown = """ + Conversation info (untrusted metadata): + ```json + { + "message_id": "123", + "sender": "openclaw-ios" + } + ``` + + Sender (untrusted metadata): + ```json + { + "label": "Razor" + } + ``` + + Razor? + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "Razor?") + } + + @Test func stripsLeadingTimestampPrefix() { + let markdown = """ + [Fri 2026-02-20 18:45 GMT+1] How's it going? + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "How's it going?") + } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift new file mode 100644 index 00000000000..1688725c850 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift @@ -0,0 +1,54 @@ +import Testing +@testable import OpenClawChatUI + +@Suite("ToolResultTextFormatter") +struct ToolResultTextFormatterTests { + @Test func leavesPlainTextUntouched() { + let result = ToolResultTextFormatter.format(text: "All good", toolName: "nodes") + #expect(result == "All good") + } + + @Test func summarizesNodesListJSON() { + let json = """ + { + "ts": 1771610031380, + "nodes": [ + { + "displayName": "iPhone 16 Pro Max", + "connected": true, + "platform": "ios" + } + ] + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result.contains("1 node found.")) + #expect(result.contains("iPhone 16 Pro Max")) + #expect(result.contains("connected")) + } + + @Test func summarizesErrorJSONAndDropsAgentPrefix() { + let json = """ + { + "status": "error", + "tool": "nodes", + "error": "agent=main node=iPhone gateway=default action=invoke: pairing required" + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result == "Error: pairing required") + } + + @Test func suppressesUnknownStructuredPayload() { + let json = """ + { + "foo": "bar" + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result.isEmpty) + } +}