Fix Enter key semantics and add expandable model descriptions
- Replace TextEditor with NativeTextEditor (NSViewRepresentable) so plain Enter sends the message and Shift/Cmd+Enter inserts a newline. The old TextEditor passed bare Return directly to NSTextView before SwiftUI's onKeyPress could intercept it, accidentally making Cmd+Enter send instead. - Add More…/Less toggle in ModelInfoView for descriptions longer than 250 characters, with smooth expand/collapse animation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,7 @@ struct InputBar: View {
|
||||
|
||||
@State private var showCommandDropdown = false
|
||||
@State private var selectedSuggestionIndex: Int = 0
|
||||
@FocusState private var isInputFocused: Bool
|
||||
@State private var isInputFocused: Bool = false
|
||||
|
||||
private static let minInputHeight: CGFloat = 56
|
||||
private static let maxInputHeight: CGFloat = 320
|
||||
@@ -95,55 +95,50 @@ struct InputBar: View {
|
||||
}
|
||||
|
||||
// Editor — fills the fixed-height box, bottom area reserved for globe
|
||||
TextEditor(text: $text)
|
||||
.font(.system(size: settings.inputTextSize))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 30)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.focused($isInputFocused)
|
||||
.onChange(of: text) {
|
||||
showCommandDropdown = text.hasPrefix("/")
|
||||
selectedSuggestionIndex = 0
|
||||
}
|
||||
#if os(macOS)
|
||||
.onKeyPress(.upArrow) {
|
||||
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
||||
selectedSuggestionIndex -= 1
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
.onKeyPress(.downArrow) {
|
||||
if showCommandDropdown {
|
||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||
if selectedSuggestionIndex < count - 1 {
|
||||
selectedSuggestionIndex += 1
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
.onKeyPress(.escape) {
|
||||
if showCommandDropdown { showCommandDropdown = false; return .handled }
|
||||
if isGenerating { onCancel(); return .handled }
|
||||
return .ignored
|
||||
}
|
||||
.onKeyPress(.return, phases: .down) { press in
|
||||
if press.modifiers.contains(.shift) { return .ignored }
|
||||
NativeTextEditor(
|
||||
text: $text,
|
||||
font: .systemFont(ofSize: settings.inputTextSize),
|
||||
textColor: NSColor(Color.oaiPrimary),
|
||||
isFocused: isInputFocused,
|
||||
onReturn: {
|
||||
if showCommandDropdown {
|
||||
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
|
||||
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
|
||||
selectCommand(suggestions[selectedSuggestionIndex].command)
|
||||
return .handled
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !text.isEmpty { onSend(); return .handled }
|
||||
return .handled
|
||||
}
|
||||
#endif
|
||||
if !text.isEmpty { onSend(); return true }
|
||||
return true
|
||||
},
|
||||
onEscape: {
|
||||
if showCommandDropdown { showCommandDropdown = false; return true }
|
||||
if isGenerating { onCancel(); return true }
|
||||
return false
|
||||
},
|
||||
onUpArrow: {
|
||||
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
||||
selectedSuggestionIndex -= 1; return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
onDownArrow: {
|
||||
if showCommandDropdown {
|
||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||
if selectedSuggestionIndex < count - 1 {
|
||||
selectedSuggestionIndex += 1; return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
onFocusChange: { focused in isInputFocused = focused }
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onChange(of: text) {
|
||||
showCommandDropdown = text.hasPrefix("/")
|
||||
selectedSuggestionIndex = 0
|
||||
}
|
||||
.padding(.bottom, 30)
|
||||
|
||||
// Online / offline toggle — bottom-left of the text box
|
||||
VStack {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// NativeTextEditor.swift
|
||||
// oAI
|
||||
//
|
||||
// NSViewRepresentable text editor with correct Enter-key semantics:
|
||||
// plain Enter → send, Shift+Enter or Cmd+Enter → newline.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct NativeTextEditor: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var font: NSFont
|
||||
var textColor: NSColor
|
||||
var isFocused: Bool
|
||||
|
||||
/// Plain Enter (no modifiers). Return true if the event was consumed.
|
||||
var onReturn: () -> Bool
|
||||
/// Escape key. Return true if consumed.
|
||||
var onEscape: () -> Bool
|
||||
/// Up arrow. Return true if consumed.
|
||||
var onUpArrow: () -> Bool
|
||||
/// Down arrow. Return true if consumed.
|
||||
var onDownArrow: () -> Bool
|
||||
/// Called when the view gains or loses first-responder status.
|
||||
var onFocusChange: (Bool) -> Void
|
||||
|
||||
// MARK: - NSViewRepresentable
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.hasVerticalScroller = false
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.borderType = .noBorder
|
||||
|
||||
let tv = context.coordinator.textView
|
||||
tv.delegate = context.coordinator
|
||||
tv.isEditable = true
|
||||
tv.isRichText = false
|
||||
tv.drawsBackground = false
|
||||
tv.backgroundColor = .clear
|
||||
tv.isAutomaticQuoteSubstitutionEnabled = false
|
||||
tv.isAutomaticDashSubstitutionEnabled = false
|
||||
tv.isAutomaticSpellingCorrectionEnabled = true
|
||||
tv.isContinuousSpellCheckingEnabled = true
|
||||
tv.allowsUndo = true
|
||||
tv.isVerticallyResizable = true
|
||||
tv.isHorizontallyResizable = false
|
||||
tv.autoresizingMask = [.width]
|
||||
tv.textContainer?.widthTracksTextView = true
|
||||
tv.textContainerInset = NSSize(width: 8, height: 6)
|
||||
|
||||
scrollView.documentView = tv
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
let tv = context.coordinator.textView
|
||||
let coord = context.coordinator
|
||||
|
||||
// Update text only when it differs (avoids caret-jumping on every keystroke)
|
||||
if tv.string != text {
|
||||
let sel = tv.selectedRanges
|
||||
tv.string = text
|
||||
let len = (tv.string as NSString).length
|
||||
tv.selectedRanges = sel.map { v in
|
||||
let r = v.rangeValue
|
||||
let loc = min(r.location, len)
|
||||
let length = min(r.length, max(0, len - loc))
|
||||
return NSValue(range: NSRange(location: loc, length: length))
|
||||
}
|
||||
}
|
||||
|
||||
if tv.font != font { tv.font = font }
|
||||
if tv.textColor != textColor { tv.textColor = textColor }
|
||||
|
||||
// Keep coordinator callbacks current with each SwiftUI render
|
||||
coord.textBinding = $text
|
||||
coord.onReturn = onReturn
|
||||
coord.onEscape = onEscape
|
||||
coord.onUpArrow = onUpArrow
|
||||
coord.onDownArrow = onDownArrow
|
||||
coord.onFocusChange = onFocusChange
|
||||
|
||||
if isFocused {
|
||||
DispatchQueue.main.async {
|
||||
guard let window = tv.window, window.firstResponder !== tv else { return }
|
||||
window.makeFirstResponder(tv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
let textView = KeyableNSTextView()
|
||||
|
||||
// Updated on every SwiftUI render via updateNSView
|
||||
var textBinding: Binding<String>?
|
||||
var onReturn: () -> Bool = { false }
|
||||
var onEscape: () -> Bool = { false }
|
||||
var onUpArrow: () -> Bool = { false }
|
||||
var onDownArrow: () -> Bool = { false }
|
||||
var onFocusChange: (Bool) -> Void = { _ in }
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
textView.coordinator = self
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let tv = notification.object as? NSTextView else { return }
|
||||
textBinding?.wrappedValue = tv.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - KeyableNSTextView
|
||||
|
||||
/// NSTextView that routes Return / Escape / arrow keys to the SwiftUI
|
||||
/// coordinator before the AppKit default handling runs.
|
||||
final class KeyableNSTextView: NSTextView {
|
||||
weak var coordinator: NativeTextEditor.Coordinator?
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
guard let coord = coordinator else { super.keyDown(with: event); return }
|
||||
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
let shift = flags.contains(.shift)
|
||||
let cmd = flags.contains(.command)
|
||||
|
||||
switch event.keyCode {
|
||||
case 36: // Return
|
||||
if shift || cmd {
|
||||
// Shift+Enter or Cmd+Enter → literal newline
|
||||
insertNewlineIgnoringFieldEditor(nil)
|
||||
} else {
|
||||
// Plain Enter → let SwiftUI decide (send or select dropdown item)
|
||||
if !coord.onReturn() {
|
||||
insertNewlineIgnoringFieldEditor(nil)
|
||||
}
|
||||
}
|
||||
case 53: // Escape
|
||||
if !coord.onEscape() { super.keyDown(with: event) }
|
||||
case 126: // Up arrow
|
||||
if !coord.onUpArrow() { super.keyDown(with: event) }
|
||||
case 125: // Down arrow
|
||||
if !coord.onDownArrow() { super.keyDown(with: event) }
|
||||
default:
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
let ok = super.becomeFirstResponder()
|
||||
if ok { coordinator?.onFocusChange(true) }
|
||||
return ok
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
let ok = super.resignFirstResponder()
|
||||
if ok { coordinator?.onFocusChange(false) }
|
||||
return ok
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user