From d397125813f741721285bba0b3d8036877adbd8e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 04:39:57 -0700 Subject: [PATCH 01/10] fix: Pack Detail parity with Rules for Auto Shift + layer-preset packs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of all 10 shipped packs showed two gaps where Pack Detail fell through to the generic bindings-list while the Rules tab showed a rich embedded editor: * Auto Shift Symbols — Rules shows AutoShiftCollectionView (per-key toggles + timing slider + fast-typing protection). Pack Detail was showing the pack's illustrative binding samples instead. * Symbol Layer — Rules shows LayerPresetPickerContent (Mirrored / alternatives preset picker). Pack Detail was showing 9 hardcoded Mirrored samples instead. Pack Detail now dispatches both configurations to the same views Rules uses, with live wire-through: edits install the pack on first touch (skipFinalReload), then call the VM method (updateAutoShiftSymbolsConfig or updateCollectionLayerPreset) to persist. Seeds local editor state from the live collection on refreshInstallState so opening Pack Detail after a Rules-tab edit shows the current selection rather than catalog defaults. The other 9 packs already had parity — verified against the dispatch in RulesSummaryView+CollectionRow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UI/Gallery/PackDetailView.swift | 195 ++++++++++++++---- 1 file changed, 151 insertions(+), 44 deletions(-) diff --git a/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift b/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift index b897c0c5..cc801960 100644 --- a/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift +++ b/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift @@ -45,6 +45,12 @@ struct PackDetailView: View { /// to a persisted collection config is follow-up work. @State private var homeRowModsConfig: HomeRowModsConfig = .init() + /// Local state for the embedded Auto Shift Symbols editor. + @State private var autoShiftConfig: AutoShiftSymbolsConfig = .init() + + /// Currently-selected layer-preset id (for the Symbol/Fun packs). + @State private var selectedLayerPresetId: String? + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -422,12 +428,83 @@ struct PackDetailView: View { .opacity(isInstalled ? 1.0 : 0.55) .animation(.easeInOut(duration: 0.2), value: isInstalled) } + } else if let autoShiftCollection = associatedAutoShiftCollection { + // Auto Shift Symbols: enabled-keys toggles + timing slider + fast- + // typing protection. Same view the Rules tab uses. + VStack(alignment: .leading, spacing: 0) { + Divider() + AutoShiftCollectionView( + config: autoShiftConfig, + onConfigChanged: { newConfig in + Task { await applyAutoShiftEdit(newConfig, collectionID: autoShiftCollection.id) } + } + ) + .id(autoShiftCollection.id) // re-mount when live config changes + .opacity(isInstalled ? 1.0 : 0.55) + .animation(.easeInOut(duration: 0.2), value: isInstalled) + } + } else if let layerPresetCollection = associatedLayerPresetCollection { + // Layer preset picker — Symbol/Fun layers. Users choose a preset + // (e.g. Mirrored, Alphabetical) that redefines the layer's + // mappings. Reuses the Rules-tab picker directly. + VStack(alignment: .leading, spacing: 0) { + Divider() + LayerPresetPickerContent( + collection: layerPresetCollection, + onSelectPreset: { presetId in + selectedLayerPresetId = presetId + Task { await applyLayerPresetEdit(presetId: presetId, collectionID: layerPresetCollection.id) } + } + ) + .id(selectedLayerPresetId ?? "") + .opacity(isInstalled ? 1.0 : 0.55) + .animation(.easeInOut(duration: 0.2), value: isInstalled) + } + } else if let collection = liveAssociatedCollection, + collection.id == RuleCollectionIdentifier.windowSnapping { + // Window Snapping has a custom visual editor in Rules — convention + // picker + monitor canvas + floating action cards. Pack Detail + // embeds the same view so the experience is identical. + VStack(alignment: .leading, spacing: 0) { + Divider() + if let hint = collection.activationHint, !hint.isEmpty { + Text(hint) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.top, 8) + } + WindowSnappingView( + mappings: collection.mappings, + convention: collection.windowKeyConvention ?? .standard, + onConventionChange: { convention in + Task { await applyWindowConventionEdit(convention, collectionID: collection.id) } + } + ) + .opacity(isInstalled ? 1.0 : 0.55) + .animation(.easeInOut(duration: 0.2), value: isInstalled) + } } else if pack.bindings.isEmpty, let collection = liveAssociatedCollection, !collection.mappings.isEmpty { - // Collection-backed pack with no explicit bindings — e.g. Vim - // Navigation. Render the collection's mapping table so there's - // a single source of truth if the collection changes upstream. - collectionMappingsBlock(collection) + // Collection-backed pack with no explicit bindings and a plain + // `.table` config (Vim Navigation, Mission Control, Numpad). + // Uses the same MappingTableContent view Rules uses so the + // table styling matches exactly. + VStack(alignment: .leading, spacing: 8) { + Divider() + if let hint = collection.activationHint, !hint.isEmpty { + Text(hint) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + MappingTableContent( + mappings: collection.mappings.map { + ($0.input, $0.output, $0.shiftedOutput, $0.ctrlOutput, + $0.description, $0.sectionBreak, isInstalled, $0.id, nil) + } + ) + .opacity(isInstalled ? 1.0 : 0.6) + .animation(.easeInOut(duration: 0.2), value: isInstalled) + } } else { VStack(alignment: .leading, spacing: 8) { Divider() @@ -463,6 +540,24 @@ struct PackDetailView: View { return collection } + /// Collection backing an Auto Shift-style pack (enabled-keys toggles + + /// timing slider). Matches the `.autoShiftSymbols` configuration case. + private var associatedAutoShiftCollection: RuleCollection? { + guard let collection = liveAssociatedCollection, + case .autoShiftSymbols = collection.configuration + else { return nil } + return collection + } + + /// Collection backing a layer-preset pack (Symbol, Fun). Matches the + /// `.layerPresetPicker` configuration case. + private var associatedLayerPresetCollection: RuleCollection? { + guard let collection = liveAssociatedCollection, + case .layerPresetPicker = collection.configuration + else { return nil } + return collection + } + /// True if this pack's bindings line up with the Home Row Mods /// collection's input set — in which case we render that collection's /// interactive keyboard + modifier controls instead of the generic @@ -531,46 +626,6 @@ struct PackDetailView: View { .padding(.vertical, 2) } - /// Read-only mapping table for collection-backed packs whose behavior is - /// a fixed table rather than a user-tuned picker (e.g. Vim Navigation). - /// Sourced directly from the collection so there's no risk of drift. - @ViewBuilder - private func collectionMappingsBlock(_ collection: RuleCollection) -> some View { - VStack(alignment: .leading, spacing: 8) { - Divider() - if let hint = collection.activationHint, !hint.isEmpty { - Text(hint) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.bottom, 2) - } - Text("What this will change") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - // Compact two-column layout — keycap on the left, description on - // the right. Keeps Pack Detail scannable even when the collection - // has ~15-20 mappings. - LazyVGrid( - columns: [ - GridItem(.fixed(72), alignment: .leading), - GridItem(.flexible(), alignment: .leading) - ], - alignment: .leading, - spacing: 4 - ) { - ForEach(collection.mappings) { mapping in - KeyCapChip(text: RuleBehaviorSummaryView.formatKeyForBehavior(mapping.input)) - Text(mapping.description ?? mapping.output) - .font(.system(size: 12)) - .foregroundStyle(.primary) - .lineLimit(2) - } - } - .opacity(isInstalled ? 1.0 : 0.6) - .animation(.easeInOut(duration: 0.2), value: isInstalled) - } - } - // MARK: - Toast overlay @ViewBuilder @@ -687,9 +742,29 @@ struct PackDetailView: View { if let liveHomeRow { homeRowModsConfig = liveHomeRow } + if let liveAutoShift = liveAutoShiftConfig() { + autoShiftConfig = liveAutoShift + } + if let livePreset = liveLayerPresetId() { + selectedLayerPresetId = livePreset + } } } + private func liveAutoShiftConfig() -> AutoShiftSymbolsConfig? { + guard let collection = associatedAutoShiftCollection, + case let .autoShiftSymbols(cfg) = collection.configuration + else { return nil } + return cfg + } + + private func liveLayerPresetId() -> String? { + guard let collection = associatedLayerPresetCollection, + case let .layerPresetPicker(cfg) = collection.configuration + else { return nil } + return cfg.selectedPresetId + } + private func liveSingleKeySelection() async -> String? { guard let collectionID = pack.associatedCollectionID else { return nil } let collections = await kanataManager.underlyingManager @@ -720,6 +795,38 @@ struct PackDetailView: View { ) } + /// Mirror of `applyHomeRowEdit` for Auto Shift Symbols. + private func applyAutoShiftEdit(_ newConfig: AutoShiftSymbolsConfig, collectionID: UUID) async { + autoShiftConfig = newConfig + if !isInstalled { + await install(skipFinalReload: true) + } + await kanataManager.updateAutoShiftSymbolsConfig( + collectionId: collectionID, + config: newConfig + ) + } + + /// Mirror of `applyHomeRowEdit` for Window Snapping's convention picker + /// (Standard L/R/U/I/J/K vs Vim H/L/Y/U/B/N). + private func applyWindowConventionEdit(_ convention: WindowKeyConvention, collectionID: UUID) async { + if !isInstalled { + await install(skipFinalReload: true) + } + await kanataManager.updateWindowKeyConvention(collectionID, convention: convention) + } + + /// Mirror of `applyHomeRowEdit` for layer-preset packs (Symbol, Fun). + /// Installs the pack on first touch, then switches the collection's + /// selected preset so the generated kanata config rebinds the layer. + private func applyLayerPresetEdit(presetId: String, collectionID: UUID) async { + selectedLayerPresetId = presetId + if !isInstalled { + await install(skipFinalReload: true) + } + await kanataManager.updateCollectionLayerPreset(collectionID, presetId: presetId) + } + /// Supplies the same layer list Rules uses when rendering the Home Row /// Mods editor, so hold-to-layer bindings resolve correctly inside /// Pack Detail. From 833a0319edee7da64d27bb40347d62cacc005d68 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 05:02:41 -0700 Subject: [PATCH 02/10] fix: InsetBackPlane wrapping + HRM help button for full Rules parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two remaining UI gaps from the pack-detail parity audit: * Every embedded editor now wraps in `InsetBackPlane` with the same padding Rules applies (`.padding(.top, 8).padding(.bottom, 12) .padding(.horizontal, 16)`; window snapping uses 12 to match). Packs visually match their Rules-tab counterparts — subtle inner shadow, consistent insets — instead of plain `VStack` + `Divider`. * Home Row Mods pack now shows a `?` help button next to the title that opens the same `MarkdownHelpSheet(resource: "home-row-mods")` Rules uses. Wired via a `helpResourceName` computed property so other packs can opt in later just by naming their markdown. Extracted `embeddedEditor` helper to keep the bindingsBlock dispatch readable now that every case wraps in the same chrome. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UI/Gallery/PackDetailView.swift | 129 +++++++++++------- 1 file changed, 79 insertions(+), 50 deletions(-) diff --git a/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift b/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift index cc801960..6b00074d 100644 --- a/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift +++ b/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift @@ -51,6 +51,10 @@ struct PackDetailView: View { /// Currently-selected layer-preset id (for the Symbol/Fun packs). @State private var selectedLayerPresetId: String? + /// Help sheet presentation (Home Row Mods only — mirrors the Rules tab's + /// `?` button that opens the HRM markdown help). + @State private var showingHomeRowModsHelp = false + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -72,6 +76,9 @@ struct PackDetailView: View { loadDefaultQuickSettings() } .overlay(toastOverlay, alignment: .bottom) + .sheet(isPresented: $showingHomeRowModsHelp) { + MarkdownHelpSheet(resource: "home-row-mods", title: "Home Row Mods") + } } // MARK: - Header @@ -116,8 +123,28 @@ struct PackDetailView: View { HStack(alignment: .center, spacing: 16) { heroIcon VStack(alignment: .leading, spacing: 4) { - Text(pack.name) - .font(.system(size: 20, weight: .semibold)) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(pack.name) + .font(.system(size: 20, weight: .semibold)) + // Help button — mirrors the `?` on the Rules tab's + // Home Row Mods row. Only present for packs that + // have curated help markdown. + if helpResourceName != nil { + Button { + if helpResourceName != nil { + showingHomeRowModsHelp = true + } + } label: { + Image(systemName: "questionmark.circle") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .focusable(false) + .accessibilityLabel("\(pack.name) help") + .accessibilityIdentifier("pack-detail-help") + } + } Text(pack.tagline) .font(.system(size: 13)) .foregroundStyle(.secondary) @@ -352,6 +379,24 @@ struct PackDetailView: View { // MARK: - Binding list + /// Wraps embedded editors in `InsetBackPlane` with the same padding the + /// Rules tab uses, so Pack Detail editors visually match their Rules + /// counterparts (subtle inner-shadow container, consistent insets). + @ViewBuilder + private func embeddedEditor( + horizontalPadding: CGFloat = 16, + @ViewBuilder content: () -> Content + ) -> some View { + InsetBackPlane { + content() + .padding(.top, 8) + .padding(.bottom, 12) + .padding(.horizontal, horizontalPadding) + } + .opacity(isInstalled ? 1.0 : 0.55) + .animation(.easeInOut(duration: 0.2), value: isInstalled) + } + @ViewBuilder private var bindingsBlock: some View { if isHomeRowModsPack, let collectionID = pack.associatedCollectionID { @@ -360,8 +405,7 @@ struct PackDetailView: View { // so we embed the same view the Rules tab uses. All layer- // aware callbacks delegate into the same `kanataManager` // methods Rules uses so hold-to-layer bindings work here too. - VStack(alignment: .leading, spacing: 0) { - Divider() + embeddedEditor { HomeRowModsCollectionView( config: $homeRowModsConfig, availableLayers: availableHomeRowLayers(), @@ -377,22 +421,17 @@ struct PackDetailView: View { await kanataManager.batchEnableCollections(collectionIds) } ) - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) } } else if let singleKeyCollection = associatedSingleKeyCollection { // Single-key remap with preset pills (Escape Remap, Delete // Enhancement, Backup Caps Lock). Reuses Rules' view directly. - VStack(alignment: .leading, spacing: 0) { - Divider() + embeddedEditor { SingleKeyPickerContent( collection: singleKeyCollection, onSelectOutput: { output in Task { await applySingleKeyEdit(output: output) } } ) - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) } } else if let pickerConfig = associatedPickerConfig { // Phase 2: for packs that map onto an existing RuleCollection's @@ -404,8 +443,7 @@ struct PackDetailView: View { // pack, the user's click registers in the picker's internal // selection state, and the dim lifts. Phase 3 will persist the // selected preset through to the installed CustomRule. - VStack(alignment: .leading, spacing: 0) { - Divider() + embeddedEditor { TapHoldPickerContent( config: pickerConfig, isEditable: true, @@ -421,34 +459,26 @@ struct PackDetailView: View { // Picker seeds its selection state at init. Re-id whenever // the live selection flips (e.g. on appear, after refresh // from the live rule) so SwiftUI re-creates it with the - // fresh seed. User clicks update our local state, which - // bumps the id, which re-seeds — harmless because the new - // seed matches what the user just clicked. + // fresh seed. .id("\(pickerTapSelection ?? "")-\(pickerHoldSelection ?? "")") - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) } } else if let autoShiftCollection = associatedAutoShiftCollection { // Auto Shift Symbols: enabled-keys toggles + timing slider + fast- // typing protection. Same view the Rules tab uses. - VStack(alignment: .leading, spacing: 0) { - Divider() + embeddedEditor { AutoShiftCollectionView( config: autoShiftConfig, onConfigChanged: { newConfig in Task { await applyAutoShiftEdit(newConfig, collectionID: autoShiftCollection.id) } } ) - .id(autoShiftCollection.id) // re-mount when live config changes - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) + .id(autoShiftCollection.id) } } else if let layerPresetCollection = associatedLayerPresetCollection { // Layer preset picker — Symbol/Fun layers. Users choose a preset // (e.g. Mirrored, Alphabetical) that redefines the layer's // mappings. Reuses the Rules-tab picker directly. - VStack(alignment: .leading, spacing: 0) { - Divider() + embeddedEditor { LayerPresetPickerContent( collection: layerPresetCollection, onSelectPreset: { presetId in @@ -457,22 +487,14 @@ struct PackDetailView: View { } ) .id(selectedLayerPresetId ?? "") - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) } } else if let collection = liveAssociatedCollection, collection.id == RuleCollectionIdentifier.windowSnapping { // Window Snapping has a custom visual editor in Rules — convention // picker + monitor canvas + floating action cards. Pack Detail // embeds the same view so the experience is identical. - VStack(alignment: .leading, spacing: 0) { - Divider() - if let hint = collection.activationHint, !hint.isEmpty { - Text(hint) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.top, 8) - } + // Rules uses horizontal padding of 12 for this particular editor. + embeddedEditor(horizontalPadding: 12) { WindowSnappingView( mappings: collection.mappings, convention: collection.windowKeyConvention ?? .standard, @@ -480,8 +502,6 @@ struct PackDetailView: View { Task { await applyWindowConventionEdit(convention, collectionID: collection.id) } } ) - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) } } else if pack.bindings.isEmpty, let collection = liveAssociatedCollection, !collection.mappings.isEmpty { @@ -489,21 +509,20 @@ struct PackDetailView: View { // `.table` config (Vim Navigation, Mission Control, Numpad). // Uses the same MappingTableContent view Rules uses so the // table styling matches exactly. - VStack(alignment: .leading, spacing: 8) { - Divider() - if let hint = collection.activationHint, !hint.isEmpty { - Text(hint) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } - MappingTableContent( - mappings: collection.mappings.map { - ($0.input, $0.output, $0.shiftedOutput, $0.ctrlOutput, - $0.description, $0.sectionBreak, isInstalled, $0.id, nil) + embeddedEditor { + VStack(alignment: .leading, spacing: 8) { + if let hint = collection.activationHint, !hint.isEmpty { + Text(hint) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) } - ) - .opacity(isInstalled ? 1.0 : 0.6) - .animation(.easeInOut(duration: 0.2), value: isInstalled) + MappingTableContent( + mappings: collection.mappings.map { + ($0.input, $0.output, $0.shiftedOutput, $0.ctrlOutput, + $0.description, $0.sectionBreak, isInstalled, $0.id, nil) + } + ) + } } } else { VStack(alignment: .leading, spacing: 8) { @@ -518,6 +537,16 @@ struct PackDetailView: View { } } + /// Help markdown resource name for packs that have curated help content. + /// Returns nil for packs without — matches the Rules tab, where only + /// Home Row Mods surfaces a `?` button today. + private var helpResourceName: String? { + switch pack.associatedCollectionID { + case RuleCollectionIdentifier.homeRowMods: "home-row-mods" + default: nil + } + } + /// Live `RuleCollection` this pack is associated with. Used to hand /// to Rules' view components (they take the whole collection so they /// can render the user's latest selections, not catalog defaults). From 839e6a508229953ae3e0447f7c116f7da9942598 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 05:10:05 -0700 Subject: [PATCH 03/10] fix: don't toast cooldown-blocked reloads; defer-retry instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the Window Snapping convention (or any collection-config tweak) right after toggling a pack on was hitting the 3s TCP reload cooldown and surfacing "Reload delayed: Reload blocked" as a user-facing error toast + error sound. The user's edit was persisted to the config file but never reached kanata until the next unrelated reload — classic "why didn't my change apply" bug. Two fixes in `ConfigReloadCoordinator.triggerTCPReload`: * Cooldown blocks now skip the `configReloadFailed` notification. They are an intentional throttle, not a failure worth surfacing. * When blocked by cooldown, schedule a single deferred retry for ~3.2s later. Rapid successive edits coalesce into one final reload via a tracked `deferredReloadTask` (cancelled on each new block). The config write already landed on disk; this just propagates it to kanata. Real reload failures (validation errors, actual network issues) still notify and toast as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Managers/ConfigReloadCoordinator.swift | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 8e8f3376..d2277cb4 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -110,23 +110,58 @@ final class ConfigReloadCoordinator { AppLogger.shared.debug( "📡 [Reload] TCP reload failed: \(tcpResult.errorMessage ?? "Unknown error")" ) - NotificationCenter.default.post( - name: .configReloadFailed, - object: nil, - userInfo: [ - "message": tcpResult.errorMessage ?? "TCP reload failed", - "response": tcpResult.response ?? "", - ] - ) + let errorMessage = tcpResult.errorMessage ?? "TCP reload failed" + // Cooldown blocks are a deliberate throttle, not a real failure. + // Schedule a deferred retry so the write we just persisted + // actually reaches kanata, and suppress the user-facing toast/ + // error sound — the next reload attempt will fire when cooldown + // expires. Real failures (validation, network, etc.) still + // notify as before. + if isCooldownBlockMessage(errorMessage) { + scheduleDeferredReloadAfterCooldown() + } else { + NotificationCenter.default.post( + name: .configReloadFailed, + object: nil, + userInfo: [ + "message": errorMessage, + "response": tcpResult.response ?? "", + ] + ) + } return ReloadResult( success: false, response: tcpResult.response, - errorMessage: tcpResult.errorMessage ?? "TCP reload failed", + errorMessage: errorMessage, protocol: .tcp ) } } + /// True if the error came from the 3s reload-cooldown throttle rather + /// than a real failure. + private func isCooldownBlockMessage(_ message: String) -> Bool { + message.contains("Reload blocked") && message.contains("cooldown") + } + + /// When a reload is blocked by the cooldown, we still want it to actually + /// happen so the config file we just wrote reaches kanata. Schedule a + /// deferred retry once the cooldown expires; de-duped via a single + /// outstanding task so rapid edits coalesce into one final reload. + private var deferredReloadTask: Task? + + private func scheduleDeferredReloadAfterCooldown() { + deferredReloadTask?.cancel() + deferredReloadTask = Task { [weak self] in + // 3s cooldown + a little slop so the safety check passes. + try? await Task.sleep(nanoseconds: 3_200_000_000) + guard let self, !Task.isCancelled else { return } + AppLogger.shared.log("🔁 [Reload] Firing deferred reload after cooldown expiry") + await self.triggerReload() + self.deferredReloadTask = nil + } + } + /// TCP-based config reload (no authentication required - see ADR-013) func triggerTCPReload() async -> TCPReloadResult { if TestEnvironment.isRunningTests { From 2311b6f68a2a4e0448a5af5e06e27d85a6932748 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 05:17:41 -0700 Subject: [PATCH 04/10] feat: hide overlay while Gallery is open, restore on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live keyboard overlay was floating on top of the Gallery window because the two don't know about each other. Mirrors the pattern Settings already uses — `autoHideOnceForSettings` stashes pre-open visibility and `resetSettingsAutoHideGuard` restores it. `GalleryWindowController.showWindow` now: * Calls `autoHideOnceForSettings` after creating the NSWindow. * Attaches a `willCloseNotification` observer scoped to that window, which fires `resetSettingsAutoHideGuard` and cleans itself up. No changes needed on the overlay controller side — reusing existing hooks so Settings, Wizard, and Gallery all share the same pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UI/Gallery/GalleryWindowController.swift | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift b/Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift index f3829939..dc474c81 100644 --- a/Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift +++ b/Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift @@ -5,12 +5,16 @@ import AppKit import SwiftUI @MainActor -final class GalleryWindowController { +final class GalleryWindowController: NSObject { static let shared = GalleryWindowController() private var window: NSWindow? + private var willCloseObserver: NSObjectProtocol? - /// Open (or focus if already open) the Gallery window. + /// Open (or focus if already open) the Gallery window. Hides the live + /// keyboard overlay while the Gallery is up (same pattern Settings uses + /// via `autoHideOnceForSettings`) and restores it on close, so the + /// overlay doesn't hover on top of the Gallery sheet. /// - Parameter kanataManager: the env object the content view reads. func showWindow(kanataManager: KanataViewModel) { if let existingWindow = window, existingWindow.isVisible { @@ -36,12 +40,34 @@ final class GalleryWindowController { if !newWindow.setFrameUsingName("KeyPathGalleryWindow") { newWindow.center() } - newWindow.makeKeyAndOrderFront(nil) + // Hide the overlay so it doesn't float on top of the Gallery window. + // Reuses the same API Settings uses — the controller remembers + // pre-hide visibility and restores it when we reset the guard. + LiveKeyboardOverlayController.shared.autoHideOnceForSettings() + + // Observe this window's willClose so we can restore the overlay + // when the user dismisses the Gallery (either clicking the ✕ or + // Cmd+W). Scoped to this window so we don't fire on unrelated closes. + willCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: newWindow, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + LiveKeyboardOverlayController.shared.resetSettingsAutoHideGuard() + self?.willCloseObserver.map(NotificationCenter.default.removeObserver) + self?.willCloseObserver = nil + self?.window = nil + } + } + + newWindow.makeKeyAndOrderFront(nil) self.window = newWindow } /// Close the Gallery window if it's open. Does nothing otherwise. + /// Overlay restoration happens via the willClose observer. func closeWindow() { window?.close() } From 9a62b8d70fcae0c3d8dc2b2bee7b6c41be26a15c Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 05:23:10 -0700 Subject: [PATCH 05/10] fix: render space-separated chord inputs as modifier symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mission Control's pack detail (and the matching Rules-tab row) was rendering its chord inputs as raw kanata tokens stacked four-wide — \`LCTL LMET LALT UP\` overflowed the Key column and crashed into the Description column, making both unreadable. Updates `MappingTableContent.formatKeyForDisplay` to detect space- separated inputs (defchordsv2 form) and format each token through the same `macModifiers` map the single-key path uses, joined with no separator. \`Lctl Lmet Lalt Up\` now renders as \`⌃⌘⌥↑\`, matching the Action column's visual vocabulary and fitting comfortably in the Key column. Affects both Rules and Gallery Pack Detail (they share the same view), so the Mission Control row in Rules gets the same cleanup for free. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rules/RulesSummaryView+MappingTable.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView+MappingTable.swift b/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView+MappingTable.swift index 1bacdbd0..d0518311 100644 --- a/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView+MappingTable.swift +++ b/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView+MappingTable.swift @@ -199,6 +199,23 @@ struct MappingTableContent: View { return macName } + // Space-separated chord input (kanata's defchordsv2 form) — + // e.g. "Lctl Lmet Lalt Up" → "⌃⌘⌥↑". Each token is formatted via + // the macModifiers map or arrow symbols; we join with no separator + // so the visual mirrors the Action column. + if key.contains(" ") { + let tokens = key.split(separator: " ") + let formatted = tokens.map { token -> String in + let t = String(token) + if let macName = macModifiers[t] { + // Take just the leading symbol (e.g. "⌘" from "⌘ Cmd"). + return macName.components(separatedBy: " ").first ?? macName + } + return formatModifierPrefixNotation(t, macModifiers: macModifiers) + } + return formatted.joined() + } + // Handle modifier prefix notation (e.g., "C-M-A-up" -> "⌃⌘⌥↑") return formatModifierPrefixNotation(key, macModifiers: macModifiers) } From 5727a7ad51f970d032adccac46024ff048a60177 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 05:50:37 -0700 Subject: [PATCH 06/10] redesign: Mission Control uses Leader + single key, fixes modifier-in-multi bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original Mission Control pack was ergonomically backward — it asked users to press a 4-key chord (Ctrl+Cmd+Option+arrow) to trigger macOS's own 2-key Ctrl+arrow shortcut. That's not a convenience, that's more work than pressing F3. Redesign to match the rest of the Gallery: hold Space (nav layer), then a single letter fires the action. Keys chosen to not collide with Vim Navigation's nav-layer bindings: * m → Mission Control * e → App Exposé * b → Show Desktop * c → Notification Center * [ / ] → Previous / Next Desktop The collection targets the nav layer and provides its own Space activator (dedup'd if Vim Navigation is also on). While implementing, found a pre-existing runtime bug: kanata's `(multi (release-layer X) (push-msg ...))` silently swallows the modifier-chord emission. Fix applied in two layers: 1. `convertSingleKeyToForkFormat` was matching against the key-name values (`"lctl"`) instead of the notation prefixes (`"C-"`), so it never expanded anything. Fixed to match prefixes. 2. `wrapWithOneShotExit` now expands modifier-prefixed outputs via the fixed helper before placing them inside the multi wrap. Plain keys and S-expressions pass through untouched. This bug affected every collection whose outputs used `C-`, `M-`, `A-` or `S-` prefixes inside the nav/layer one-shot wrap — potentially silently broken for users of Vim Navigation's modifier-combo mappings (h→M-left, g→M-up, etc.) and any other pack that hit this path. Covered by the Mission Control runtime behavior test now that `space+m` → emits lctl+up correctly. Both Rules and Gallery share the same RuleCollection, so this redesign updates the Mission Control row in both places. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../KanataConfiguration+BlockBuilders.swift | 37 ++++++++++++++++--- .../Services/Packs/PackRegistry.swift | 14 +++---- .../RuleCollectionCatalog.swift | 36 ++++++++++++------ .../Config/PackRuntimeBehaviorTests.swift | 23 +++++------- 4 files changed, 71 insertions(+), 39 deletions(-) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift index 3b5b7586..1cb3a67a 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift @@ -135,9 +135,31 @@ extension KanataConfiguration { guard !hasLayerBasePush else { return output } if activatorKeysBySourceLayer[layer]?.contains(sourceKey) == true { return output } // Use release-layer to explicitly release the layer-while-held, then output, then notify UI. - // Modifier chords (M-v, M-S-z) work natively inside multi actions thanks to the - // keyberon multi_depth fix — no expansion needed. - return "(multi (release-layer \(layer.kanataName)) \(output) (push-msg \"layer:base\"))" + // `(release-layer ...)` inside a `multi` silently swallows subsequent + // modifier-prefixed tokens (e.g. C-up, M-left) in kanata's runtime, + // so we expand them to their explicit multi form first + // (`(multi lctl up)`). Plain key outputs and already-S-expression + // outputs pass through unchanged. + let expandedOutput = expandOutputForMultiWrapping(output) + return "(multi (release-layer \(layer.kanataName)) \(expandedOutput) (push-msg \"layer:base\"))" + } + + /// When a modifier-prefixed output like `C-up` or `M-S-z` needs to be + /// placed inside a `(multi ...)` action that also contains + /// `(release-layer ...)`, kanata drops the key emission on its floor. + /// Expand to the explicit `(multi mod1 mod2 key)` form in that case. + /// Leaves non-prefix outputs (`down`, `f11`, `(push-msg ...)`) alone. + func expandOutputForMultiWrapping(_ output: String) -> String { + let trimmed = output.trimmingCharacters(in: .whitespaces) + // Already an S-expression — pass through. + if trimmed.hasPrefix("("), trimmed.hasSuffix(")") { return trimmed } + // No modifier prefix — plain key. + let modifierPrefixes = ["C-", "M-", "A-", "S-", "RA-", "RM-", "RC-", "RS-", "AG-"] + guard modifierPrefixes.contains(where: { trimmed.uppercased().hasPrefix($0) }) else { + return trimmed + } + // Expand to explicit `(multi lctl up)` form. + return Self.convertSingleKeyToForkFormat(trimmed) } // Precompute mapped keys for non-base layers to avoid blocking keys mapped by other collections. @@ -700,9 +722,12 @@ extension KanataConfiguration { var remainingKey = key var modifiers: [String] = [] - // Extract all modifier prefixes (only match first one, case-insensitive) - let lowercasedKey = remainingKey.lowercased() - if let (prefix, modKey) = modifierMap.first(where: { lowercasedKey.hasPrefix($0.key.lowercased()) }) { + // Extract modifier prefixes (e.g. "C-", "M-", "C-S-"). Note: these + // are *prefixes* (the notation markers), not the lowered-case key + // names. A previous implementation mistakenly matched against the + // key-name values and therefore never expanded anything. + let uppercasedKey = remainingKey.uppercased() + if let (prefix, modKey) = modifierMap.first(where: { uppercasedKey.hasPrefix($0.prefix) }) { modifiers.append(contentsOf: modKey.split(separator: " ").map(String.init)) remainingKey = String(remainingKey.dropFirst(prefix.count)) } diff --git a/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift b/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift index 92b4bc33..f689e90a 100644 --- a/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift +++ b/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift @@ -250,19 +250,17 @@ public enum PackRegistry { // MARK: - Pack 8: Mission Control - /// Collection-backed pack over `missionControl`. Three-modifier chords - /// (lctl + lmet + lalt + {up,down,left,right,d,n}) fire the system's - /// Mission Control, Exposé, Desktop, and Notification Center actions — - /// without fighting the physical F3 or binding a new shortcut from - /// scratch. Renders via the generic collection-mappings fallback in - /// Pack Detail. + /// Collection-backed pack over `missionControl`. Leader (Space) held, + /// then a single letter fires the system's Mission Control, Exposé, + /// Desktop, and Notification Center actions. One-hand, no reach — + /// consistent with the rest of the Gallery's Leader-based packs. public static let missionControl = Pack( id: "com.keypath.pack.mission-control", version: "1.0.0", name: "Mission Control", - tagline: "Shortcuts for Exposé, Desktop, Notification Center", + tagline: "Leader + single key for Exposé, Desktops, Notifications", shortDescription: - "Triple-chord shortcuts (Ctrl + Cmd + Option + a direction) for Mission Control, App Exposé, Desktop switching, Show Desktop, and Notification Center. Avoids the F3 muscle memory, no reach.", + "Hold Space, then: M = Mission Control, E = App Exposé, B = Show Desktop, C = Notification Center, [ / ] for previous / next Desktop. One-hand shortcuts, no reach for F3.", longDescription: "", category: "Productivity", iconSymbol: "rectangle.3.group", diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift index fd6cf763..c8868c69 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift @@ -325,24 +325,38 @@ struct RuleCollectionCatalog { RuleCollection( id: RuleCollectionIdentifier.missionControl, name: "Mission Control", - summary: "Quick access to Mission Control, App Exposé, and Desktop switching.", + summary: "Leader → single key: Mission Control, Exposé, Desktops, Notification Center.", category: .navigation, - // Inputs are expressed as kanata chords (space-separated physical - // keys). The generator emits these into defchordsv2; a modifier- - // prefix form like "C-M-A-up" would end up in defsrc, which only - // accepts single physical keys and fails validation. + // Maps onto uncommon keys inside the navigation layer so users + // can hit Mission Control actions as Leader (Space) + single + // letter — much cheaper than the previous 3-modifier chord form + // and consistent with the rest of the Gallery (Vim Nav, Numpad, + // Window Snapping all share the Leader → key pattern). + // + // Keys chosen to avoid colliding with Vim Navigation's nav-layer + // bindings (h/j/k/l, y/p, g, u, d, n, o, a, r, /, 4, 0, x, .). mappings: [ - KeyMapping(input: "lctl lmet lalt up", output: "C-up", description: "Mission Control"), - KeyMapping(input: "lctl lmet lalt down", output: "C-down", description: "App Exposé"), - KeyMapping(input: "lctl lmet lalt left", output: "C-left", description: "Previous Desktop"), - KeyMapping(input: "lctl lmet lalt right", output: "C-right", description: "Next Desktop"), - KeyMapping(input: "lctl lmet lalt d", output: "f11", description: "Show Desktop"), - KeyMapping(input: "lctl lmet lalt n", output: "C-S-n", description: "Notification Center") + KeyMapping(input: "m", output: "C-up", description: "Mission Control"), + KeyMapping(input: "e", output: "C-down", description: "App Exposé"), + KeyMapping(input: "[", output: "C-left", description: "Previous Desktop"), + KeyMapping(input: "]", output: "C-right", description: "Next Desktop"), + KeyMapping(input: "b", output: "f11", description: "Show Desktop"), + KeyMapping(input: "c", output: "C-S-n", description: "Notification Center") ], isEnabled: false, isSystemDefault: false, icon: "rectangle.3.group", tags: ["mission control", "spaces", "desktop"], + targetLayer: .navigation, + // Piggybacks on the nav layer — the user needs a collection that + // activates nav (Vim Navigation or Leader Key). Providing our + // own Space activator keeps the pack standalone-usable; the + // generator's dedupe coalesces duplicate activators. + momentaryActivator: MomentaryActivator( + input: "space", + targetLayer: .navigation + ), + activationHint: "Leader → single key", configuration: .table ) } diff --git a/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift b/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift index 1d61a56d..0ce11015 100644 --- a/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift +++ b/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift @@ -93,25 +93,20 @@ final class PackRuntimeBehaviorTests: XCTestCase { "Holding space + w should put us in the 'window' layer for window-snapping actions") } - /// Mission Control maps 3-modifier chords (lctl + lmet + lalt + direction - /// or letter) to plain key combos macOS already interprets — e.g. the - /// `lctl + lmet + lalt + up` chord emits `C-up`, which is macOS's own - /// Mission Control shortcut. No AX or push-msg indirection, so the - /// simulator observes the actual output. - func testMissionControlChordEmitsCtrlUp() throws { + /// Mission Control: Leader (Space) + single key. Holding Space for the + /// nav layer, then tapping `m` should emit `C-up` (macOS's Mission + /// Control shortcut → we see `up` in the simulator output as part of + /// lctl+up). Replaced the earlier 3-modifier-chord design which was + /// harder to press than the F3 it claimed to improve on. + func testMissionControlLeaderMEmitsCtrlUp() throws { let events = try simulate( collectionIDs: [RuleCollectionIdentifier.missionControl], - // Press all 3 modifiers together with up inside the chord - // window, release in reverse. Timings stay under defchordsv2's - // chord window. - script: "↓lctl 🕐5 ↓lmet 🕐5 ↓lalt 🕐5 ↓up 🕐50 ↑up 🕐20 ↑lalt 🕐5 ↑lmet 🕐5 ↑lctl 🕐50" + // Hold Space (→ nav layer), tap `m`, release. + script: "↓spc 🕐300 ↓m 🕐30 ↑m 🕐30 ↑spc 🕐30" ) let output = outputKeys(events) - // The chord resolves to `C-up` which emits lctl+up. If the chord - // hadn't fired, we'd still see lctl but not a clean `up` output - // (up would still be suppressed by defsrc's chord input claim). XCTAssertTrue(output.contains("up"), - "Mission Control chord should emit up (as part of C-up); got: \(output)") + "Leader + m should emit C-up (lctl+up) for Mission Control; got: \(output)") } /// Auto Shift Symbols makes symbol keys dual-role: quick tap emits the From fdfea76c2ed545c8f45c1edfd6349fa359a8435b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 06:20:10 -0700 Subject: [PATCH 07/10] feat: per-app overlay suppression with Figma in default list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Figma is frontmost, holding Space to pan/navigate conflicts with the overlay's hold-Space leader activation. Add a preference for a list of bundle IDs where the live keyboard overlay and the context HUD auto-hide while that app is frontmost, restored when focus moves away. Implementation: * `PreferencesService.overlaySuppressedBundleIDs` (Set), defaults to Figma's two bundle IDs (com.figma.Desktop, com.figma.agent). Posts `.overlaySuppressedBundleIDsChanged` on mutation. * `LiveKeyboardOverlayController.suppressForApp` / `restoreFromAppSuppression` — separate guard from `autoHideOnceForSettings` so Settings and app suppression compose without clobbering each other. * `OverlayAppSuppressor` observes `NSWorkspace.didActivateApplicationNotification` and the preference-change notification, calling suppress/restore. Started from `App.didFinishLaunching` alongside the overlay controller. * `ContextHUDController.showForLayer` bails early if the frontmost app is listed (the HUD is short-lived so an inline check is simpler than a separate suppressor). * Settings → Experimental → "Hide Overlay in Specific Apps" card — lists each configured bundle, shows a nicer display name via `urlForApplication`, adds via the system app picker, removes inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/KeyPathAppKit/App.swift | 4 + .../Configuration/PreferencesService.swift | 37 ++++++ .../UI/ContextHUD/ContextHUDController.swift | 24 +++- .../LiveKeyboardOverlayController.swift | 31 +++++ .../UI/Overlay/OverlayAppSuppressor.swift | 65 ++++++++++ .../ExperimentalSettingsSection.swift | 112 ++++++++++++++++++ .../Utilities/Notifications.swift | 4 + 7 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 Sources/KeyPathAppKit/UI/Overlay/OverlayAppSuppressor.swift diff --git a/Sources/KeyPathAppKit/App.swift b/Sources/KeyPathAppKit/App.swift index 4decf839..2209d948 100644 --- a/Sources/KeyPathAppKit/App.swift +++ b/Sources/KeyPathAppKit/App.swift @@ -447,6 +447,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { _ = ContextHUDController.shared + // Watch frontmost-app changes and auto-hide the overlay for apps + // the user has listed in Settings → Experimental (default: Figma). + OverlayAppSuppressor.shared.start() + // Sequential startup: regenerate config, auto-launch, validate, auto-wizard Task { @MainActor in do { diff --git a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift index 75c53313..c2df20b9 100644 --- a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift +++ b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift @@ -253,6 +253,27 @@ final class PreferencesService: @unchecked Sendable { } } + // MARK: - App-Scoped Suppression + + /// Bundle identifiers of apps where the live keyboard overlay AND the + /// context HUD hint panel should auto-hide while that app is frontmost. + /// Restored when focus moves to any other app. + /// + /// Ships with Figma by default — its hold-to-pan keyboard interactions + /// conflict with the overlay's hold-Space activation. + var overlaySuppressedBundleIDs: Set { + didSet { + UserDefaults.standard.set( + Array(overlaySuppressedBundleIDs).sorted(), + forKey: Keys.overlaySuppressedBundleIDs + ) + NotificationCenter.default.post( + name: .overlaySuppressedBundleIDsChanged, + object: nil + ) + } + } + // MARK: - Context HUD Configuration /// Display mode for the Context HUD (overlay only, HUD only, or both) @@ -359,6 +380,7 @@ final class PreferencesService: @unchecked Sendable { static let neovimReferenceTopicsVersion = "KeyPath.Neovim.ReferenceTopicsVersion" static let keyLabelStyle = "KeyPath.Display.KeyLabelStyle" static let overlayHiddenHintShowCount = "KeyPath.Education.OverlayHiddenHintShowCount" + static let overlaySuppressedBundleIDs = "KeyPath.Overlay.SuppressedBundleIDs" } // MARK: - Defaults @@ -384,6 +406,13 @@ final class PreferencesService: @unchecked Sendable { static let kindaVimLeaderHUDMode = KindaVimLeaderHUDMode.contextualCoach static let neovimReferenceTopics = NeovimTerminalCategory.defaultRawValues static let neovimReferenceTopicsVersion = 2 + // Figma uses hold-Space for pan/navigate; the overlay's hold-Space + // leader interferes. Ship disabled there by default — user can + // add more apps in Settings → Experimental. + static let overlaySuppressedBundleIDs: Set = [ + "com.figma.Desktop", + "com.figma.agent" + ] } // MARK: - Initialization @@ -439,6 +468,14 @@ final class PreferencesService: @unchecked Sendable { overlayHiddenHintShowCount = UserDefaults.standard.object(forKey: Keys.overlayHiddenHintShowCount) as? Int ?? 0 + // App-scoped overlay suppression list. UserDefaults stores an Array; + // decode to Set, fall back to the Figma-seeded default if unset. + if let stored = UserDefaults.standard.stringArray(forKey: Keys.overlaySuppressedBundleIDs) { + overlaySuppressedBundleIDs = Set(stored) + } else { + overlaySuppressedBundleIDs = Defaults.overlaySuppressedBundleIDs + } + // Context HUD preferences let hudModeString = UserDefaults.standard.string(forKey: Keys.contextHUDDisplayMode) ?? Defaults.contextHUDDisplayMode.rawValue diff --git a/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift b/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift index f85fae59..2a7fcbac 100644 --- a/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift +++ b/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift @@ -365,6 +365,15 @@ final class ContextHUDController { // MARK: - Show / Dismiss private func showForLayer(_ layerName: String) { + // Respect the user's per-app suppression list — if the frontmost + // app is listed (e.g. Figma), skip showing the HUD entirely. Also + // dismiss if it's already up, for apps the user just activated. + if let frontBundle = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, + PreferencesService.shared.overlaySuppressedBundleIDs.contains(frontBundle) + { + dismiss() + return + } // Cancel any pending dismiss dismissTask?.cancel() dismissTask = nil @@ -724,7 +733,11 @@ final class ContextHUDController { private func positionWindow() { guard let window, let screen = NSScreen.main else { return } - // Size to fit content + let screenFrame = screen.visibleFrame + + // Size to fit content. Cap at the available screen width (with a + // margin) so multi-group HUDs with many entries still fit on-screen + // without clipping, instead of hitting a hardcoded 1100pt ceiling. if let hostingView { // Force SwiftUI layout pass so fittingSize reflects current content, // not stale layout from a previous show/update cycle. @@ -732,13 +745,16 @@ final class ContextHUDController { hostingView.layoutSubtreeIfNeeded() let fittingSize = hostingView.fittingSize - let width = min(max(fittingSize.width, 240), 1100) - let height = min(max(fittingSize.height, 100), 600) + let horizontalMargin: CGFloat = 64 + let verticalMargin: CGFloat = 80 + let maxWidth = max(600, screenFrame.width - horizontalMargin) + let maxHeight = max(400, screenFrame.height - verticalMargin) + let width = min(max(fittingSize.width, 240), maxWidth) + let height = min(max(fittingSize.height, 100), maxHeight) window.setContentSize(NSSize(width: width, height: height)) } // Position at center of screen - let screenFrame = screen.visibleFrame let windowFrame = window.frame let x = screenFrame.midX - (windowFrame.width / 2) let y = screenFrame.midY - (windowFrame.height / 2) diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift index 09203939..9be491e3 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift @@ -865,6 +865,37 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } } + // MARK: - App-Scoped Suppression + + /// Per-app suppression state. Independent of the Settings/Wizard guard + /// above so they don't clobber each other — a user could open Settings + /// while Figma is frontmost and both reasons should compose. + private var wasVisibleBeforeAppSuppression: Bool = false + private var isAppSuppressed: Bool = false + + /// Hide the overlay because the frontmost app is on the user's + /// suppression list (e.g. Figma, where hold-Space conflicts). Stashes + /// pre-hide visibility so `restoreFromAppSuppression` can put it back. + func suppressForApp() { + guard !isAppSuppressed else { return } + isAppSuppressed = true + wasVisibleBeforeAppSuppression = isVisible + if isVisible { + isVisible = false + } + } + + /// Restore the overlay when leaving a suppressed app. + func restoreFromAppSuppression() { + guard isAppSuppressed else { return } + let shouldRestore = wasVisibleBeforeAppSuppression + isAppSuppressed = false + wasVisibleBeforeAppSuppression = false + if shouldRestore, !isVisible { + isVisible = true + } + } + // MARK: - Window Management private func showWindow() { diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayAppSuppressor.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayAppSuppressor.swift new file mode 100644 index 00000000..53f24086 --- /dev/null +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayAppSuppressor.swift @@ -0,0 +1,65 @@ +// Watches NSWorkspace for frontmost-app changes and suppresses/restores +// the live keyboard overlay based on the user's +// `PreferencesService.overlaySuppressedBundleIDs` list. +// +// The ContextHUD already checks the same list inline when deciding whether +// to show, so it doesn't need a parallel suppressor — this type handles +// the overlay's longer-lived visibility state. + +import AppKit +import Foundation +import KeyPathCore + +@MainActor +final class OverlayAppSuppressor { + static let shared = OverlayAppSuppressor() + + private var activationObserver: NSObjectProtocol? + private var preferenceChangeObserver: NSObjectProtocol? + + func start() { + guard activationObserver == nil else { return } + + // Apply state for whatever app is frontmost right now. + applyForCurrentApp() + + activationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.applyForCurrentApp() + } + } + + // When the user edits the list in Settings, re-evaluate so the + // overlay doesn't stay suppressed in an app the user just removed. + preferenceChangeObserver = NotificationCenter.default.addObserver( + forName: .overlaySuppressedBundleIDsChanged, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.applyForCurrentApp() + } + } + } + + func stop() { + activationObserver.map(NSWorkspace.shared.notificationCenter.removeObserver) + activationObserver = nil + preferenceChangeObserver.map(NotificationCenter.default.removeObserver) + preferenceChangeObserver = nil + } + + private func applyForCurrentApp() { + let frontBundle = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + let suppressed = PreferencesService.shared.overlaySuppressedBundleIDs + if let frontBundle, suppressed.contains(frontBundle) { + LiveKeyboardOverlayController.shared.suppressForApp() + } else { + LiveKeyboardOverlayController.shared.restoreFromAppSuppression() + } + } +} diff --git a/Sources/KeyPathAppKit/UI/Settings/ExperimentalSettingsSection.swift b/Sources/KeyPathAppKit/UI/Settings/ExperimentalSettingsSection.swift index 7bf05030..0c813a4e 100644 --- a/Sources/KeyPathAppKit/UI/Settings/ExperimentalSettingsSection.swift +++ b/Sources/KeyPathAppKit/UI/Settings/ExperimentalSettingsSection.swift @@ -17,6 +17,7 @@ struct ExperimentalSettingsSection: View { ? UserDefaults.standard.bool(forKey: LayoutPreferences.qmkSearchEnabledKey) : LayoutPreferences.qmkSearchEnabledDefault @State private var accessibilityTestMode = PreferencesService.shared.accessibilityTestMode + @State private var suppressedBundleIDs: [String] = Array(PreferencesService.shared.overlaySuppressedBundleIDs).sorted() var body: some View { ScrollView { @@ -60,6 +61,69 @@ struct ExperimentalSettingsSection: View { } } + // Per-app overlay suppression + SettingsCard { + VStack(alignment: .leading, spacing: 12) { + SectionHeader( + icon: "eye.slash", + title: "Hide Overlay in Specific Apps", + color: .indigo + ) + Text("The live keyboard overlay and hint panel auto-hide while these apps are frontmost. They restore when you switch away.") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(spacing: 6) { + ForEach(suppressedBundleIDs, id: \.self) { bundleID in + HStack { + Image(systemName: appIcon(for: bundleID)) + .foregroundStyle(.secondary) + .frame(width: 16) + VStack(alignment: .leading, spacing: 1) { + Text(appDisplayName(for: bundleID)) + .font(.system(size: 12, weight: .medium)) + Text(bundleID) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + removeBundleID(bundleID) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red.opacity(0.7)) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(appDisplayName(for: bundleID))") + .accessibilityIdentifier("overlay-suppressed-remove-\(bundleID)") + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondary.opacity(0.08)) + ) + } + if suppressedBundleIDs.isEmpty { + Text("No apps configured.") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.vertical, 8) + } + } + + Button { + addAppViaPicker() + } label: { + Label("Add App…", systemImage: "plus") + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("overlay-suppressed-add-app") + } + } + // Testing Section SettingsCard { VStack(alignment: .leading, spacing: 12) { @@ -275,6 +339,54 @@ struct ExperimentalSettingsSection: View { } .padding(.vertical, 4) } + + // MARK: - Per-app suppression helpers + + private func addAppViaPicker() { + let panel = NSOpenPanel() + panel.title = "Choose an app" + panel.prompt = "Choose" + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + guard let bundle = Bundle(url: url), + let bundleID = bundle.bundleIdentifier + else { return } + var updated = Set(suppressedBundleIDs) + updated.insert(bundleID) + applySuppressedChange(updated) + } + + private func removeBundleID(_ id: String) { + var updated = Set(suppressedBundleIDs) + updated.remove(id) + applySuppressedChange(updated) + } + + private func applySuppressedChange(_ updated: Set) { + suppressedBundleIDs = Array(updated).sorted() + services.preferences.overlaySuppressedBundleIDs = updated + } + + /// Best-effort nice display name for a bundle id (reads + /// `CFBundleDisplayName` / `CFBundleName` from the installed app). + /// Falls back to the last path component of the bundle id. + private func appDisplayName(for bundleID: String) -> String { + if let path = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)?.path, + let bundle = Bundle(path: path), + let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + { + return name + } + return bundleID.components(separatedBy: ".").last ?? bundleID + } + + private func appIcon(for _: String) -> String { + "app" + } } // MARK: - Supporting Views diff --git a/Sources/KeyPathAppKit/Utilities/Notifications.swift b/Sources/KeyPathAppKit/Utilities/Notifications.swift index 8290b4ad..4b035065 100644 --- a/Sources/KeyPathAppKit/Utilities/Notifications.swift +++ b/Sources/KeyPathAppKit/Utilities/Notifications.swift @@ -48,6 +48,10 @@ extension Notification.Name { static let configReloadFailed = Notification.Name("KeyPath.Config.ReloadFailed") static let configReloadRecovered = Notification.Name("KeyPath.Config.ReloadRecovered") + /// Overlay app-scoped suppression list changed — observers should + /// re-evaluate whether to hide/show the overlay for the current app. + static let overlaySuppressedBundleIDsChanged = Notification.Name("KeyPath.Overlay.SuppressedBundleIDsChanged") + /// Mapper drawer /// Posted when a key is clicked and should be selected in the mapper drawer /// userInfo: keyCode (UInt16), inputKey (String), outputKey (String), optional shiftedOutputKey (String), layer (String) From 60e32a2e2e7a7174776b53317e7e38f194f844f3 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 06:27:09 -0700 Subject: [PATCH 08/10] feat: suppress LAYER indicator + system sounds in suppressed apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the per-app suppression list to cover the remaining pieces of UI/audio that still fired in Figma: * `LayerIndicatorManager.showLayer` now bails early (and orders the window out) when the frontmost app is on the suppression list, so the "LAYER NAV" badge doesn't pop while you're drawing in Figma. * `SoundManager` gains a single `shouldSuppress()` gate that extends the pre-existing test-environment guard to also cover the app list. Every system sound (tink/glass/beep/warning/submarine/layer-up/ layer-down/overlay-show/overlay-hide) checks it, so no audio feedback sneaks out while the user is in a suppressed app. No new Settings UI needed — everything piggybacks on the existing `overlaySuppressedBundleIDs` preference added in the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UI/LayerIndicatorWindow.swift | 13 +++++ .../Utilities/SoundManager.swift | 50 ++++++++++++------- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/Sources/KeyPathAppKit/UI/LayerIndicatorWindow.swift b/Sources/KeyPathAppKit/UI/LayerIndicatorWindow.swift index c2498dc8..fe9a7cc7 100644 --- a/Sources/KeyPathAppKit/UI/LayerIndicatorWindow.swift +++ b/Sources/KeyPathAppKit/UI/LayerIndicatorWindow.swift @@ -109,6 +109,19 @@ class LayerIndicatorManager { func showLayer(_ layerName: String) { AppLogger.shared.log("🪟 [LayerIndicator] showLayer called with: '\(layerName)' (previous: '\(previousLayer)')") + // Respect the per-app suppression list — if the user is in Figma + // (etc), skip the indicator AND the directional sounds. We still + // update `previousLayer` so the next legitimate change doesn't + // double-fire. + if let frontBundle = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, + PreferencesService.shared.overlaySuppressedBundleIDs.contains(frontBundle) + { + AppLogger.shared.debug("🪟 [LayerIndicator] Suppressed for app \(frontBundle)") + previousLayer = layerName + window?.orderOut(nil) + return + } + let isBase = layerName.lowercased() == "base" let wasBase = previousLayer.lowercased() == "base" diff --git a/Sources/KeyPathAppKit/Utilities/SoundManager.swift b/Sources/KeyPathAppKit/Utilities/SoundManager.swift index 6f1f06ac..2cb853ac 100644 --- a/Sources/KeyPathAppKit/Utilities/SoundManager.swift +++ b/Sources/KeyPathAppKit/Utilities/SoundManager.swift @@ -9,10 +9,24 @@ class SoundManager { private init() {} + /// Gate that silences every sound when: + /// * We're running inside XCTest (pre-existing behavior), OR + /// * The frontmost app's bundle identifier is on the user's + /// `overlaySuppressedBundleIDs` list (Settings → Experimental). + private func shouldSuppress() -> Bool { + if shouldSuppress() { return true } + if let front = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, + PreferencesService.shared.overlaySuppressedBundleIDs.contains(front) + { + return true + } + return false + } + /// Play tink sound when saving configuration func playTinkSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed tink sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed tink sound") return } NSSound(named: "Tink")?.play() @@ -21,8 +35,8 @@ class SoundManager { /// Play glass sound when configuration reload is complete func playGlassSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed glass sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed glass sound") return } NSSound(named: "Glass")?.play() @@ -31,8 +45,8 @@ class SoundManager { /// Play system beep for errors func playErrorSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed error beep in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed error beep") return } NSSound.beep() @@ -41,8 +55,8 @@ class SoundManager { /// Play warning sound for conflicts (non-blocking warnings) func playWarningSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed warning sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed warning sound") return } // "Basso" is a low, cautionary sound - appropriate for warnings @@ -52,8 +66,8 @@ class SoundManager { /// Play submarine sound for successful operations (alternative to glass) func playSubmarineSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed submarine sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed submarine sound") return } NSSound(named: "Submarine")?.play() @@ -64,8 +78,8 @@ class SoundManager { /// Play subtle sound when entering a non-base layer (higher pitch) func playLayerUpSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed layer-up sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed layer-up sound") return } // "Tink" is a light, higher-pitched tap - good for going "up" @@ -75,8 +89,8 @@ class SoundManager { /// Play subtle sound when returning to base layer (lower pitch) func playLayerDownSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed layer-down sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed layer-down sound") return } // "Pop" has a lower, softer quality - good for settling "down" @@ -88,8 +102,8 @@ class SoundManager { /// Play subtle sound when overlay appears func playOverlayShowSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed overlay-show sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed overlay-show sound") return } // "Bottle" is a soft cork/bubble sound - gentle for appearing @@ -99,8 +113,8 @@ class SoundManager { /// Play subtle sound when overlay hides func playOverlayHideSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [Sound] Suppressed overlay-hide sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("🔇 [Sound] Suppressed overlay-hide sound") return } // "Funk" is a subtle descending tone - good for dismissing From 1c4a070f5f439b1a0d7931f68741798bc0a1647c Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 06:30:54 -0700 Subject: [PATCH 09/10] fix: restore test-env check in SoundManager.shouldSuppress (infinite recursion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit did a global replace of `if TestEnvironment.isRunning Tests` → `if shouldSuppress()` inside SoundManager.swift, but one hit was inside `shouldSuppress` itself — turning the guard into a self-call and blowing the stack at bootstrap. Caught at launch by a user crash report: Thread 0 Crashed SoundManager.shouldSuppress() + 12 ... (~522333 recursion levels) ... SoundManager.playLayerUpSound SoundManager.playTinkSound RuleCollectionsManager.regenerateConfigFromCollections RuleCollectionsManager.bootstrap RuntimeCoordinator init Restore the test-env check as the first line of `shouldSuppress` so the gate has a base case and the per-app branch runs only when needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/KeyPathAppKit/Utilities/SoundManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/KeyPathAppKit/Utilities/SoundManager.swift b/Sources/KeyPathAppKit/Utilities/SoundManager.swift index 2cb853ac..0e508558 100644 --- a/Sources/KeyPathAppKit/Utilities/SoundManager.swift +++ b/Sources/KeyPathAppKit/Utilities/SoundManager.swift @@ -14,7 +14,7 @@ class SoundManager { /// * The frontmost app's bundle identifier is on the user's /// `overlaySuppressedBundleIDs` list (Settings → Experimental). private func shouldSuppress() -> Bool { - if shouldSuppress() { return true } + if TestEnvironment.isRunningTests { return true } if let front = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, PreferencesService.shared.overlaySuppressedBundleIDs.contains(front) { From c3b96008ec70b49a4d3966efb9bd93ff9225d34a Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 24 Apr 2026 08:09:04 -0700 Subject: [PATCH 10/10] test: align PR 318 expectations with current behavior --- ...aConfigurationGeneratorSnapshotTests.swift | 19 ++++++++++--------- ...tusOverviewPermissionVisibilityTests.swift | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift b/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift index 88a0dcd5..5384887a 100644 --- a/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift +++ b/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift @@ -96,7 +96,7 @@ final class KanataConfigurationGeneratorSnapshotTests: XCTestCase { assertContains(config, "(multi (release-layer window) @act_window_h (push-msg \"layer:base\"))") } - func testModifierChordsPreservedInsideMultiWrapper() throws { + func testModifierChordsExpandedInsideMultiWrapper() throws { let navCollection = try makeCollection( id: XCTUnwrap(UUID(uuidString: "11111111-1111-1111-1111-111111111111")), name: "Navigation", @@ -115,18 +115,19 @@ final class KanataConfigurationGeneratorSnapshotTests: XCTestCase { navActivationMode: .tapToToggle ) - // Modifier chords should pass through intact — not expanded to "lmet v" / "lctl lsft z" - assertContains(config, "(multi (release-layer nav) M-v (push-msg \"layer:base\"))") - assertContains(config, "(multi (release-layer nav) C-S-z (push-msg \"layer:base\"))") + // Modifier chords must expand inside the release-layer multi wrapper. + // Kanata drops modifier-prefixed tokens after release-layer, so the + // generator emits explicit nested multi forms instead. + assertContains(config, "(multi (release-layer nav) (multi lmet v) (push-msg \"layer:base\"))") + assertContains(config, "(multi (release-layer nav) (multi lctl lsft z) (push-msg \"layer:base\"))") - // Verify the expanded forms are NOT present XCTAssertFalse( - config.contains("lmet v"), - "M-v should not be expanded to 'lmet v' inside multi\n\nActual output:\n\(config)" + config.contains("(multi (release-layer nav) M-v (push-msg \"layer:base\"))"), + "M-v should be expanded inside multi\n\nActual output:\n\(config)" ) XCTAssertFalse( - config.contains("lctl lsft z"), - "C-S-z should not be expanded to 'lctl lsft z' inside multi\n\nActual output:\n\(config)" + config.contains("(multi (release-layer nav) C-S-z (push-msg \"layer:base\"))"), + "C-S-z should be expanded inside multi\n\nActual output:\n\(config)" ) } diff --git a/Tests/KeyPathTests/InstallationWizard/WizardSystemStatusOverviewPermissionVisibilityTests.swift b/Tests/KeyPathTests/InstallationWizard/WizardSystemStatusOverviewPermissionVisibilityTests.swift index b4ce9954..dd395283 100644 --- a/Tests/KeyPathTests/InstallationWizard/WizardSystemStatusOverviewPermissionVisibilityTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/WizardSystemStatusOverviewPermissionVisibilityTests.swift @@ -5,7 +5,7 @@ import KeyPathWizardCore @MainActor final class WizardSystemStatusOverviewPermissionVisibilityTests: XCTestCase { - func testKanataPermissionWarningAppearsInInputMonitoringStatusRow() { + func testKanataPermissionWarningAppearsAsUnverifiedWithoutFullDiskAccess() { let warningIssue = WizardIssue( identifier: .permission(.kanataInputMonitoring), severity: .warning, @@ -32,7 +32,7 @@ final class WizardSystemStatusOverviewPermissionVisibilityTests: XCTestCase { let items = overview.statusItems let input = items.first(where: { $0.id == "input-monitoring" }) XCTAssertNotNil(input, "Expected an Input Monitoring status row") - XCTAssertEqual(input?.status, .warning, "Kanata permission warning should surface as warning status") + XCTAssertEqual(input?.status, .unverified, "Kanata permission warning should surface as unverified when Full Disk Access is unavailable") XCTAssertTrue( (input?.relatedIssues.contains { $0.identifier == .permission(.kanataInputMonitoring) } ?? false), "Input Monitoring row should include the underlying warning issue"