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:
parent
894f9842d1
commit
5ac3beab37
6 changed files with 174 additions and 38 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue