Files
openclaw/apps/macos/Sources/OpenClaw/QuickChatTextView.swift
Peter Steinberger 1859cdb231 feat(mac): Quick Chat — global-shortcut floating composer for the main session (#109720)
* feat(mac): add Quick Chat floating composer with global shortcut

- Spotlight-style non-activating key panel on Option+Space (KeyboardShortcuts
  3.0.1 exact-pinned, recorder row in Settings -> General) plus a menu bar item
- shows the main-session agent identity via agent.identity.get with a
  'main session' chip; Return sends via chat.send reusing the idempotency key
  on ambiguous retries; Cmd+Return also opens full chat; Shift+Return newline
- targeted permission strip (notifications/accessibility/screen recording)
  with a scoped 1s status poll while visible; grant flow keeps the bar alive
  and avoids PermissionMonitor's AppleScript probe
- extract shared ChatSendStatus acceptance mapping; TalkModeRuntime adopts it
- rename anchored-panel 'quick chat' wording to 'compact chat panel'; docs

* chore(mac): regenerate docs map and native i18n inventory for Quick Chat

* ci: retrigger checks after stalled push event

* chore(mac): refresh native locale artifacts for Quick Chat strings

* chore(android): regenerate locale strings for refreshed shared translations
2026-07-17 01:00:45 -07:00

143 lines
5.3 KiB
Swift

import AppKit
import SwiftUI
struct QuickChatTextView: NSViewRepresentable {
@Binding var text: String
let onSubmit: (Bool) -> Void
let onEscape: () -> Void
let onHeightChange: (CGFloat) -> Void
let onTextViewReady: (NSTextView) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let textView = QuickChatNSTextView()
textView.delegate = context.coordinator
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.font = .systemFont(ofSize: 13.5)
textView.textColor = .labelColor
textView.insertionPointColor = .controlAccentColor
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 6)
textView.minSize = .zero
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.focusRingType = .none
textView.string = self.text
textView.onSubmit = self.onSubmit
textView.onEscape = self.onEscape
let scrollView = NSScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = false
scrollView.autohidesScrollers = true
scrollView.scrollerStyle = .overlay
scrollView.hasHorizontalScroller = false
scrollView.documentView = textView
context.coordinator.scrollView = scrollView
DispatchQueue.main.async {
self.onTextViewReady(textView)
context.coordinator.updateHeight(for: textView)
}
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
guard let textView = scrollView.documentView as? QuickChatNSTextView else { return }
textView.onSubmit = self.onSubmit
textView.onEscape = self.onEscape
if textView.string != self.text, !textView.hasMarkedText() {
context.coordinator.isProgrammaticUpdate = true
textView.string = self.text
textView.setSelectedRange(NSRange(location: textView.string.utf16.count, length: 0))
context.coordinator.isProgrammaticUpdate = false
}
DispatchQueue.main.async {
context.coordinator.updateHeight(for: textView)
}
}
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: QuickChatTextView
weak var scrollView: NSScrollView?
var isProgrammaticUpdate = false
private var lastHeight: CGFloat = 0
init(_ parent: QuickChatTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? QuickChatNSTextView else { return }
if !self.isProgrammaticUpdate {
self.parent.text = textView.string
}
self.updateHeight(for: textView)
}
func updateHeight(for textView: NSTextView) {
guard let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer else { return }
layoutManager.ensureLayout(for: textContainer)
let font = textView.font ?? .systemFont(ofSize: 13.5)
let lineHeight = ceil(layoutManager.defaultLineHeight(for: font))
let naturalHeight = ceil(layoutManager.usedRect(for: textContainer).height + 12)
let minHeight = lineHeight + 12
let maxHeight = (lineHeight * 5) + 12
let height = min(max(naturalHeight, minHeight), maxHeight)
self.scrollView?.hasVerticalScroller = naturalHeight > maxHeight
guard abs(height - self.lastHeight) > 0.5 else { return }
self.lastHeight = height
self.parent.onHeightChange(height)
}
}
}
private final class QuickChatNSTextView: NSTextView {
var onSubmit: ((Bool) -> Void)?
var onEscape: (() -> Void)?
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 {
guard !self.hasMarkedText() else {
super.keyDown(with: event)
return
}
self.onEscape?()
return
}
guard event.keyCode == 36 || event.keyCode == 76 else {
super.keyDown(with: event)
return
}
guard !self.hasMarkedText() else {
super.keyDown(with: event)
return
}
let modifiers = event.modifierFlags.intersection([.command, .shift])
if modifiers.contains(.shift), !modifiers.contains(.command) {
self.insertNewline(nil)
} else {
self.onSubmit?(modifiers.contains(.command))
}
}
}