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, name: tool.name,
description: tool.description ?? "Tool: \(tool.name)", description: tool.description ?? "Tool: \(tool.name)",
version: tool.package.npmVersion, 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, inputSchema: tool.inputSchema,
env: tool.package.env env: tool.package.env
) )

View file

@ -16,10 +16,11 @@ struct TPMJSToolResult: Decodable {
let inputSchema: JSONValue? let inputSchema: JSONValue?
let qualityScore: Double? let qualityScore: Double?
let executionHealth: String? let executionHealth: String?
let importUrl: String?
let package: TPMJSPackageInfo let package: TPMJSPackageInfo
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name, description, inputSchema, qualityScore, executionHealth case name, description, inputSchema, qualityScore, executionHealth, importUrl
case package = "package" case package = "package"
} }
} }
@ -28,13 +29,7 @@ struct TPMJSPackageInfo: Decodable {
let npmPackageName: String let npmPackageName: String
let npmVersion: String let npmVersion: String
let category: String? let category: String?
let env: [TPMJSEnvVarDef]? let env: [String]?
}
struct TPMJSEnvVarDef: Decodable, Sendable {
let name: String
let description: String?
let required: Bool?
} }
// MARK: - Executor API Types // MARK: - Executor API Types
@ -65,7 +60,7 @@ struct ToolMeta: Sendable {
let version: String let version: String
let importUrl: String let importUrl: String
let inputSchema: JSONValue? let inputSchema: JSONValue?
let env: [TPMJSEnvVarDef]? let env: [String]?
/// Convert to an OpenAI function tool definition /// Convert to an OpenAI function tool definition
func toChatTool() -> ChatTool { func toChatTool() -> ChatTool {

View file

@ -10,24 +10,12 @@ struct ChatInputBar: View {
Divider() Divider()
HStack(alignment: .bottom, spacing: 8) { HStack(alignment: .bottom, spacing: 8) {
TextEditor(text: $text) SendableTextEditor(text: $text, onSend: {
.font(.body) if canSend { onSend() }
.scrollContentBackground(.hidden) })
.padding(8) .font(.body)
.background(.quaternary.opacity(0.3)) .frame(minHeight: 40, maxHeight: 160)
.clipShape(RoundedRectangle(cornerRadius: 10)) .fixedSize(horizontal: false, vertical: true)
.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
}
Button(action: onSend) { Button(action: onSend) {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
@ -52,3 +40,80 @@ struct ChatInputBar: View {
!isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !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 SwiftData
import SwiftUI import SwiftUI
@ -7,6 +8,7 @@ struct ChatView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@State private var inputText: String = "" @State private var inputText: String = ""
@State private var showCopied: Bool = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -53,6 +55,14 @@ struct ChatView: View {
.controlSize(.small) .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) 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 // Live tool calls
ForEach(liveToolCalls) { tc in ForEach(isStreaming ? liveToolCalls : []) { tc in
ToolCallView(toolCall: tc) ToolCallView(toolCall: tc)
.id("live-tc-\(tc.id)") .id("live-tc-\(tc.id)")
} }
// Streaming content // Streaming content
if !streamingContent.isEmpty { if isStreaming && !streamingContent.isEmpty {
HStack(alignment: .top) { HStack(alignment: .top) {
assistantBubble(content: streamingContent, isStreaming: true) assistantBubble(content: streamingContent, isStreaming: true)
Spacer(minLength: 60) Spacer(minLength: 60)

View file

@ -4,15 +4,30 @@ struct ConversationRow: View {
let conversation: Conversation let conversation: Conversation
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { HStack {
Text(conversation.displayTitle) VStack(alignment: .leading, spacing: 4) {
.font(.system(.body, design: .default)) Text(conversation.displayTitle)
.lineLimit(1) .font(.system(.body, design: .default))
.foregroundStyle(.primary) .lineLimit(1)
.foregroundStyle(.primary)
Text(timeAgo(conversation.updatedAt)) Text(timeAgo(conversation.updatedAt))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .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) .padding(.vertical, 2)
} }