From 91f67f891be23f192a59daccae1a16310b80f80d Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 7 Aug 2026 13:15:32 +0200 Subject: [PATCH] Fix Escape beeping instead of dismissing Model Info after clicking description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .onExitCommand alone wasn't enough: clicking into the multi-line .textSelection(.enabled) description handed it real AppKit first-responder status, and its own cancelOperation: handling for Escape consumed the key event before it ever reached the modal's exit-command handler. Replaced text-selection on the description with an explicit Copy button (same pattern as the chat message copy button in MessageRow.swift) so it can no longer grab keyboard focus at all. infoRow's single-line values keep .textSelection(.enabled) — only the multi-line description reproduced the bug. --- oAI/Views/Screens/ModelInfoView.swift | 53 +++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/oAI/Views/Screens/ModelInfoView.swift b/oAI/Views/Screens/ModelInfoView.swift index d7df0f5..b5c843a 100644 --- a/oAI/Views/Screens/ModelInfoView.swift +++ b/oAI/Views/Screens/ModelInfoView.swift @@ -28,6 +28,7 @@ struct ModelInfoView: View { @Environment(\.dismiss) var dismiss @Bindable private var settings = SettingsService.shared + @State private var showDescriptionCopied = false var body: some View { VStack(spacing: 0) { @@ -80,13 +81,34 @@ struct ModelInfoView: View { // lay out). The modal itself already scrolls, so a long description just // means more scrolling, which sidesteps the whole bug class. VStack(alignment: .leading, spacing: 6) { - Text("Description") - .font(.subheadline.weight(.medium)) - .foregroundColor(.secondary) + HStack(spacing: 6) { + Text("Description") + .font(.subheadline.weight(.medium)) + .foregroundColor(.secondary) + Spacer() + // A Copy button instead of .textSelection(.enabled): clicking into + // a multi-line selectable Text hands it real AppKit first-responder + // status, and Escape then gets consumed by that text view's own + // cancelOperation: handling before it ever reaches this modal's + // onExitCommand — the beep-instead-of-dismiss bug Rune reported. + // infoRow's single-line values keep .textSelection(.enabled); only + // this multi-line block reproduced the bug. + Button(action: copyDescription) { + HStack(spacing: 3) { + Image(systemName: showDescriptionCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 11)) + if showDescriptionCopied { + Text("Copied!") + .font(.system(size: 11)) + } + } + .foregroundColor(showDescriptionCopied ? .green : .secondary) + } + .buttonStyle(.plain) + } Text(desc) .font(.body) .foregroundColor(.primary) - .textSelection(.enabled) } .padding(.leading, 4) } @@ -212,6 +234,29 @@ struct ModelInfoView: View { .padding(.vertical, 12) } .frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750) + // Nearly every value in this modal has .textSelection(.enabled) (infoRow's value text, + // the description). Once one of those has text-selection focus, Escape can get + // intercepted by AppKit's text-selection machinery instead of reaching the close + // button's .keyboardShortcut(.escape) — no action is bound there, so it just beeps + // instead of dismissing. onExitCommand is macOS's dedicated hook for the "Escape/Cancel" + // user command and fires regardless of which child currently holds focus, so it's a more + // reliable place to handle this than a single button's keyboardShortcut alone. + .onExitCommand { dismiss() } + } + + private func copyDescription() { + guard let desc = model.description else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(desc, forType: .string) + withAnimation { + showDescriptionCopied = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + showDescriptionCopied = false + } + } } // MARK: - Layout Helpers