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,
|
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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue