diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index b5c02f61..6afb2a27 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -1339,6 +1339,92 @@ final class OmnibarSuggestionRankingTests: XCTestCase { XCTAssertEqual(row.listText, "Example Domain — example.com/path?q=1") XCTAssertFalse(row.listText.contains("\n")) } + + func testPublishedBufferTextUsesTypedPrefixWhenInlineSuffixIsSelected() { + let inline = OmnibarInlineCompletion( + typedText: "l", + displayText: "localhost:3000", + acceptedText: "https://localhost:3000/" + ) + + let published = omnibarPublishedBufferTextForFieldChange( + fieldValue: inline.displayText, + inlineCompletion: inline, + selectionRange: inline.suffixRange, + hasMarkedText: false + ) + + XCTAssertEqual(published, "l") + } + + func testPublishedBufferTextKeepsUserTypedValueWhenDisplayDiffersFromInlineText() { + let inline = OmnibarInlineCompletion( + typedText: "l", + displayText: "localhost:3000", + acceptedText: "https://localhost:3000/" + ) + + let published = omnibarPublishedBufferTextForFieldChange( + fieldValue: "la", + inlineCompletion: inline, + selectionRange: NSRange(location: 2, length: 0), + hasMarkedText: false + ) + + XCTAssertEqual(published, "la") + } + + func testInlineCompletionRenderIgnoresStaleTypedPrefixMismatch() { + let staleInline = OmnibarInlineCompletion( + typedText: "g", + displayText: "github.com", + acceptedText: "https://github.com/" + ) + + let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix( + bufferText: "l", + inlineCompletion: staleInline + ) + + XCTAssertNil(active) + } + + func testInlineCompletionRenderKeepsMatchingTypedPrefix() { + let inline = OmnibarInlineCompletion( + typedText: "l", + displayText: "localhost:3000", + acceptedText: "https://localhost:3000/" + ) + + let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix( + bufferText: "l", + inlineCompletion: inline + ) + + XCTAssertEqual(active, inline) + } + + func testInlineCompletionSkipsTitleMatchWhoseURLDoesNotStartWithTypedText() { + // History entry: visited google.com/search?q=localhost:3000 with title + // "localhost:3000 - Google Search". Typing "l" should NOT inline-complete + // to "google.com/..." because that replaces the typed "l" with "g". + let suggestions: [OmnibarSuggestion] = [ + .history( + url: "https://www.google.com/search?q=localhost:3000", + title: "localhost:3000 - Google Search" + ), + ] + + let result = omnibarInlineCompletionForDisplay( + typedText: "l", + suggestions: suggestions, + isFocused: true, + selectionRange: NSRange(location: 1, length: 0), + hasMarkedText: false + ) + + XCTAssertNil(result, "Should not inline-complete when display text does not start with typed prefix") + } } @MainActor diff --git a/GhosttyTabsUITests/BrowserOmnibarSuggestionsUITests.swift b/GhosttyTabsUITests/BrowserOmnibarSuggestionsUITests.swift index 2f119c72..4d18e5dc 100644 --- a/GhosttyTabsUITests/BrowserOmnibarSuggestionsUITests.swift +++ b/GhosttyTabsUITests/BrowserOmnibarSuggestionsUITests.swift @@ -307,6 +307,59 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey("x", modifierFlags: []) } + func testCmdLImmediateTypingReplacesExistingURLBuffer() { + seedBrowserHistoryForTest() + + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1" + app.launch() + app.activate() + + let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch + XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0)) + + // Navigate to a non-empty URL first so Cmd+L must replace existing text. + app.typeKey("l", modifierFlags: [.command]) + omnibar.typeText("example.com") + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + let loadedDeadline = Date().addingTimeInterval(8.0) + var loaded = false + while Date() < loadedDeadline { + let value = ((omnibar.value as? String) ?? "").lowercased() + if value.contains("example.com") || value.contains("example.org") { + loaded = true + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.") + + // Reproduce user flow: Cmd+L then immediate typing without waiting. + app.typeKey("l", modifierFlags: [.command]) + app.typeText("lo") + + let typedDeadline = Date().addingTimeInterval(4.0) + var observedValue = "" + var startsWithTypedPrefix = false + while Date() < typedDeadline { + observedValue = ((omnibar.value as? String) ?? "").lowercased() + if observedValue.hasPrefix("lo") { + startsWithTypedPrefix = true + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + XCTAssertTrue( + startsWithTypedPrefix, + "Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)" + ) + } + func testOmnibarAutocompleteCandidateIsCommittedOnEnter() { seedBrowserHistoryForTest( seedEntries: [ diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f4a73330..34ef9c94 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -170,7 +170,16 @@ struct BrowserPanelView: View { .onReceive(NotificationCenter.default.publisher(for: .browserFocusAddressBar)) { notification in guard let panelId = notification.object as? UUID, panelId == panel.id else { return } panel.beginSuppressWebViewFocusForAddressBar() - addressBarFocused = true + if addressBarFocused { + // Cmd+L should always refresh omnibar state/select-all, even when the + // field already has focus. + let urlString = panel.preferredURLStringForOmnibar() ?? "" + let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) + applyOmnibarEffects(effects) + refreshInlineCompletion() + } else { + addressBarFocused = true + } } .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in guard let panelId = notification.object as? UUID, panelId == panel.id else { return } @@ -750,6 +759,11 @@ struct BrowserPanelView: View { refreshSuggestions() } if effects.shouldSelectAll { + // Apply immediately for fast Cmd+L typing, then retry once in case + // first responder wasn't fully settled on the same runloop. + DispatchQueue.main.async { + NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) } @@ -1292,6 +1306,11 @@ func omnibarInlineCompletionForDisplay( } guard omnibarSuggestionSupportsAutocompletion(query: query, suggestion: candidate) else { return nil } + // The display text must start with the typed query so the inline completion + // visually extends what the user typed rather than replacing it (e.g. a + // history entry matched via title "localhost:3000" whose URL is google.com + // should not replace a typed "l" with "g"). + guard displayText.lowercased().hasPrefix(loweredQuery) else { return nil } guard displayText.utf16.count > queryCount else { return nil } @@ -1339,6 +1358,40 @@ func omnibarDesiredSelectionRangeForInlineCompletion( return inlineCompletion.suffixRange } +func omnibarPublishedBufferTextForFieldChange( + fieldValue: String, + inlineCompletion: OmnibarInlineCompletion?, + selectionRange: NSRange?, + hasMarkedText: Bool +) -> String { + guard !hasMarkedText else { return fieldValue } + guard let inlineCompletion else { return fieldValue } + guard fieldValue == inlineCompletion.displayText else { return fieldValue } + guard let selectionRange else { return inlineCompletion.typedText } + + let typedCount = inlineCompletion.typedText.utf16.count + let displayCount = inlineCompletion.displayText.utf16.count + let typedPrefixSelection = NSRange(location: 0, length: typedCount) + let isCaretAtTypedBoundary = selectionRange.location == typedCount && selectionRange.length == 0 + let isSuffixSelection = NSEqualRanges(selectionRange, inlineCompletion.suffixRange) + let isSelectAllSelection = selectionRange.location == 0 && selectionRange.length == displayCount + let isTypedPrefixSelection = NSEqualRanges(selectionRange, typedPrefixSelection) + if isCaretAtTypedBoundary || isSuffixSelection || isSelectAllSelection || isTypedPrefixSelection { + return inlineCompletion.typedText + } + + return fieldValue +} + +func omnibarInlineCompletionIfBufferMatchesTypedPrefix( + bufferText: String, + inlineCompletion: OmnibarInlineCompletion? +) -> OmnibarInlineCompletion? { + guard let inlineCompletion else { return nil } + guard bufferText == inlineCompletion.typedText else { return nil } + return inlineCompletion +} + private func typedQueryHasExplicitPathOrQuery(_ typedQuery: String) -> Bool { var normalized = typedQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if normalized.hasPrefix("https://") { @@ -1863,7 +1916,13 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { func controlTextDidChange(_ obj: Notification) { guard !isProgrammaticMutation else { return } guard let field = obj.object as? NSTextField else { return } - parent.text = field.stringValue + let editor = field.currentEditor() as? NSTextView + parent.text = omnibarPublishedBufferTextForFieldChange( + fieldValue: field.stringValue, + inlineCompletion: parent.inlineCompletion, + selectionRange: editor?.selectedRange(), + hasMarkedText: editor?.hasMarkedText() ?? false + ) publishSelectionState() } @@ -2051,7 +2110,11 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { context.coordinator.parentField = nsView nsView.placeholderString = placeholder - let desiredDisplayText = inlineCompletion?.displayText ?? text + let activeInlineCompletion = omnibarInlineCompletionIfBufferMatchesTypedPrefix( + bufferText: text, + inlineCompletion: inlineCompletion + ) + let desiredDisplayText = activeInlineCompletion?.displayText ?? text if let editor = nsView.currentEditor() as? NSTextView { if editor.string != desiredDisplayText { context.coordinator.isProgrammaticMutation = true @@ -2076,13 +2139,13 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } if let editor = nsView.currentEditor() as? NSTextView { - if let inlineCompletion { + if let activeInlineCompletion { let currentSelection = editor.selectedRange() let desiredSelection = omnibarDesiredSelectionRangeForInlineCompletion( currentSelection: currentSelection, - inlineCompletion: inlineCompletion + inlineCompletion: activeInlineCompletion ) - if context.coordinator.appliedInlineCompletion != inlineCompletion || + if context.coordinator.appliedInlineCompletion != activeInlineCompletion || !NSEqualRanges(currentSelection, desiredSelection) { context.coordinator.isProgrammaticMutation = true editor.setSelectedRange(desiredSelection) @@ -2098,7 +2161,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } } } - context.coordinator.appliedInlineCompletion = inlineCompletion + context.coordinator.appliedInlineCompletion = activeInlineCompletion context.coordinator.attachSelectionObserverIfNeeded() context.coordinator.publishSelectionState() } diff --git a/web/public/ghostty-vt.wasm b/web/public/ghostty-vt.wasm new file mode 100755 index 00000000..f3dc36b6 Binary files /dev/null and b/web/public/ghostty-vt.wasm differ