Keyboard navigation for @-mention suggestions (#1542)

* Tab and arrow keys for mention autocomplete

Highlight the selected suggestion and let Tab accept it. Up/down move
the selection when the mention panel is open; Tab otherwise still
cycles focus.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Retrigger CI after flaky GeoRelayDirectory iOS test

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: navigate mention suggestions with the same macOS key monitor as commands

Arrow keys never reach SwiftUI while the composer field editor has
focus, so adopt the NSEvent local monitor from #1504. Align accept
keys (Return or Tab), add Escape to dismiss, and match highlight opacity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix dead key monitor: gate on live state, not a value-captured Bool

A synthetic-event harness against the extracted modifier showed the
realistic path broken: the panel is hidden when the composer appears, so
onChange(of: isActive) ran on the previous render's modifier value and
installed a monitor whose closure had captured isActive == false — it
passed every key through forever. Arrows/Tab/Escape never worked on a
real Mac.

Install the monitor once for the view's lifetime and gate each event on
an isActive closure that reads the reference-typed model live. Same fix
applies to the iOS onKeyPress guards for consistency. Harness now passes
all paths including deactivate/reactivate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Taksh Kothari 2026-08-01 18:21:31 +05:30 committed by GitHub
parent 0152344554
commit 1f59e814f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 162 additions and 1 deletions

View File

@ -9,6 +9,7 @@ import UIKit
final class ConversationUIModel: ObservableObject {
@Published private(set) var showAutocomplete = false
@Published private(set) var autocompleteSuggestions: [String] = []
@Published private(set) var selectedAutocompleteIndex = 0
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
@ -36,6 +37,7 @@ final class ConversationUIModel: ObservableObject {
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
bind()
@ -104,6 +106,31 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.completeNickname(nickname, in: &text)
}
/// Accept the currently highlighted mention suggestion, if any.
func completeSelectedSuggestion(in text: inout String) -> Bool {
guard showAutocomplete,
autocompleteSuggestions.indices.contains(selectedAutocompleteIndex)
else { return false }
_ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text)
return true
}
/// Dismiss the mention suggestion panel without inserting (Escape).
func dismissAutocomplete() {
guard showAutocomplete else { return }
chatViewModel.showAutocomplete = false
chatViewModel.autocompleteSuggestions = []
chatViewModel.autocompleteRange = nil
chatViewModel.selectedAutocompleteIndex = 0
}
func moveAutocompleteSelection(by delta: Int) {
guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return }
let count = min(4, autocompleteSuggestions.count)
let next = (selectedAutocompleteIndex + delta + count) % count
chatViewModel.selectedAutocompleteIndex = next
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
}
@ -205,6 +232,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$autocompleteSuggestions)
chatViewModel.$selectedAutocompleteIndex
.receive(on: DispatchQueue.main)
.assign(to: &$selectedAutocompleteIndex)
chatViewModel.$isBatchingPublic
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)

View File

@ -2,6 +2,9 @@ import SwiftUI
#if os(iOS)
import UIKit
#endif
#if os(macOS)
import AppKit
#endif
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@ -29,7 +32,7 @@ struct ContentComposerView: View {
VStack(alignment: .leading, spacing: 6) {
if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
}) {
@ -43,6 +46,11 @@ struct ContentComposerView: View {
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
index == conversationUIModel.selectedAutocompleteIndex
? palette.secondary.opacity(0.15)
: Color.clear
)
}
.buttonStyle(.plain)
}
@ -73,7 +81,28 @@ struct ContentComposerView: View {
.textInputAutocapitalization(.sentences)
#endif
.submitLabel(.send)
.modifier(AutocompleteKeyboardNavigationModifier(
isActive: { conversationUIModel.showAutocomplete
&& !conversationUIModel.autocompleteSuggestions.isEmpty },
onMove: { delta in
conversationUIModel.moveAutocompleteSelection(by: delta)
},
onAccept: {
conversationUIModel.completeSelectedSuggestion(in: &messageText)
},
onDismiss: {
conversationUIModel.dismissAutocomplete()
}
))
// Return while the mention panel is open completes the
// highlight instead of sending matches command suggestions
// (#1504) and keeps Tab/Return/Escape on one convention.
.onSubmit {
if conversationUIModel.showAutocomplete,
!conversationUIModel.autocompleteSuggestions.isEmpty,
conversationUIModel.completeSelectedSuggestion(in: &messageText) {
return
}
onSendMessage()
// Only the return-key path: it steals focus on iOS, so
// every message would cost a tap to reopen the keyboard.
@ -374,3 +403,104 @@ private extension ContentComposerView {
)
}
}
/// Arrow/Tab/Return/Escape navigation for the mention suggestion list.
///
/// Deployment targets are iOS 16 / macOS 13, so `.onKeyPress` (iOS 17 /
/// macOS 14+) is gated and unavailable on the minimum OS. Separately, on
/// macOS the single-line field editor consumes `moveUp:`/`moveDown:` itself,
/// so arrow keys never reach SwiftUI while the composer has focus the
/// same reason command suggestions (#1504) use an `NSEvent` local monitor.
/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+.
private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
/// Live activity check, not a captured Bool. The macOS monitor closure is
/// registered once for the view's lifetime; a plain `Bool` would freeze
/// the value captured at install time (this is a value type), so a panel
/// that opens after the monitor installs would never intercept a key.
/// The provider closes over the reference-typed model and reads current
/// state on every event.
let isActive: () -> Bool
let onMove: (Int) -> Void
let onAccept: () -> Bool
let onDismiss: () -> Void
#if os(macOS)
@State private var keyMonitor: Any?
#endif
func body(content: Content) -> some View {
#if os(macOS)
content
.onAppear { installKeyMonitor() }
.onDisappear { removeKeyMonitor() }
#else
if #available(iOS 17.0, *) {
content
.onKeyPress(.upArrow) {
guard isActive() else { return .ignored }
onMove(-1)
return .handled
}
.onKeyPress(.downArrow) {
guard isActive() else { return .ignored }
onMove(1)
return .handled
}
.onKeyPress(.tab) {
guard isActive() else { return .ignored }
return onAccept() ? .handled : .ignored
}
.onKeyPress(.escape) {
guard isActive() else { return .ignored }
onDismiss()
return .handled
}
} else {
content
}
#endif
}
#if os(macOS)
private func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
handleKeyDown(event)
}
}
private func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
}
keyMonitor = nil
}
/// Standard autocomplete navigation (aligned with #1504): arrows move
/// the highlight, return/tab insert, escape dismisses. Returning nil
/// consumes the event so return completes instead of sending while the
/// list is up. Inactive monitors pass everything through.
private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
guard isActive(),
event.modifierFlags.intersection([.command, .option, .control]).isEmpty else {
return event
}
switch event.keyCode {
case 126: // up arrow
onMove(-1)
return nil
case 125: // down arrow
onMove(1)
return nil
case 36, 48: // return, tab
return onAccept() ? nil : event
case 53: // escape
onDismiss()
return nil
default:
return event
}
}
#endif
}