Add keyboard navigation for command suggestions on macOS

The command suggestion panel was click-only (#233). On macOS, arrow keys
now move a highlight through the suggestions and return/tab insert the
highlighted command; typing continues to filter as before.

Arrow keys never reach SwiftUI modifiers while the composer's single-line
field editor has focus (it consumes moveUp:/moveDown: itself, and the
macOS 13 deployment target rules out .onKeyPress), so navigation uses an
NSEvent local monitor installed while the panel is visible and removed
when it disappears. The monitor only consumes plain up/down/return/tab
while the selectable list is showing: modified keys pass through, the
usage-reminder row stays informational (return still sends the composed
command), and iOS behavior is untouched.

Fixes #233
This commit is contained in:
Vidit Kulshrestha 2026-07-27 09:53:29 +05:30
parent 0152196ac2
commit 34e64cbe4d

View File

@ -6,6 +6,9 @@
//
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct CommandSuggestionsView: View {
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@ -14,6 +17,16 @@ struct CommandSuggestionsView: View {
@Binding var messageText: String
/// Row highlighted for keyboard navigation (macOS). Reset whenever the
/// filtered list changes so the highlight never outlives its list.
@State private var selectedIndex = 0
#if os(macOS)
/// Arrow keys never reach SwiftUI while the composer's field editor has
/// focus (the single-line field consumes moveUp:/moveDown: itself), so
/// navigation uses a local key monitor scoped to the panel's lifetime.
@State private var keyMonitor: Any?
#endif
/// The command already typed in full, once arguments have begun.
private var typedCommandAlias: String? {
guard messageText.hasPrefix("/"),
@ -43,23 +56,91 @@ struct CommandSuggestionsView: View {
// off-center.
if !filteredCommands.isEmpty {
let isUsageReminder = typedCommandAlias != nil
let commands = filteredCommands
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in
Button {
// In usage-reminder mode the row is informational; an
// insert here would wipe the arguments being typed.
guard !isUsageReminder else { return }
messageText = command.alias + " "
accept(command)
} label: {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(rowHighlight(index: index, isUsageReminder: isUsageReminder))
}
}
.themedOverlayPanel()
.onChange(of: commands) { _ in
selectedIndex = 0
}
#if os(macOS)
.onAppear { installKeyMonitor() }
.onDisappear { removeKeyMonitor() }
#endif
}
}
/// Inserts the command with a trailing space, ready for arguments.
private func accept(_ command: CommandInfo) {
messageText = command.alias + " "
}
private func rowHighlight(index: Int, isUsageReminder: Bool) -> Color {
#if os(macOS)
if !isUsageReminder && index == selectedIndex {
return palette.secondary.opacity(0.15)
}
#endif
return .clear
}
#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: arrows move the highlight,
/// return/tab insert the highlighted command. Returning nil consumes the
/// event, so return completes instead of sending while the list is up;
/// the usage-reminder row stays informational and passes everything
/// through (return sends the composed command).
private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
let commands = filteredCommands
guard !commands.isEmpty,
typedCommandAlias == nil,
event.modifierFlags.intersection([.command, .option, .control]).isEmpty else {
return event
}
switch event.keyCode {
case 126: // up arrow
selectedIndex = max(0, selectedIndex - 1)
return nil
case 125: // down arrow
selectedIndex = min(commands.count - 1, selectedIndex + 1)
return nil
case 36, 48: // return, tab
accept(commands[min(selectedIndex, commands.count - 1)])
return nil
default:
return event
}
}
#endif
private func buttonRow(for command: CommandInfo) -> some View {
HStack {
Text(command.alias)