diff --git a/desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift b/desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift index faa02b10c97..1f2a5a183fd 100644 --- a/desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift +++ b/desktop/Desktop/Sources/FloatingControlBar/AskAIInputView.swift @@ -6,6 +6,7 @@ struct AskAIInputView: View { @Binding var userInput: String @State private var localInput: String = "" @State private var textHeight: CGFloat = 40 + @State private var hasMarkedText = false var canClearVisibleConversation: Bool = false var onSend: ((String) -> Void)? @@ -15,6 +16,8 @@ struct AskAIInputView: View { private let minHeight: CGFloat = 40 private let maxHeight: CGFloat = 200 + private var trimmedInput: String { localInput.trimmingCharacters(in: .whitespacesAndNewlines) } + private var canSend: Bool { !hasMarkedText && !trimmedInput.isEmpty } var body: some View { VStack(spacing: 0) { @@ -40,7 +43,7 @@ struct AskAIInputView: View { HStack(spacing: 6) { ZStack(alignment: .topLeading) { - if localInput.isEmpty { + if localInput.isEmpty && !hasMarkedText { Text("Ask a question...") .scaledFont(size: 13) .foregroundColor(.secondary) @@ -52,11 +55,11 @@ struct AskAIInputView: View { text: $localInput, lineFragmentPadding: 8, onSubmit: { - let trimmed = localInput.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - onSend?(trimmed) + guard canSend else { return } + onSend?(trimmedInput) }, focusOnAppear: true, + onMarkedTextChange: { hasMarkedText = $0 }, minHeight: minHeight, maxHeight: maxHeight, onHeightChange: { newHeight in @@ -77,18 +80,16 @@ struct AskAIInputView: View { .frame(height: textHeight) Button(action: { - let trimmed = localInput.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - onSend?(trimmed) + guard canSend else { return } + onSend?(trimmedInput) }) { Image(systemName: "arrow.up.circle.fill") .scaledFont(size: 24) .foregroundColor( - localInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? .secondary : .white + canSend ? .white : .secondary ) } - .disabled(localInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!canSend) .buttonStyle(.plain) } .padding(.horizontal, 16) diff --git a/desktop/Desktop/Sources/MainWindow/Components/ChatInputView.swift b/desktop/Desktop/Sources/MainWindow/Components/ChatInputView.swift index 672c9f22a2b..2fdd8106fb8 100644 --- a/desktop/Desktop/Sources/MainWindow/Components/ChatInputView.swift +++ b/desktop/Desktop/Sources/MainWindow/Components/ChatInputView.swift @@ -37,6 +37,7 @@ struct ChatInputView: View { @AppStorage("askModeEnabled") private var askModeEnabled = false @Environment(\.fontScale) private var fontScale @State private var isDropTargeted = false + @State private var hasMarkedText = false private var hasText: Bool { !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -89,7 +90,7 @@ struct ChatInputView: View { .accessibilityHidden(true) .overlay(alignment: .topLeading) { // Placeholder text — padding matches textContainerInset exactly - if inputText.isEmpty { + if inputText.isEmpty && !hasMarkedText { Text(placeholder) .scaledFont(size: 14) .foregroundColor(OmiColors.textTertiary) @@ -104,7 +105,8 @@ struct ChatInputView: View { fontSize: round(14 * fontScale), textColor: NSColor(OmiColors.textPrimary), textContainerInset: NSSize(width: inputPaddingH, height: inputPaddingV), - onSubmit: handleSubmit + onSubmit: handleSubmit, + onMarkedTextChange: { hasMarkedText = $0 } ) } .frame(maxHeight: 200) @@ -177,6 +179,7 @@ struct ChatInputView: View { /// Send is enabled when there's text OR (when supported) any attachment ready /// to ship — Flutter allows sending attachments without text. private var canSend: Bool { + guard !hasMarkedText else { return false } if hasText { return true } if attachmentsEnabled && !currentAttachments.isEmpty { return true } return false diff --git a/desktop/Desktop/Sources/Theme/OmiTextEditor.swift b/desktop/Desktop/Sources/Theme/OmiTextEditor.swift index ff026a00e31..b9198d16741 100644 --- a/desktop/Desktop/Sources/Theme/OmiTextEditor.swift +++ b/desktop/Desktop/Sources/Theme/OmiTextEditor.swift @@ -17,6 +17,30 @@ private class AutoFocusScrollView: NSScrollView { } } +/// NSTextView subclass that reports IME marked-text composition state. +private class OmiNSTextView: NSTextView { + var onMarkedTextStatusChange: ((Bool) -> Void)? + + override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) + publishMarkedTextStatus() + } + + override func unmarkText() { + super.unmarkText() + publishMarkedTextStatus() + } + + override func insertText(_ string: Any, replacementRange: NSRange) { + super.insertText(string, replacementRange: replacementRange) + publishMarkedTextStatus() + } + + private func publishMarkedTextStatus() { + onMarkedTextStatusChange?(hasMarkedText()) + } +} + /// Unified NSTextView wrapper used by both the main chat input and the floating control bar. struct OmiTextEditor: NSViewRepresentable { @Binding var text: String @@ -30,6 +54,7 @@ struct OmiTextEditor: NSViewRepresentable { // Behavior var onSubmit: (() -> Void)? = nil var focusOnAppear: Bool = true + var onMarkedTextChange: ((Bool) -> Void)? = nil // Optional height tracking (for floating bar's window resize flow) var minHeight: CGFloat? = nil @@ -37,7 +62,7 @@ struct OmiTextEditor: NSViewRepresentable { var onHeightChange: ((CGFloat) -> Void)? = nil func makeNSView(context: Context) -> NSScrollView { - let textView = NSTextView() + let textView = OmiNSTextView() textView.font = .systemFont(ofSize: fontSize) textView.textColor = textColor textView.backgroundColor = .clear @@ -48,6 +73,9 @@ struct OmiTextEditor: NSViewRepresentable { textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.delegate = context.coordinator + textView.onMarkedTextStatusChange = { [weak coordinator = context.coordinator] hasMarkedText in + coordinator?.updateMarkedTextState(hasMarkedText) + } textView.textContainer?.lineFragmentPadding = lineFragmentPadding textView.textContainerInset = textContainerInset @@ -87,7 +115,7 @@ struct OmiTextEditor: NSViewRepresentable { // correct task's draftText when SwiftUI reuses this NSView across tasks. context.coordinator.updateTextBinding($text) - if textView.string != text { + if textView.string != text, !textView.hasMarkedText() { context.coordinator.isUpdating = true textView.string = text context.coordinator.isUpdating = false @@ -124,11 +152,14 @@ struct OmiTextEditor: NSViewRepresentable { // Keep closures fresh so they capture the latest SwiftUI state context.coordinator.onSubmit = onSubmit + context.coordinator.onMarkedTextChange = onMarkedTextChange let newFont = NSFont.systemFont(ofSize: fontSize) if textView.font != newFont { textView.font = newFont } + + context.coordinator.updateMarkedTextState(textView.hasMarkedText()) } /// Return a concrete size to SwiftUI's layout engine so it doesn't have to @@ -156,6 +187,7 @@ struct OmiTextEditor: NSViewRepresentable { Coordinator( text: $text, onSubmit: onSubmit, + onMarkedTextChange: onMarkedTextChange, minHeight: minHeight, maxHeight: maxHeight, onHeightChange: onHeightChange @@ -165,6 +197,7 @@ struct OmiTextEditor: NSViewRepresentable { class Coordinator: NSObject, NSTextViewDelegate { @Binding var text: String var onSubmit: (() -> Void)? + var onMarkedTextChange: ((Bool) -> Void)? var isUpdating = false func updateTextBinding(_ binding: Binding) { @@ -176,16 +209,19 @@ struct OmiTextEditor: NSViewRepresentable { private let maxHeight: CGFloat? private let onHeightChange: ((CGFloat) -> Void)? private var lastHeight: CGFloat = 0 + private var lastMarkedTextState = false init( text: Binding, onSubmit: (() -> Void)?, + onMarkedTextChange: ((Bool) -> Void)?, minHeight: CGFloat?, maxHeight: CGFloat?, onHeightChange: ((CGFloat) -> Void)? ) { self._text = text self.onSubmit = onSubmit + self.onMarkedTextChange = onMarkedTextChange self.minHeight = minHeight self.maxHeight = maxHeight self.onHeightChange = onHeightChange @@ -198,9 +234,15 @@ struct OmiTextEditor: NSViewRepresentable { if onHeightChange != nil, let scrollView = textView.enclosingScrollView { updateHeight(for: textView, scrollView: scrollView) } + + updateMarkedTextState(textView.hasMarkedText()) } func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if textView.hasMarkedText() { + return false + } + if commandSelector == #selector(NSResponder.insertNewline(_:)) { let flags = NSApp.currentEvent?.modifierFlags ?? [] if !flags.contains(.shift) { @@ -228,5 +270,14 @@ struct OmiTextEditor: NSViewRepresentable { onHeightChange(constrainedHeight) } } + + func updateMarkedTextState(_ hasMarkedText: Bool) { + guard hasMarkedText != lastMarkedTextState else { return } + lastMarkedTextState = hasMarkedText + DispatchQueue.main.async { [weak self] in + guard self?.lastMarkedTextState == hasMarkedText else { return } + self?.onMarkedTextChange?(hasMarkedText) + } + } } }