From 5ac3beab372d3b5b3b61a8d1906488cebb8d4a2c Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Mon, 9 Feb 2026 22:48:13 +1000 Subject: [PATCH] fix(omega-mac): fix enter-to-send, duplicate messages, search parsing + new features - Replace TextEditor+onKeyPress with NSTextView that properly intercepts Return (send) vs Shift+Return (newline) - Only show streaming content and live tool calls while isStreaming is true, preventing duplicate rendering after messages are persisted - Fix registry search JSON parsing: API returns env as [String] not objects - Add importUrl from search API response instead of constructing it - Add message count badge to sidebar conversation rows - Add "Copy JSON" toolbar button (Cmd+Shift+C) to export full conversation with all messages and tool call results as JSON --- .../Services/TPMJSRegistryService.swift | 2 +- .../OmegaMac/Services/TPMJSTypes.swift | 13 +-- .../OmegaMac/Views/Chat/ChatInputBar.swift | 101 ++++++++++++++---- .../OmegaMac/Views/Chat/ChatView.swift | 61 +++++++++++ .../OmegaMac/Views/Chat/MessageList.swift | 4 +- .../Views/Sidebar/ConversationRow.swift | 31 ++++-- 6 files changed, 174 insertions(+), 38 deletions(-) diff --git a/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift b/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift index 4a92312..f1dc613 100644 --- a/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift +++ b/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift @@ -49,7 +49,7 @@ actor TPMJSRegistryService { name: tool.name, description: tool.description ?? "Tool: \(tool.name)", version: tool.package.npmVersion, - importUrl: "https://esm.sh/\(tool.package.npmPackageName)@\(tool.package.npmVersion)", + importUrl: tool.importUrl ?? "https://esm.sh/\(tool.package.npmPackageName)@\(tool.package.npmVersion)", inputSchema: tool.inputSchema, env: tool.package.env ) diff --git a/apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift b/apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift index 0858581..f44f0c8 100644 --- a/apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift +++ b/apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift @@ -16,10 +16,11 @@ struct TPMJSToolResult: Decodable { let inputSchema: JSONValue? let qualityScore: Double? let executionHealth: String? + let importUrl: String? let package: TPMJSPackageInfo enum CodingKeys: String, CodingKey { - case name, description, inputSchema, qualityScore, executionHealth + case name, description, inputSchema, qualityScore, executionHealth, importUrl case package = "package" } } @@ -28,13 +29,7 @@ struct TPMJSPackageInfo: Decodable { let npmPackageName: String let npmVersion: String let category: String? - let env: [TPMJSEnvVarDef]? -} - -struct TPMJSEnvVarDef: Decodable, Sendable { - let name: String - let description: String? - let required: Bool? + let env: [String]? } // MARK: - Executor API Types @@ -65,7 +60,7 @@ struct ToolMeta: Sendable { let version: String let importUrl: String let inputSchema: JSONValue? - let env: [TPMJSEnvVarDef]? + let env: [String]? /// Convert to an OpenAI function tool definition func toChatTool() -> ChatTool { diff --git a/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift b/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift index 61d0f42..b5d1fd9 100644 --- a/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift +++ b/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift @@ -10,24 +10,12 @@ struct ChatInputBar: View { Divider() HStack(alignment: .bottom, spacing: 8) { - TextEditor(text: $text) - .font(.body) - .scrollContentBackground(.hidden) - .padding(8) - .background(.quaternary.opacity(0.3)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .frame(minHeight: 40, maxHeight: 160) - .fixedSize(horizontal: false, vertical: true) - .onKeyPress(.return, phases: .down) { keyPress in - if keyPress.modifiers.contains(.shift) { - return .ignored // Let shift+enter add newline - } - if canSend { - onSend() - return .handled - } - return .ignored - } + SendableTextEditor(text: $text, onSend: { + if canSend { onSend() } + }) + .font(.body) + .frame(minHeight: 40, maxHeight: 160) + .fixedSize(horizontal: false, vertical: true) Button(action: onSend) { Image(systemName: "arrow.up.circle.fill") @@ -52,3 +40,80 @@ struct ChatInputBar: View { !isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } + +/// NSTextView-backed editor that intercepts Return (send) vs Shift+Return (newline) +struct SendableTextEditor: NSViewRepresentable { + @Binding var text: String + let onSend: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + let textView = SendableNSTextView() + textView.delegate = context.coordinator + textView.sendAction = onSend + textView.isRichText = false + textView.allowsUndo = true + textView.font = .systemFont(ofSize: NSFont.systemFontSize) + textView.textColor = .labelColor + textView.drawsBackground = false + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.textContainer?.widthTracksTextView = true + textView.autoresizingMask = [.width] + + scrollView.documentView = textView + scrollView.hasVerticalScroller = false + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.contentView.drawsBackground = false + + // Style the scroll view as a rounded input field + scrollView.wantsLayer = true + scrollView.layer?.cornerRadius = 10 + scrollView.layer?.backgroundColor = NSColor.quaternaryLabelColor.withAlphaComponent(0.3).cgColor + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + if textView.string != text { + textView.string = text + } + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: SendableTextEditor + + init(_ parent: SendableTextEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + } + } +} + +/// Custom NSTextView that sends on Return and inserts newline on Shift+Return +class SendableNSTextView: NSTextView { + var sendAction: (() -> Void)? + + override func keyDown(with event: NSEvent) { + if event.keyCode == 36 { // Return key + if event.modifierFlags.contains(.shift) { + super.keyDown(with: event) // Insert newline + } else { + sendAction?() + } + return + } + super.keyDown(with: event) + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift b/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift index 6a3707a..02f0067 100644 --- a/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift +++ b/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftData import SwiftUI @@ -7,6 +8,7 @@ struct ChatView: View { @Environment(\.modelContext) private var modelContext @State private var inputText: String = "" + @State private var showCopied: Bool = false var body: some View { VStack(spacing: 0) { @@ -53,6 +55,14 @@ struct ChatView: View { .controlSize(.small) } } + ToolbarItem(placement: .automatic) { + Button(action: copyConversationAsJSON) { + Label(showCopied ? "Copied!" : "Copy JSON", + systemImage: showCopied ? "checkmark" : "doc.on.doc") + } + .help("Copy conversation as JSON (Cmd+Shift+C)") + .keyboardShortcut("c", modifiers: [.command, .shift]) + } } } @@ -65,4 +75,55 @@ struct ChatView: View { await orchestrator.sendMessage(text, conversation: conversation, modelContext: modelContext) } } + + private func copyConversationAsJSON() { + let messages = conversation.sortedMessages.map { msg -> [String: Any] in + var dict: [String: Any] = [ + "role": msg.role.rawValue.lowercased(), + "content": msg.content, + "createdAt": ISO8601DateFormatter().string(from: msg.createdAt), + ] + if let input = msg.inputTokens { dict["inputTokens"] = input } + if let output = msg.outputTokens { dict["outputTokens"] = output } + + let toolCalls = msg.toolCalls + if !toolCalls.isEmpty { + dict["toolCalls"] = toolCalls.map { tc -> [String: Any] in + var tcDict: [String: Any] = [ + "toolCallId": tc.toolCallId, + "toolName": tc.toolName, + ] + if let args = tc.args, + let data = try? JSONEncoder().encode(args), + let json = try? JSONSerialization.jsonObject(with: data) { + tcDict["args"] = json + } + if let output = tc.output, + let data = try? JSONEncoder().encode(output), + let json = try? JSONSerialization.jsonObject(with: data) { + tcDict["output"] = json + } + return tcDict + } + } + return dict + } + + let payload: [String: Any] = [ + "conversationId": conversation.id.uuidString, + "title": conversation.displayTitle, + "createdAt": ISO8601DateFormatter().string(from: conversation.createdAt), + "messages": messages, + ] + + if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]), + let json = String(data: data, encoding: .utf8) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(json, forType: .string) + showCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + showCopied = false + } + } + } } diff --git a/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift b/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift index d6f9b42..a87aa2d 100644 --- a/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift +++ b/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift @@ -16,13 +16,13 @@ struct MessageList: View { } // Live tool calls - ForEach(liveToolCalls) { tc in + ForEach(isStreaming ? liveToolCalls : []) { tc in ToolCallView(toolCall: tc) .id("live-tc-\(tc.id)") } // Streaming content - if !streamingContent.isEmpty { + if isStreaming && !streamingContent.isEmpty { HStack(alignment: .top) { assistantBubble(content: streamingContent, isStreaming: true) Spacer(minLength: 60) diff --git a/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift b/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift index 79fd307..d7ebc49 100644 --- a/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift +++ b/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift @@ -4,15 +4,30 @@ struct ConversationRow: View { let conversation: Conversation var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(conversation.displayTitle) - .font(.system(.body, design: .default)) - .lineLimit(1) - .foregroundStyle(.primary) + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(conversation.displayTitle) + .font(.system(.body, design: .default)) + .lineLimit(1) + .foregroundStyle(.primary) - Text(timeAgo(conversation.updatedAt)) - .font(.caption) - .foregroundStyle(.secondary) + Text(timeAgo(conversation.updatedAt)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + let count = conversation.messages.count + if count > 0 { + Text("\(count)") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary) + .clipShape(Capsule()) + } } .padding(.vertical, 2) }