diff --git a/CHANGELOG.md b/CHANGELOG.md index 427169d069..e85d83bb4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Privacy manifest for the iOS app. - Oracle `DBMS_OUTPUT` lines shown with the result of the statement that printed them, and in a new **Output** result view. - Oracle transactions opened with `SET TRANSACTION`, `SAVEPOINT` or `LOCK TABLE`, held until `COMMIT` or `ROLLBACK`. +- **Highlight When Focused** on the AI chat input's context menu, for turning its colored focus highlight off. (#2995) - Several label columns beside the key in the foreign key picker, for a parent row only told apart by a combination. (#2996) ### Changed @@ -41,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Colored highlight on the AI chat input painting over a window that is not key. +- Colored highlight on the AI chat input ignoring Reduce Transparency and Increase Contrast. +- AI chat input focus crossfade playing against Reduce Motion. +- AI chat input announced with no name by VoiceOver. - Oracle PL/SQL blocks split at their inner semicolons and sent as fragments, failing with PLS-00103. (#2984) - Oracle procedures, packages and triggers created from the editor stored INVALID while the run reported success. - SQL*Plus `/` lines, `q'[…]'` literals and backslashes in strings misread in Oracle scripts. diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 66fb4c6aac..75f24358b1 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -236,6 +236,9 @@ struct AISettings: Codable, Equatable, Sendable { var maxToolRoundtripsEnabled: Bool var defaultConnectionPolicy: AIConnectionPolicy var chatMode: AIChatMode + /// Set from the composer's own context menu rather than the Settings window, because the only + /// place the highlight is worth thinking about is the field it wraps. + var composerHighlightEnabled: Bool static let defaultInlineSuggestionDebounceMs: Int = 500 static let inlineSuggestionDebounceRange: ClosedRange = 100...3_000 @@ -255,7 +258,8 @@ struct AISettings: Codable, Equatable, Sendable { maxToolRoundtrips: AISettings.defaultMaxToolRoundtrips, maxToolRoundtripsEnabled: true, defaultConnectionPolicy: .askEachTime, - chatMode: .ask + chatMode: .ask, + composerHighlightEnabled: true ) init( @@ -271,7 +275,8 @@ struct AISettings: Codable, Equatable, Sendable { maxToolRoundtrips: Int = AISettings.defaultMaxToolRoundtrips, maxToolRoundtripsEnabled: Bool = true, defaultConnectionPolicy: AIConnectionPolicy = .askEachTime, - chatMode: AIChatMode = .ask + chatMode: AIChatMode = .ask, + composerHighlightEnabled: Bool = true ) { self.enabled = enabled self.providers = providers @@ -286,6 +291,7 @@ struct AISettings: Codable, Equatable, Sendable { self.maxToolRoundtripsEnabled = maxToolRoundtripsEnabled self.defaultConnectionPolicy = defaultConnectionPolicy self.chatMode = chatMode + self.composerHighlightEnabled = composerHighlightEnabled } init(from decoder: Decoder) throws { @@ -311,6 +317,9 @@ struct AISettings: Codable, Equatable, Sendable { AIConnectionPolicy.self, forKey: .defaultConnectionPolicy ) ?? .askEachTime chatMode = try container.decodeIfPresent(AIChatMode.self, forKey: .chatMode) ?? .ask + composerHighlightEnabled = try container.decodeIfPresent( + Bool.self, forKey: .composerHighlightEnabled + ) ?? true } var activeProvider: AIProviderConfig? { diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index fef42a1081..da57d150f1 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -284,6 +284,10 @@ struct AIChatPanelView: View { }, onImageAttachmentFailed: { message in viewModel.reportImageAttachmentFailure(message) + }, + highlightEnabled: settingsManager.ai.composerHighlightEnabled, + onToggleHighlight: { + settingsManager.ai.composerHighlightEnabled.toggle() } ) diff --git a/TablePro/Views/AIChat/ChatComposerChrome.swift b/TablePro/Views/AIChat/ChatComposerChrome.swift new file mode 100644 index 0000000000..600dffddb8 --- /dev/null +++ b/TablePro/Views/AIChat/ChatComposerChrome.swift @@ -0,0 +1,38 @@ +// +// ChatComposerChrome.swift +// TablePro +// + +import AppKit +import SwiftUI + +internal enum ChatComposerMetrics { + /// One owner for the composer's curvature, read by the SwiftUI background shape and by the + /// scroll view's focus ring mask, so the ring cannot drift from the surface it wraps. + static let cornerRadius: CGFloat = 16 +} + +/// Whether the composer paints its own focus highlight, which is a user preference first and a +/// system accessibility decision second. The highlight is a wide translucent colour wash, so +/// Reduce Transparency and Increase Contrast both mean "not this", the same answer +/// `SolidSurfacePreference` gives for every other translucent surface in the app. +/// +/// Reduce Motion is deliberately absent: the gradient is static, and the only motion is the +/// crossfade, which `motionAnimation` already gates at the call site. +internal enum ComposerHighlightPreference { + static func paintsHighlight( + enabled: Bool, + reduceTransparency: Bool, + contrast: ColorSchemeContrast + ) -> Bool { + guard enabled else { return false } + return !SolidSurfacePreference.prefersSolid(reduceTransparency: reduceTransparency, contrast: contrast) + } + + /// The composer draws exactly one focus affordance. When it paints its own, AppKit must not + /// add a second; when it does not, the system ring is the whole indication that the field has + /// keyboard focus, so it has to be on. + static func focusRingType(paintsHighlight: Bool) -> NSFocusRingType { + paintsHighlight ? .none : .exterior + } +} diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift index b0a8ddbb4a..e5f68cb8c1 100644 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ b/TablePro/Views/AIChat/ChatComposerTextView.swift @@ -15,6 +15,9 @@ struct ChatComposerTextView: NSViewRepresentable { let maxLines: Int let isCommittingMention: Bool let acceptsImages: Bool + let paintsHighlight: Bool + let highlightEnabled: Bool + let onToggleHighlight: () -> Void let onTextChange: (String, Int) -> Void let onSubmit: () -> Void let onCommitMention: () -> Bool @@ -29,10 +32,13 @@ struct ChatComposerTextView: NSViewRepresentable { textView.placeholder = placeholder textView.acceptsImagePaste = acceptsImages textView.onPasteImageData = onPasteImageData + textView.highlightEnabled = highlightEnabled + textView.onToggleHighlight = onToggleHighlight let scrollView = ChatComposerScrollView.make(documentView: textView) scrollView.minLines = minLines scrollView.maxLines = maxLines + scrollView.focusRingType = ComposerHighlightPreference.focusRingType(paintsHighlight: paintsHighlight) textView.onFocusChange = { [weak coordinator = context.coordinator] focused in coordinator?.handleFocusChange(focused) @@ -58,6 +64,14 @@ struct ChatComposerTextView: NSViewRepresentable { context.coordinator.refresh(from: self) scrollView.minLines = minLines scrollView.maxLines = maxLines + textView.highlightEnabled = highlightEnabled + textView.onToggleHighlight = onToggleHighlight + + let ringType = ComposerHighlightPreference.focusRingType(paintsHighlight: paintsHighlight) + if scrollView.focusRingType != ringType { + scrollView.focusRingType = ringType + scrollView.noteFocusRingMaskChanged() + } // Replacing the string outright while an input method has marked text cancels the // composition. Routing through shouldChangeText/didChangeText also keeps the undo @@ -73,11 +87,7 @@ struct ChatComposerTextView: NSViewRepresentable { textView.setSelectedRange(NSRange(location: clampedLocation, length: 0)) } - if textView.placeholder != placeholder { - textView.placeholder = placeholder - textView.setAccessibilityPlaceholderValue(placeholder) - textView.needsDisplay = true - } + textView.placeholder = placeholder if isFocused, textView.window?.firstResponder !== textView { DispatchQueue.main.async { @@ -186,12 +196,26 @@ struct ChatComposerTextView: NSViewRepresentable { } final class ChatComposerNSTextView: NSTextView { - var placeholder: String = "" + /// The placeholder is painted in `draw(_:)`, which the accessibility tree never sees, so the + /// only thing that names this field to VoiceOver is the value set here. Keeping the two + /// together means no assignment path can leave the field nameless: guarding the call at one + /// caller is what left it unset since #2097, because `makeNSView` had already stored the same + /// string and the caller's comparison was never true again. + var placeholder: String = "" { + didSet { + guard oldValue != placeholder else { return } + setAccessibilityPlaceholderValue(placeholder) + needsDisplay = true + } + } + var placeholderColor: NSColor = .placeholderTextColor var onFocusChange: ((Bool) -> Void)? var onSizeChange: (() -> Void)? var acceptsImagePaste: Bool = false var onPasteImageData: ((Data, String) -> Void)? + var highlightEnabled: Bool = true + var onToggleHighlight: (() -> Void)? static func make() -> ChatComposerNSTextView { let textView = ChatComposerNSTextView() @@ -221,6 +245,35 @@ final class ChatComposerNSTextView: NSTextView { return resigned } + /// Measured on macOS 27: unparenting the pane moves the window's first responder away without + /// ever sending `resignFirstResponder` here, so focus has to be re-read from the window rather + /// than waited for. Switching the trailing pane away and back otherwise left the composer + /// believing it still held focus, and the highlight painted over a field that did not. + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onFocusChange?(window?.firstResponder === self) + } + + override func menu(for event: NSEvent) -> NSMenu? { + let menu = super.menu(for: event) ?? NSMenu() + if !menu.items.isEmpty { + menu.addItem(.separator()) + } + let item = NSMenuItem( + title: String(localized: "Highlight When Focused"), + action: #selector(toggleComposerHighlight(_:)), + keyEquivalent: "" + ) + item.target = self + item.state = highlightEnabled ? .on : .off + menu.addItem(item) + return menu + } + + @objc private func toggleComposerHighlight(_ sender: Any?) { + onToggleHighlight?() + } + override func didChangeText() { super.didChangeText() onSizeChange?() @@ -313,6 +366,31 @@ final class ChatComposerScrollView: NSScrollView { return scrollView } + /// The composer's rounded surface is drawn by SwiftUI, so AppKit has to be told the shape to + /// wrap; left to itself it rings the square bounds. This is the shape `ShortcutRecorderNSView` + /// uses for the same job, and the radius is the one the SwiftUI background reads. + /// + /// The document view carries the focus, but the ring belongs on the scroll view: measured on + /// macOS 27, `focusRingType` on the text view never produces a `drawFocusRingMask` call, while + /// on the scroll view it does, `.noBorder` included. + override func drawFocusRingMask() { + NSBezierPath( + roundedRect: bounds, + xRadius: ChatComposerMetrics.cornerRadius, + yRadius: ChatComposerMetrics.cornerRadius + ).fill() + } + + override var focusRingMaskBounds: NSRect { bounds } + + /// The mask is derived from `bounds`, and the composer grows from one line to five as the user + /// types, so the cached ring has to be invalidated with the size that produced it. + override func setFrameSize(_ newSize: NSSize) { + let changed = newSize != frame.size + super.setFrameSize(newSize) + if changed { noteFocusRingMaskChanged() } + } + override var intrinsicContentSize: NSSize { guard let textView = documentView as? NSTextView, diff --git a/TablePro/Views/AIChat/ChatComposerView.swift b/TablePro/Views/AIChat/ChatComposerView.swift index ddac0ca380..a315f31bd1 100644 --- a/TablePro/Views/AIChat/ChatComposerView.swift +++ b/TablePro/Views/AIChat/ChatComposerView.swift @@ -18,11 +18,17 @@ struct ChatComposerView: View { let acceptsImages: Bool let onAttachImages: ([ChatImageInput]) -> Void let onImageAttachmentFailed: (String) -> Void + let highlightEnabled: Bool + let onToggleHighlight: () -> Void @State private var isFocused: Bool = false @State private var isCommittingMention = false @State private var isDropTargeted: Bool = false + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var contrast + @Environment(\.controlActiveState) private var controlActiveState + init( text: Binding, placeholder: String, @@ -34,7 +40,9 @@ struct ChatComposerView: View { onAttach: @escaping (ContextItem) -> Void, acceptsImages: Bool = false, onAttachImages: @escaping ([ChatImageInput]) -> Void = { _ in }, - onImageAttachmentFailed: @escaping (String) -> Void = { _ in } + onImageAttachmentFailed: @escaping (String) -> Void = { _ in }, + highlightEnabled: Bool = true, + onToggleHighlight: @escaping () -> Void = {} ) { self._text = text self.placeholder = placeholder @@ -47,6 +55,8 @@ struct ChatComposerView: View { self.acceptsImages = acceptsImages self.onAttachImages = onAttachImages self.onImageAttachmentFailed = onImageAttachmentFailed + self.highlightEnabled = highlightEnabled + self.onToggleHighlight = onToggleHighlight } var body: some View { @@ -58,6 +68,9 @@ struct ChatComposerView: View { maxLines: maxLines, isCommittingMention: isCommittingMention, acceptsImages: acceptsImages, + paintsHighlight: paintsHighlight, + highlightEnabled: highlightEnabled, + onToggleHighlight: onToggleHighlight, onTextChange: { newText, caret in guard !isCommittingMention else { return } onTextChange(newText, caret) @@ -92,7 +105,7 @@ struct ChatComposerView: View { ) .overlay { if isDropTargeted { - RoundedRectangle(cornerRadius: 16, style: .continuous) + RoundedRectangle(cornerRadius: ChatComposerMetrics.cornerRadius, style: .continuous) .strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 2) .allowsHitTesting(false) } @@ -135,12 +148,29 @@ struct ChatComposerView: View { } } + /// The preference and the two system settings that override it, answered once for both the + /// SwiftUI overlay and the scroll view's focus ring type, so the two can never both paint. + private var paintsHighlight: Bool { + ComposerHighlightPreference.paintsHighlight( + enabled: highlightEnabled, + reduceTransparency: reduceTransparency, + contrast: contrast + ) + } + + /// A focus affordance belongs to the key window alone, which is why AppKit withdraws its own + /// ring the moment the window resigns key. The highlight is drawn by SwiftUI and gets no such + /// treatment for free, so it asks. + private var showsHighlight: Bool { + paintsHighlight && isFocused && controlActiveState == .key + } + private var composerBackground: some View { - let shape = RoundedRectangle(cornerRadius: 16, style: .continuous) + let shape = RoundedRectangle(cornerRadius: ChatComposerMetrics.cornerRadius, style: .continuous) return shape .fill(Color(nsColor: .textBackgroundColor)) .overlay { - if isFocused { + if showsHighlight { IntelligenceFocusBorder(shape: shape) .transition(.opacity) .accessibilityHidden(true) @@ -150,7 +180,7 @@ struct ChatComposerView: View { .accessibilityHidden(true) } } - .animation(.easeOut(duration: 0.25), value: isFocused) + .motionAnimation(.easeOut(duration: 0.25), value: showsHighlight) } private var popoverBinding: Binding { diff --git a/TableProTests/Models/AISettingsTests.swift b/TableProTests/Models/AISettingsTests.swift index cd697c1bb8..e1317ff2e4 100644 --- a/TableProTests/Models/AISettingsTests.swift +++ b/TableProTests/Models/AISettingsTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("AISettings") @@ -31,6 +31,27 @@ struct AISettingsTests { #expect(settings.enabled == false) } + /// The highlight is the shipped appearance, so everyone who has never opened the composer's + /// context menu has to keep it across the upgrade that adds the key. + @Test("Settings saved before the highlight preference existed keep the highlight on") + func decodingWithoutComposerHighlightDefaultsToTrue() throws { + let settings = try JSONDecoder().decode(AISettings.self, from: Data("{}".utf8)) + #expect(settings.composerHighlightEnabled == true) + #expect(AISettings.default.composerHighlightEnabled == true) + #expect(AISettings().composerHighlightEnabled == true) + } + + @Test("Turning the highlight off survives a round trip") + func composerHighlightRoundTrips() throws { + var settings = AISettings.default + settings.composerHighlightEnabled = false + let decoded = try JSONDecoder().decode( + AISettings.self, + from: JSONEncoder().encode(settings) + ) + #expect(decoded.composerHighlightEnabled == false) + } + @Test("Default settings include schema and current query, exclude query results") func defaultsForContextFlags() { let settings = AISettings.default diff --git a/TableProTests/Views/AIChat/ChatComposerScrollViewTests.swift b/TableProTests/Views/AIChat/ChatComposerScrollViewTests.swift index 2a6207d61b..53d01396a3 100644 --- a/TableProTests/Views/AIChat/ChatComposerScrollViewTests.swift +++ b/TableProTests/Views/AIChat/ChatComposerScrollViewTests.swift @@ -80,4 +80,86 @@ struct ChatComposerScrollViewTests { let scrollView = makeComposer(width: 320) #expect(scrollView.intrinsicContentSize.width == NSView.noIntrinsicMetric) } + + /// The ring wraps the rounded surface SwiftUI paints, not the square bounds AppKit would ring + /// on its own, so the mask has to cover the whole frame and be re-asked for when it changes. + @Test("The focus ring mask covers the composer's own bounds") + func focusRingMaskCoversBounds() { + let scrollView = makeComposer(width: 320) + #expect(scrollView.focusRingMaskBounds == scrollView.bounds) + + scrollView.setFrameSize(NSSize(width: 320, height: 96)) + scrollView.layoutSubtreeIfNeeded() + #expect(scrollView.focusRingMaskBounds == scrollView.bounds) + } + + @Test("Drawing the mask leaves the whole rounded surface covered") + func maskFillsTheRoundedSurface() throws { + let scrollView = makeComposer(width: 320, height: 44) + let image = NSImage(size: scrollView.bounds.size) + image.lockFocus() + NSColor.black.setFill() + scrollView.drawFocusRingMask() + image.unlockFocus() + + let bitmap = try #require(NSBitmapImageRep(data: image.tiffRepresentation ?? Data())) + let centre = try #require(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)) + #expect(centre.alphaComponent > 0.5) + } +} + +@MainActor +@Suite("ChatComposerNSTextView accessibility") +struct ChatComposerTextViewAccessibilityTests { + /// The placeholder is painted in `draw(_:)` and never reaches the accessibility tree, so this + /// value is the only name the AI chat field has. It went unset from #2097 until #2995 because + /// the one caller that set it compared against a value `makeNSView` had already stored. + @Test("Setting the placeholder names the field for VoiceOver") + func placeholderNamesTheField() { + let textView = ChatComposerNSTextView.make() + textView.placeholder = "Ask about your database…" + #expect(textView.accessibilityPlaceholderValue() as? String == "Ask about your database…") + } + + @Test("A later placeholder replaces the accessible name") + func placeholderChangeUpdatesTheName() { + let textView = ChatComposerNSTextView.make() + textView.placeholder = "first" + textView.placeholder = "second" + #expect(textView.accessibilityPlaceholderValue() as? String == "second") + } + + @Test("The context menu offers the highlight toggle in the state the preference holds") + func contextMenuCarriesTheToggle() throws { + let textView = ChatComposerNSTextView.make() + var toggled = 0 + textView.onToggleHighlight = { toggled += 1 } + + let event = try #require(NSEvent.mouseEvent( + with: .rightMouseDown, + location: NSPoint(x: 10, y: 10), + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + )) + + textView.highlightEnabled = true + let onMenu = try #require(textView.menu(for: event)) + let onItem = try #require(onMenu.items.last) + #expect(onItem.state == .on) + + textView.highlightEnabled = false + let offMenu = try #require(textView.menu(for: event)) + let offItem = try #require(offMenu.items.last) + #expect(offItem.state == .off) + + let action = try #require(offItem.action) + _ = offItem.target as AnyObject? + NSApp.sendAction(action, to: offItem.target, from: offItem) + #expect(toggled == 1) + } } diff --git a/TableProTests/Views/AIChat/ComposerHighlightPreferenceTests.swift b/TableProTests/Views/AIChat/ComposerHighlightPreferenceTests.swift new file mode 100644 index 0000000000..a21a2b6fed --- /dev/null +++ b/TableProTests/Views/AIChat/ComposerHighlightPreferenceTests.swift @@ -0,0 +1,86 @@ +// +// ComposerHighlightPreferenceTests.swift +// TableProTests +// +// The composer draws one focus affordance, never two and never none. The highlight is a wide +// translucent colour wash, so the two system settings that mean "not this" withdraw it, and the +// system focus ring has to take over whenever it does, otherwise switching the highlight off +// leaves a field with nothing at all to say it holds the keyboard. +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@Suite("Composer highlight preference") +struct ComposerHighlightPreferenceTests { + @Test("The highlight paints when it is on and no system setting overrides it") + func paintsWhenEnabled() { + #expect(ComposerHighlightPreference.paintsHighlight( + enabled: true, + reduceTransparency: false, + contrast: .standard + )) + } + + @Test("Switching it off withdraws the highlight") + func disabledNeverPaints() { + #expect(!ComposerHighlightPreference.paintsHighlight( + enabled: false, + reduceTransparency: false, + contrast: .standard + )) + } + + @Test("Reduce Transparency and Increase Contrast each withdraw it while it is still on") + func systemSettingsOverrideEnabled() { + #expect(!ComposerHighlightPreference.paintsHighlight( + enabled: true, + reduceTransparency: true, + contrast: .standard + )) + #expect(!ComposerHighlightPreference.paintsHighlight( + enabled: true, + reduceTransparency: false, + contrast: .increased + )) + } + + @Test("Neither system setting turns the highlight back on") + func systemSettingsNeverEnable() { + for reduceTransparency in [true, false] { + for contrast in [ColorSchemeContrast.standard, .increased] { + #expect(!ComposerHighlightPreference.paintsHighlight( + enabled: false, + reduceTransparency: reduceTransparency, + contrast: contrast + )) + } + } + } + + @Test("The two affordances are exclusive, and one of them is always present") + func exactlyOneAffordance() { + #expect(ComposerHighlightPreference.focusRingType(paintsHighlight: true) == .none) + #expect(ComposerHighlightPreference.focusRingType(paintsHighlight: false) == .exterior) + } + + @Test("The preference and the ring agree for every combination") + func ringFollowsThePreference() { + for enabled in [true, false] { + for reduceTransparency in [true, false] { + for contrast in [ColorSchemeContrast.standard, .increased] { + let paints = ComposerHighlightPreference.paintsHighlight( + enabled: enabled, + reduceTransparency: reduceTransparency, + contrast: contrast + ) + let ring = ComposerHighlightPreference.focusRingType(paintsHighlight: paints) + #expect((ring == .none) == paints) + #expect((ring == .exterior) == !paints) + } + } + } + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 858e29d242..2a7d378f44 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -62,6 +62,8 @@ Conversations save themselves and take their title from your first message. The Paste or drag images into the composer on any provider that takes them, which is all of them except GitHub Copilot, Cursor, ChatGPT, and Claude Agent. +The focused composer carries a colored highlight. Right-click it and turn **Highlight When Focused** off for the standard macOS focus ring instead. The choice is app-wide and survives restarts. Reduce Transparency and Increase Contrast, both under **System Settings > Accessibility > Display**, also replace the highlight with the focus ring. + ### Chat modes The mode picker in the composer footer controls which tools the AI can call. It is an app-level setting that survives restarts, and a fresh install starts in **Ask**.