From dc83b21a8ee8b97dc37146ce7a2ad1a4e815d7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Gr=C3=A9goire?= Date: Sat, 1 Aug 2026 17:20:27 -0400 Subject: [PATCH] add context_modes resolution (context token to mode) Map the external context token from ContextObserver to a mode, per profile. Policy lives here so the bridge stays dumb: ProfileConfig gains an optional context_modes ([String: String], token -> mode name), decoded with decodeIfPresent ?? [:] so existing configs keep parsing. Resolution rules, all deliberate: - fires only on token change (ContextObserver publishes only on change) - no match (or nil token) leaves the mode alone, never falls back to default_mode - a mode picked by hand sticks until the token changes - an unknown mode name is logged and ignored, mirroring setMode - only the active profile is affected refreshActiveProfile now restores the context-implied mode on return to a profile instead of snapping to default_mode, and sets both activeModeName and profileModes[name] to keep them consistent. Closes #9 Co-Authored-By: Claude Opus 4.8 --- PadIO/ControllerManager.swift | 51 ++++++++++++++++++++++++++++++++--- PadIO/MappingConfig.swift | 13 ++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/PadIO/ControllerManager.swift b/PadIO/ControllerManager.swift index 9e808cb..98cf5fc 100644 --- a/PadIO/ControllerManager.swift +++ b/PadIO/ControllerManager.swift @@ -74,10 +74,43 @@ final class ControllerManager: ObservableObject { // MARK: - External context - /// Reacts to a change in the external context token. Transport only for now — - /// context_modes resolution is added in a follow-up. + /// Reacts to a change in the external context token (e.g. the herdr bridge reporting + /// the app in the focused pane). Resolution rules, all deliberate: + /// + /// 1. Fires only on token change. `ContextObserver` publishes only when the token + /// actually changes, so an unchanged token never re-applies a mode. + /// 2. No match leaves the mode alone. A token with no `context_modes` entry (or a nil + /// token) keeps the active mode; it does *not* fall back to `default_mode`. + /// 3. Manual override sticks. Because we only act on token *changes*, a mode picked by + /// hand survives until the context token changes. + /// 4. Unknown mode name is logged and ignored, mirroring the `.setMode` handling. + /// 5. Only the active profile is affected. private func applyContext(_ token: String?) { - print("[PadIO] Context token: \(token ?? "nil")") + guard let token else { return } // no context: leave the mode alone + + let config = configLoader.config + let bundleID = appObserver.frontmostBundleID + guard let (profileName, profile) = mappingResolver.resolveProfile(bundleID: bundleID, config: config) else { return } + + guard let modeName = contextMode(for: token, profile: profile, config: config) else { return } + + // Skip if it is already the active mode (avoids a redundant mode-change HUD). + let currentMode = profileModes[profileName] ?? profile.defaultMode + guard modeName != currentMode else { return } + + switchMode(modeName, profileName: profileName) + } + + /// Resolves a context token to a valid mode name for the given profile, or nil when + /// the token has no `context_modes` entry (rule 2) or names a mode that does not exist + /// in the profile or shared modes (rule 4, logged). + private func contextMode(for token: String, profile: ProfileConfig, config: MappingConfig) -> String? { + guard let modeName = profile.contextModes[token] else { return nil } + guard profile.modes[modeName] != nil || config.sharedModes?[modeName] != nil else { + print("[PadIO] context: mode '\(modeName)' for token '\(token)' not found in profile") + return nil + } + return modeName } // MARK: - Profile resolution @@ -94,7 +127,17 @@ final class ControllerManager: ObservableObject { if name != activeProfileName { activeProfileName = name - activeModeName = profile.defaultMode + // On returning to a profile, prefer the mode implied by the current context + // token over default_mode, so alt-tabbing away from and back to Ghostty keeps + // the app-driven mode instead of snapping back to the default. Set both + // activeModeName and profileModes so the two stay consistent. + if let token = contextObserver.context, + let mode = contextMode(for: token, profile: profile, config: config) { + activeModeName = mode + profileModes[name] = mode + } else { + activeModeName = profile.defaultMode + } } } diff --git a/PadIO/MappingConfig.swift b/PadIO/MappingConfig.swift index 9dd3bfb..b36d452 100644 --- a/PadIO/MappingConfig.swift +++ b/PadIO/MappingConfig.swift @@ -145,20 +145,25 @@ struct ProfileConfig: Codable, Sendable { let global: [String: ActionConfig] /// Named modes within this profile, each with their own button bindings. let modes: [String: ModeConfig] + /// Maps an external context token (e.g. a process name from the herdr bridge) + /// to a mode name. Applied when the token changes; see ControllerManager. + let contextModes: [String: String] enum CodingKeys: String, CodingKey { case apps case defaultMode = "default_mode" case global case modes + case contextModes = "context_modes" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - apps = try container.decodeIfPresent([String].self, forKey: .apps) ?? [] - defaultMode = try container.decode(String.self, forKey: .defaultMode) - global = try container.decodeIfPresent([String: ActionConfig].self, forKey: .global) ?? [:] - modes = try container.decodeIfPresent([String: ModeConfig].self, forKey: .modes) ?? [:] + apps = try container.decodeIfPresent([String].self, forKey: .apps) ?? [] + defaultMode = try container.decode(String.self, forKey: .defaultMode) + global = try container.decodeIfPresent([String: ActionConfig].self, forKey: .global) ?? [:] + modes = try container.decodeIfPresent([String: ModeConfig].self, forKey: .modes) ?? [:] + contextModes = try container.decodeIfPresent([String: String].self, forKey: .contextModes) ?? [:] } }