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
This commit is contained in:
Ajax Davis 2026-02-09 22:48:13 +10:00
parent 894f9842d1
commit 5ac3beab37
6 changed files with 174 additions and 38 deletions

View file

@ -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
)

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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
}
}
}
}

View file

@ -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)

View file

@ -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)
}