From e993423f927cc0c1b40cf02b3f57378dedccb011 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:46:24 -0700 Subject: [PATCH 1/5] test: cover textbox input source regressions --- cmuxTests/TextBoxMentionCompletionTests.swift | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/cmuxTests/TextBoxMentionCompletionTests.swift b/cmuxTests/TextBoxMentionCompletionTests.swift index 60def63ff28..854049a93b2 100644 --- a/cmuxTests/TextBoxMentionCompletionTests.swift +++ b/cmuxTests/TextBoxMentionCompletionTests.swift @@ -99,6 +99,93 @@ struct TextBoxMentionCompletionTests { #expect(forwardedControls == ["g"]) } + @Test + func testTextBoxExternalTextSyncDoesNotOverwriteActiveIMEMarkedText() { + #expect(!shouldSynchronizeExternalTextToTextBox( + inlineAttachmentCount: 0, + plainText: "に", + externalText: "", + hasMarkedText: true + )) + #expect(shouldSynchronizeExternalTextToTextBox( + inlineAttachmentCount: 0, + plainText: "に", + externalText: "", + hasMarkedText: false + )) + #expect(!shouldSynchronizeExternalTextToTextBox( + inlineAttachmentCount: 1, + plainText: "に", + externalText: "", + hasMarkedText: false + )) + } + + @Test + func testTextBoxPlaceholderHidesDuringActiveIMEMarkedText() { + #expect(!shouldShowTextBoxPlaceholder( + text: "", + attachmentCount: 0, + hasMarkedText: true + )) + #expect(shouldShowTextBoxPlaceholder( + text: "", + attachmentCount: 0, + hasMarkedText: false + )) + #expect(!shouldShowTextBoxPlaceholder( + text: "に", + attachmentCount: 0, + hasMarkedText: false + )) + } + + @Test + func testTextBoxStandardEditShortcutUsesTranslatedCommandCharacter() { + guard let event = makeKeyDownEvent( + key: "c", + modifiers: [.command], + keyCode: UInt16(kVK_ANSI_B), + windowNumber: 0 + ) else { + #expect(Bool(false), "Failed to construct translated Command-C event") + return + } + + #expect(textBoxCommandShortcutKey( + for: event, + translateKey: { keyCode, flags in + #expect(keyCode == UInt16(kVK_ANSI_B)) + #expect(flags.contains(.command)) + return "c" + }, + normalizedCharacters: { _ in "b" } + ) == "c") + } + + @Test + func testTextBoxUndoShortcutUsesTranslatedCommandCharacter() { + guard let event = makeKeyDownEvent( + key: "z", + modifiers: [.command], + keyCode: UInt16(kVK_ANSI_Y), + windowNumber: 0 + ) else { + #expect(Bool(false), "Failed to construct translated Command-Z event") + return + } + + #expect(textBoxCommandShortcutKey( + for: event, + translateKey: { keyCode, flags in + #expect(keyCode == UInt16(kVK_ANSI_Y)) + #expect(flags.contains(.command)) + return "z" + }, + normalizedCharacters: { _ in "y" } + ) == "z") + } + @Test func testTextBoxMentionCompletionDetectsFileAndSkillTokens() { let filePrompt = "open @Sources/TextBox" From 23ed5b1bb664a797b365db8892d9fb2e2677002f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:46:24 -0700 Subject: [PATCH 2/5] fix: preserve textbox input method composition --- Sources/TextBoxInput.swift | 184 ++++++++++++++++-- cmuxTests/TextBoxMentionCompletionTests.swift | 24 +++ 2 files changed, 196 insertions(+), 12 deletions(-) diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index f1b02e38afe..afccbf2270a 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -1209,6 +1209,54 @@ func shouldHandleTextBoxPlainArrowLocally( } } +func shouldSynchronizeExternalTextToTextBox( + inlineAttachmentCount: Int, + plainText: String, + externalText: String, + hasMarkedText: Bool +) -> Bool { + inlineAttachmentCount == 0 && !hasMarkedText && plainText != externalText +} + +func shouldShowTextBoxPlaceholder( + text: String, + attachmentCount: Int, + hasMarkedText: Bool +) -> Bool { + text.isEmpty && attachmentCount == 0 && !hasMarkedText +} + +func shouldEnableTextBoxSubmit( + text: String, + attachmentCount: Int, + hasPendingAttachmentUpload: Bool, + hasMarkedText: Bool +) -> Bool { + !hasPendingAttachmentUpload + && !hasMarkedText + && (!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || attachmentCount > 0) +} + +func shouldSubmitTextBox( + hasPendingAttachmentUpload: Bool, + hasMarkedText: Bool +) -> Bool { + !hasPendingAttachmentUpload && !hasMarkedText +} + +func textBoxCommandShortcutKey( + for event: NSEvent, + translateKey: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:), + normalizedCharacters: (NSEvent) -> String = KeyboardLayout.normalizedCharacters(for:) +) -> String { + if let translated = translateKey(event.keyCode, event.modifierFlags)?.lowercased(), + translated.count == 1, + translated.allSatisfy(\.isASCII) { + return translated + } + return normalizedCharacters(event).lowercased() +} + private enum TextBoxAgentDetection: CaseIterable { case claudeCode case codex @@ -2625,6 +2673,7 @@ struct TextBoxInputContainer: View { @State private var textViewHeight: CGFloat = 0 @State private var hasPendingAttachmentUpload = false + @State private var hasMarkedText = false @State private var textViewReference = TextBoxInputViewReference() @State private var contentRevision: UInt64 = 0 @@ -2661,8 +2710,12 @@ struct TextBoxInputContainer: View { let clampedHeight = max(minHeight, min(maxHeight, textViewHeight)) let foreground = Color(nsColor: terminalForegroundColor) let background = Color(nsColor: terminalBackgroundColor) - let canSend = !hasPendingAttachmentUpload - && (!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty) + let canSend = shouldEnableTextBoxSubmit( + text: text, + attachmentCount: attachments.count, + hasPendingAttachmentUpload: hasPendingAttachmentUpload, + hasMarkedText: hasMarkedText + ) HStack(alignment: .bottom, spacing: 6) { addFilesButton(foreground: foreground) @@ -2691,12 +2744,17 @@ struct TextBoxInputContainer: View { onInsertFileURLs: insertSelectedFileURLs(_:into:), onChooseFiles: chooseFiles, onContentChanged: markContentChanged, + onMarkedTextStateChanged: updateMarkedTextState(_:), onTextViewCreated: registerTextView(_:), onTextViewMovedToWindow: onTextViewMovedToWindow, onTextViewDismantled: onTextViewDismantled ) - if text.isEmpty && attachments.isEmpty { + if shouldShowTextBoxPlaceholder( + text: text, + attachmentCount: attachments.count, + hasMarkedText: hasMarkedText + ) { Text(String(localized: "textbox.placeholder", defaultValue: "Prompt or command")) .font(.system(size: textFont.pointSize)) .foregroundStyle(Color(nsColor: terminalForegroundColor).opacity(0.36)) @@ -2779,18 +2837,22 @@ struct TextBoxInputContainer: View { } private func submit() { - guard textViewReference.textView?.hasPendingAttachmentUploadPlaceholder() != true else { + let textView = textViewReference.textView + guard shouldSubmitTextBox( + hasPendingAttachmentUpload: textView?.hasPendingAttachmentUploadPlaceholder() ?? hasPendingAttachmentUpload, + hasMarkedText: textView?.hasMarkedText() ?? hasMarkedText + ) else { NSSound.beep() return } - let submittedParts = textViewReference.textView?.submissionParts() + let submittedParts = textView?.submissionParts() ?? [TextBoxSubmissionPart.text(text.trimmingCharacters(in: .newlines))] guard TextBoxSubmissionFormatter.hasSubmittableContent(submittedParts) else { NSSound.beep() return } - let submittedTextView = textViewReference.textView + let submittedTextView = textView let preservedContent = submittedTextView?.attributedContentForPreservation() submittedTextView?.prepareForSubmit() submittedTextView?.clearContent(cleanupAttachmentFiles: false) @@ -2846,6 +2908,11 @@ struct TextBoxInputContainer: View { _ = advanceContentRevision() } + private func updateMarkedTextState(_ nextValue: Bool) { + guard hasMarkedText != nextValue else { return } + hasMarkedText = nextValue + } + @discardableResult private func advanceContentRevision() -> UInt64 { contentRevision &+= 1 @@ -3120,10 +3187,63 @@ struct TextBoxInputView: NSViewRepresentable { let onInsertFileURLs: ([URL], TextBoxInputTextView) -> Bool let onChooseFiles: () -> Void let onContentChanged: () -> Void + let onMarkedTextStateChanged: (Bool) -> Void let onTextViewCreated: (TextBoxInputTextView) -> Void let onTextViewMovedToWindow: (TextBoxInputTextView) -> Void let onTextViewDismantled: (TextBoxInputTextView) -> Void + init( + text: Binding, + attachments: Binding<[TextBoxAttachment]>, + textViewHeight: Binding, + hasPendingAttachmentUpload: Binding, + font: NSFont, + backgroundColor: NSColor, + foregroundColor: NSColor, + terminalTitle: String, + completionRootDirectory: String?, + onSubmit: @escaping () -> Void, + onEscape: @escaping () -> Void, + onFocusTextBox: @escaping () -> Void, + onToggleFocus: @escaping () -> Void, + onForwardText: @escaping (String, Bool) -> Void, + onForwardKey: @escaping (TextBoxTerminalKey) -> Void, + onForwardControl: @escaping (String) -> Void, + onPaste: @escaping (NSPasteboard, TextBoxInputTextView) -> Bool, + onInsertFileURLs: @escaping ([URL], TextBoxInputTextView) -> Bool, + onChooseFiles: @escaping () -> Void, + onContentChanged: @escaping () -> Void, + onMarkedTextStateChanged: @escaping (Bool) -> Void = { _ in }, + onTextViewCreated: @escaping (TextBoxInputTextView) -> Void, + onTextViewMovedToWindow: @escaping (TextBoxInputTextView) -> Void, + onTextViewDismantled: @escaping (TextBoxInputTextView) -> Void + ) { + self._text = text + self._attachments = attachments + self._textViewHeight = textViewHeight + self._hasPendingAttachmentUpload = hasPendingAttachmentUpload + self.font = font + self.backgroundColor = backgroundColor + self.foregroundColor = foregroundColor + self.terminalTitle = terminalTitle + self.completionRootDirectory = completionRootDirectory + self.onSubmit = onSubmit + self.onEscape = onEscape + self.onFocusTextBox = onFocusTextBox + self.onToggleFocus = onToggleFocus + self.onForwardText = onForwardText + self.onForwardKey = onForwardKey + self.onForwardControl = onForwardControl + self.onPaste = onPaste + self.onInsertFileURLs = onInsertFileURLs + self.onChooseFiles = onChooseFiles + self.onContentChanged = onContentChanged + self.onMarkedTextStateChanged = onMarkedTextStateChanged + self.onTextViewCreated = onTextViewCreated + self.onTextViewMovedToWindow = onTextViewMovedToWindow + self.onTextViewDismantled = onTextViewDismantled + } + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } @@ -3168,6 +3288,7 @@ struct TextBoxInputView: NSViewRepresentable { updateTextView(textView, context: context) onTextViewCreated(textView) context.coordinator.queuePendingAttachmentUploadStateSync(from: textView) + context.coordinator.queuePendingMarkedTextStateSync(from: textView) return scrollView } @@ -3192,7 +3313,12 @@ struct TextBoxInputView: NSViewRepresentable { height: CGFloat.greatestFiniteMagnitude ) } - if textView.inlineAttachments().isEmpty && textView.plainText() != text { + if shouldSynchronizeExternalTextToTextBox( + inlineAttachmentCount: textView.inlineAttachments().count, + plainText: textView.plainText(), + externalText: text, + hasMarkedText: textView.hasMarkedText() + ) { textView.string = text } updateTextView(textView, context: context) @@ -3216,6 +3342,9 @@ struct TextBoxInputView: NSViewRepresentable { textView.onPaste = onPaste textView.onInsertFileURLs = onInsertFileURLs textView.onChooseFiles = onChooseFiles + textView.onMarkedTextStateChanged = { [weak coordinator] hasMarkedText in + coordinator?.noteMarkedTextStateChanged(hasMarkedText) + } textView.refreshInlineAttachmentCells(font: font, foregroundColor: foregroundColor) textView.recenterSingleLineTextContainer() textView.wantsLayer = true @@ -3230,6 +3359,7 @@ struct TextBoxInputView: NSViewRepresentable { final class Coordinator: NSObject, NSTextViewDelegate { var parent: TextBoxInputView private var pendingAttachmentUploadStateForNextLayout: Bool? + private var pendingMarkedTextStateForNextLayout: Bool? init(parent: TextBoxInputView) { self.parent = parent @@ -3240,12 +3370,17 @@ struct TextBoxInputView: NSViewRepresentable { pendingAttachmentUploadStateForNextLayout = textView.hasPendingAttachmentUploadPlaceholder() } + func queuePendingMarkedTextStateSync(from textView: TextBoxInputTextView) { + pendingMarkedTextStateForNextLayout = textView.hasMarkedText() + } + func textDidChange(_ notification: Notification) { guard let textView = notification.object as? TextBoxInputTextView else { return } textView.normalizeTextBaselineOffsets() parent.text = textView.plainText() parent.attachments = textView.inlineAttachments() parent.hasPendingAttachmentUpload = textView.hasPendingAttachmentUploadPlaceholder() + noteMarkedTextStateChanged(textView.hasMarkedText()) parent.onContentChanged() if parent.text.isEmpty, parent.attachments.isEmpty, @@ -3258,6 +3393,7 @@ struct TextBoxInputView: NSViewRepresentable { func textViewDidChangeSelection(_ notification: Notification) { guard let textView = notification.object as? TextBoxInputTextView else { return } + noteMarkedTextStateChanged(textView.hasMarkedText()) let color = textView.textColor ?? .labelColor textView.layer?.borderColor = color.withAlphaComponent( textView.window?.firstResponder === textView ? 0.45 : 0.24 @@ -3266,12 +3402,17 @@ struct TextBoxInputView: NSViewRepresentable { textView.refreshMentionCompletions() } + func noteMarkedTextStateChanged(_ hasMarkedText: Bool) { + parent.onMarkedTextStateChanged(hasMarkedText) + } + func recalculateHeight(_ textView: NSTextView) { guard let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else { return } if let textBoxView = textView as? TextBoxInputTextView { textBoxView.recenterSingleLineTextContainer() applyPendingAttachmentUploadStateSyncIfNeeded() + applyPendingMarkedTextStateSyncIfNeeded() } layoutManager.ensureLayout(for: textContainer) let lineFragmentCount = (textView as? TextBoxInputTextView)?.visualLineFragmentCount() @@ -3321,6 +3462,13 @@ struct TextBoxInputView: NSViewRepresentable { guard parent.hasPendingAttachmentUpload != hasPendingUpload else { return } parent.hasPendingAttachmentUpload = hasPendingUpload } + + /// Applies the one-shot marked-text state captured during representable construction. + private func applyPendingMarkedTextStateSyncIfNeeded() { + guard let hasMarkedText = pendingMarkedTextStateForNextLayout else { return } + pendingMarkedTextStateForNextLayout = nil + noteMarkedTextStateChanged(hasMarkedText) + } } } @@ -3346,6 +3494,7 @@ final class TextBoxInputTextView: NSTextView { var onChooseFiles: () -> Void = {} var onMoveToWindow: (TextBoxInputTextView) -> Void = { _ in } var onLayoutCompleted: (TextBoxInputTextView) -> Void = { _ in } + var onMarkedTextStateChanged: (Bool) -> Void = { _ in } private var isReportingLayoutCompletion = false private static let localControlKeys: Set = ["a", "e", "f", "b", "n", "p", "k", "h"] @@ -3465,6 +3614,17 @@ final class TextBoxInputTextView: NSTextView { queueAutomaticAttachmentFileCleanup(in: replacementRange) super.insertText(insertString, replacementRange: replacementRange) flushAutomaticAttachmentFileCleanup() + onMarkedTextStateChanged(hasMarkedText()) + } + + override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) + onMarkedTextStateChanged(hasMarkedText()) + } + + override func unmarkText() { + super.unmarkText() + onMarkedTextStateChanged(hasMarkedText()) } override func didChangeText() { @@ -4035,7 +4195,7 @@ final class TextBoxInputTextView: NSTextView { guard flags.contains(.command), !flags.contains(.option), !flags.contains(.control), - event.keyCode == UInt16(kVK_ANSI_Z) else { + textBoxCommandShortcutKey(for: event) == "z" else { return super.performKeyEquivalent(with: event) } @@ -4834,14 +4994,14 @@ final class TextBoxInputTextView: NSTextView { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) guard flags == .command else { return false } - switch Int(event.keyCode) { - case kVK_ANSI_C: + switch textBoxCommandShortcutKey(for: event) { + case "c": copy(nil) return true - case kVK_ANSI_X: + case "x": cut(nil) return true - case kVK_ANSI_V: + case "v": paste(nil) return true default: diff --git a/cmuxTests/TextBoxMentionCompletionTests.swift b/cmuxTests/TextBoxMentionCompletionTests.swift index 854049a93b2..f8410f75afe 100644 --- a/cmuxTests/TextBoxMentionCompletionTests.swift +++ b/cmuxTests/TextBoxMentionCompletionTests.swift @@ -140,6 +140,30 @@ struct TextBoxMentionCompletionTests { )) } + @Test + func testTextBoxSubmitIsDisabledDuringActiveIMEMarkedText() { + #expect(!shouldEnableTextBoxSubmit( + text: "に", + attachmentCount: 0, + hasPendingAttachmentUpload: false, + hasMarkedText: true + )) + #expect(!shouldSubmitTextBox( + hasPendingAttachmentUpload: false, + hasMarkedText: true + )) + #expect(shouldEnableTextBoxSubmit( + text: "send", + attachmentCount: 0, + hasPendingAttachmentUpload: false, + hasMarkedText: false + )) + #expect(shouldSubmitTextBox( + hasPendingAttachmentUpload: false, + hasMarkedText: false + )) + } + @Test func testTextBoxStandardEditShortcutUsesTranslatedCommandCharacter() { guard let event = makeKeyDownEvent( From 95ec20f9232a7da8f61698729d62f909417c7660 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:08:02 -0700 Subject: [PATCH 3/5] fix: preserve committed IME text on unmark --- Sources/TextBoxInput.swift | 33 ++++++++---- cmuxTests/TextBoxMentionCompletionTests.swift | 52 +++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index afccbf2270a..cdbaba1e22c 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -3342,8 +3342,8 @@ struct TextBoxInputView: NSViewRepresentable { textView.onPaste = onPaste textView.onInsertFileURLs = onInsertFileURLs textView.onChooseFiles = onChooseFiles - textView.onMarkedTextStateChanged = { [weak coordinator] hasMarkedText in - coordinator?.noteMarkedTextStateChanged(hasMarkedText) + textView.onMarkedTextStateChanged = { [weak coordinator, weak textView] hasMarkedText in + coordinator?.noteMarkedTextStateChanged(hasMarkedText, from: textView) } textView.refreshInlineAttachmentCells(font: font, foregroundColor: foregroundColor) textView.recenterSingleLineTextContainer() @@ -3377,11 +3377,8 @@ struct TextBoxInputView: NSViewRepresentable { func textDidChange(_ notification: Notification) { guard let textView = notification.object as? TextBoxInputTextView else { return } textView.normalizeTextBaselineOffsets() - parent.text = textView.plainText() - parent.attachments = textView.inlineAttachments() - parent.hasPendingAttachmentUpload = textView.hasPendingAttachmentUploadPlaceholder() - noteMarkedTextStateChanged(textView.hasMarkedText()) - parent.onContentChanged() + publishTextViewContent(textView) + noteMarkedTextStateChanged(textView.hasMarkedText(), from: textView) if parent.text.isEmpty, parent.attachments.isEmpty, !textView.hasPendingAttachmentUploadPlaceholder() { @@ -3393,7 +3390,7 @@ struct TextBoxInputView: NSViewRepresentable { func textViewDidChangeSelection(_ notification: Notification) { guard let textView = notification.object as? TextBoxInputTextView else { return } - noteMarkedTextStateChanged(textView.hasMarkedText()) + noteMarkedTextStateChanged(textView.hasMarkedText(), from: textView) let color = textView.textColor ?? .labelColor textView.layer?.borderColor = color.withAlphaComponent( textView.window?.firstResponder === textView ? 0.45 : 0.24 @@ -3402,10 +3399,28 @@ struct TextBoxInputView: NSViewRepresentable { textView.refreshMentionCompletions() } - func noteMarkedTextStateChanged(_ hasMarkedText: Bool) { + func noteMarkedTextStateChanged(_ hasMarkedText: Bool, from textView: TextBoxInputTextView? = nil) { + if !hasMarkedText, let textView { + publishTextViewContent(textView) + } parent.onMarkedTextStateChanged(hasMarkedText) } + private func publishTextViewContent(_ textView: TextBoxInputTextView) { + let nextText = textView.plainText() + let nextAttachments = textView.inlineAttachments() + let nextHasPendingAttachmentUpload = textView.hasPendingAttachmentUploadPlaceholder() + let contentChanged = parent.text != nextText + || parent.attachments.map(\.id) != nextAttachments.map(\.id) + || parent.hasPendingAttachmentUpload != nextHasPendingAttachmentUpload + parent.text = nextText + parent.attachments = nextAttachments + parent.hasPendingAttachmentUpload = nextHasPendingAttachmentUpload + if contentChanged { + parent.onContentChanged() + } + } + func recalculateHeight(_ textView: NSTextView) { guard let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else { return } diff --git a/cmuxTests/TextBoxMentionCompletionTests.swift b/cmuxTests/TextBoxMentionCompletionTests.swift index f8410f75afe..7f73ae9e42a 100644 --- a/cmuxTests/TextBoxMentionCompletionTests.swift +++ b/cmuxTests/TextBoxMentionCompletionTests.swift @@ -1,6 +1,7 @@ import AppKit import Carbon.HIToolbox import Foundation +import SwiftUI import Testing #if canImport(cmux_DEV) @@ -164,6 +165,57 @@ struct TextBoxMentionCompletionTests { )) } + @Test + func testTextBoxPublishesCommittedIMETextBeforeClearingMarkedState() { + var text = "" + var attachments: [TextBoxAttachment] = [] + var textViewHeight: CGFloat = 24 + var hasPendingAttachmentUpload = false + var markedTextEvents: [(hasMarkedText: Bool, text: String)] = [] + + let inputView = TextBoxInputView( + text: Binding(get: { text }, set: { text = $0 }), + attachments: Binding(get: { attachments }, set: { attachments = $0 }), + textViewHeight: Binding(get: { textViewHeight }, set: { textViewHeight = $0 }), + hasPendingAttachmentUpload: Binding( + get: { hasPendingAttachmentUpload }, + set: { hasPendingAttachmentUpload = $0 } + ), + font: NSFont.systemFont(ofSize: 14), + backgroundColor: .textBackgroundColor, + foregroundColor: .labelColor, + terminalTitle: "codex", + completionRootDirectory: nil, + onSubmit: {}, + onEscape: {}, + onFocusTextBox: {}, + onToggleFocus: {}, + onForwardText: { _, _ in }, + onForwardKey: { _ in }, + onForwardControl: { _ in }, + onPaste: { _, _ in false }, + onInsertFileURLs: { _, _ in false }, + onChooseFiles: {}, + onContentChanged: {}, + onMarkedTextStateChanged: { hasMarkedText in + markedTextEvents.append((hasMarkedText, text)) + }, + onTextViewCreated: { _ in }, + onTextViewMovedToWindow: { _ in }, + onTextViewDismantled: { _ in } + ) + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "日本語" + let coordinator = TextBoxInputView.Coordinator(parent: inputView) + + coordinator.noteMarkedTextStateChanged(false, from: textView) + + #expect(text == "日本語") + #expect(markedTextEvents.count == 1) + #expect(markedTextEvents.first?.hasMarkedText == false) + #expect(markedTextEvents.first?.text == "日本語") + } + @Test func testTextBoxStandardEditShortcutUsesTranslatedCommandCharacter() { guard let event = makeKeyDownEvent( From bd633f7b5ce2ad71386938d373e15cd01ab9c36f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:22:03 -0700 Subject: [PATCH 4/5] test: avoid nested Swift Testing expectations --- cmuxTests/TextBoxMentionCompletionTests.swift | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/cmuxTests/TextBoxMentionCompletionTests.swift b/cmuxTests/TextBoxMentionCompletionTests.swift index 7f73ae9e42a..88eb5f5d428 100644 --- a/cmuxTests/TextBoxMentionCompletionTests.swift +++ b/cmuxTests/TextBoxMentionCompletionTests.swift @@ -228,15 +228,22 @@ struct TextBoxMentionCompletionTests { return } - #expect(textBoxCommandShortcutKey( + var translatedKeyCode: UInt16? + var translatedFlags: NSEvent.ModifierFlags? + + let shortcutKey = textBoxCommandShortcutKey( for: event, translateKey: { keyCode, flags in - #expect(keyCode == UInt16(kVK_ANSI_B)) - #expect(flags.contains(.command)) + translatedKeyCode = keyCode + translatedFlags = flags return "c" }, normalizedCharacters: { _ in "b" } - ) == "c") + ) + + #expect(shortcutKey == "c") + #expect(translatedKeyCode == UInt16(kVK_ANSI_B)) + #expect(translatedFlags?.contains(.command) == true) } @Test @@ -251,15 +258,22 @@ struct TextBoxMentionCompletionTests { return } - #expect(textBoxCommandShortcutKey( + var translatedKeyCode: UInt16? + var translatedFlags: NSEvent.ModifierFlags? + + let shortcutKey = textBoxCommandShortcutKey( for: event, translateKey: { keyCode, flags in - #expect(keyCode == UInt16(kVK_ANSI_Y)) - #expect(flags.contains(.command)) + translatedKeyCode = keyCode + translatedFlags = flags return "z" }, normalizedCharacters: { _ in "y" } - ) == "z") + ) + + #expect(shortcutKey == "z") + #expect(translatedKeyCode == UInt16(kVK_ANSI_Y)) + #expect(translatedFlags?.contains(.command) == true) } @Test From 7904df11379841b329114e55d26d165fd22fae7c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:26:13 -0700 Subject: [PATCH 5/5] fix: cancel stale textbox marked sync --- Sources/TextBoxInput.swift | 14 ++- cmuxTests/TextBoxMentionCompletionTests.swift | 106 +++++++++++++++++- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index cdbaba1e22c..d2d6309817c 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -3360,6 +3360,7 @@ struct TextBoxInputView: NSViewRepresentable { var parent: TextBoxInputView private var pendingAttachmentUploadStateForNextLayout: Bool? private var pendingMarkedTextStateForNextLayout: Bool? + private var deliveredMarkedTextState: Bool? init(parent: TextBoxInputView) { self.parent = parent @@ -3400,10 +3401,19 @@ struct TextBoxInputView: NSViewRepresentable { } func noteMarkedTextStateChanged(_ hasMarkedText: Bool, from textView: TextBoxInputTextView? = nil) { - if !hasMarkedText, let textView { + let pendingMarkedTextState = pendingMarkedTextStateForNextLayout + if textView != nil { + pendingMarkedTextStateForNextLayout = nil + } + if !hasMarkedText, + let textView, + deliveredMarkedTextState == true || pendingMarkedTextState == true { publishTextViewContent(textView) } - parent.onMarkedTextStateChanged(hasMarkedText) + if deliveredMarkedTextState != hasMarkedText { + parent.onMarkedTextStateChanged(hasMarkedText) + } + deliveredMarkedTextState = hasMarkedText } private func publishTextViewContent(_ textView: TextBoxInputTextView) { diff --git a/cmuxTests/TextBoxMentionCompletionTests.swift b/cmuxTests/TextBoxMentionCompletionTests.swift index 88eb5f5d428..0e45e98c3f5 100644 --- a/cmuxTests/TextBoxMentionCompletionTests.swift +++ b/cmuxTests/TextBoxMentionCompletionTests.swift @@ -205,15 +205,113 @@ struct TextBoxMentionCompletionTests { onTextViewDismantled: { _ in } ) let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) - textView.string = "日本語" let coordinator = TextBoxInputView.Coordinator(parent: inputView) + coordinator.noteMarkedTextStateChanged(true, from: textView) + textView.string = "日本語" coordinator.noteMarkedTextStateChanged(false, from: textView) #expect(text == "日本語") - #expect(markedTextEvents.count == 1) - #expect(markedTextEvents.first?.hasMarkedText == false) - #expect(markedTextEvents.first?.text == "日本語") + #expect(markedTextEvents.count == 2) + #expect(markedTextEvents.last?.hasMarkedText == false) + #expect(markedTextEvents.last?.text == "日本語") + } + + @Test + func testTextBoxLiveMarkedTextStateCancelsQueuedInitialSync() { + var text = "" + var attachments: [TextBoxAttachment] = [] + var textViewHeight: CGFloat = 24 + var hasPendingAttachmentUpload = false + var markedTextEvents: [Bool] = [] + + let inputView = TextBoxInputView( + text: Binding(get: { text }, set: { text = $0 }), + attachments: Binding(get: { attachments }, set: { attachments = $0 }), + textViewHeight: Binding(get: { textViewHeight }, set: { textViewHeight = $0 }), + hasPendingAttachmentUpload: Binding( + get: { hasPendingAttachmentUpload }, + set: { hasPendingAttachmentUpload = $0 } + ), + font: NSFont.systemFont(ofSize: 14), + backgroundColor: .textBackgroundColor, + foregroundColor: .labelColor, + terminalTitle: "codex", + completionRootDirectory: nil, + onSubmit: {}, + onEscape: {}, + onFocusTextBox: {}, + onToggleFocus: {}, + onForwardText: { _, _ in }, + onForwardKey: { _ in }, + onForwardControl: { _ in }, + onPaste: { _, _ in false }, + onInsertFileURLs: { _, _ in false }, + onChooseFiles: {}, + onContentChanged: {}, + onMarkedTextStateChanged: { markedTextEvents.append($0) }, + onTextViewCreated: { _ in }, + onTextViewMovedToWindow: { _ in }, + onTextViewDismantled: { _ in } + ) + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + let coordinator = TextBoxInputView.Coordinator(parent: inputView) + + coordinator.queuePendingMarkedTextStateSync(from: textView) + coordinator.noteMarkedTextStateChanged(true, from: textView) + coordinator.recalculateHeight(textView) + + #expect(markedTextEvents == [true]) + } + + @Test + func testTextBoxRepeatedUnmarkedStateDoesNotRepublishContent() { + var text = "ready" + var attachments: [TextBoxAttachment] = [] + var textViewHeight: CGFloat = 24 + var hasPendingAttachmentUpload = false + var contentChangeCount = 0 + var markedTextEvents: [Bool] = [] + + let inputView = TextBoxInputView( + text: Binding(get: { text }, set: { text = $0 }), + attachments: Binding(get: { attachments }, set: { attachments = $0 }), + textViewHeight: Binding(get: { textViewHeight }, set: { textViewHeight = $0 }), + hasPendingAttachmentUpload: Binding( + get: { hasPendingAttachmentUpload }, + set: { hasPendingAttachmentUpload = $0 } + ), + font: NSFont.systemFont(ofSize: 14), + backgroundColor: .textBackgroundColor, + foregroundColor: .labelColor, + terminalTitle: "codex", + completionRootDirectory: nil, + onSubmit: {}, + onEscape: {}, + onFocusTextBox: {}, + onToggleFocus: {}, + onForwardText: { _, _ in }, + onForwardKey: { _ in }, + onForwardControl: { _ in }, + onPaste: { _, _ in false }, + onInsertFileURLs: { _, _ in false }, + onChooseFiles: {}, + onContentChanged: { contentChangeCount += 1 }, + onMarkedTextStateChanged: { markedTextEvents.append($0) }, + onTextViewCreated: { _ in }, + onTextViewMovedToWindow: { _ in }, + onTextViewDismantled: { _ in } + ) + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "changed without composition" + let coordinator = TextBoxInputView.Coordinator(parent: inputView) + + coordinator.noteMarkedTextStateChanged(false, from: textView) + coordinator.noteMarkedTextStateChanged(false, from: textView) + + #expect(text == "ready") + #expect(contentChangeCount == 0) + #expect(markedTextEvents == [false]) } @Test