From 9ac39b2900cdb11a5ce7041ddc88681367cbdf4b Mon Sep 17 00:00:00 2001 From: Josh Gruenstein Date: Sun, 12 Jul 2026 02:51:58 -0400 Subject: [PATCH 1/2] feat: layered config with always-current defaults file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two files: default-config.toml is a mirror of the embedded defaults, rewritten on every launch so it always documents the running version (the app never reads it back). config.toml holds only what the user changes, applied over defaults with plain per-key precedence — user keys win wholesale (lists and inline action tables included), section tables merge per contained key. Vocabulary layering is schema, not merge magic: builtin vocabulary + user extraVocabulary, concatenated and deduped. New installs get a near-empty starter config. Rewrite pass now defaults on (with technicalFormatting). Legacy tingd/json migration code removed. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 38 +- README.md | 30 +- Sources/TingleCore/Config.swift | 453 ++++-------------- Sources/TingleCore/Dictation.swift | 4 +- Sources/TingleCore/StatusItemController.swift | 7 + Tests/tingle-tests/ConfigTests.swift | 52 +- Tests/tingle-tests/main.swift | 2 +- 7 files changed, 202 insertions(+), 384 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index db0d742..d9e29c0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -112,7 +112,7 @@ abandoned after 10s. Completed takes stack (cap 10): each green press erases exactly the characters of one more take (same app, ≤5min). Consecutive takes in the same app auto-join with a space. -### Rewrite pass (optional, `[rewrite]` in config) +### Rewrite pass (`[rewrite]` in config, on by default) After a take finalizes, Apple's on-device Foundation model (Apple Intelligence, macOS 26+) polishes it in place: punctuation, repeated-word @@ -134,16 +134,32 @@ model runs. Eligibility band: 4-150 words. ## Configuration -Literate TOML at `~/Library/Application Support/tingle/config.toml`, -live-reloaded; the shipped default file is its own documentation. Mappings: -`mode1`–`mode4`, `modeChange`, `fxChange`, `triggerDown`, `triggerUp` → -actions `dictate`, `eraseDictation`, `keystroke`, `keyHold` and `shell` -(multiline scripts via TOML `\'\'\'` strings). `vocabulary` biases dictation -via `AnalysisContext.contextualStrings`. tingle never writes the file after -creation — menu-driven state (the pinned input device) lives in -UserDefaults so user comments survive. Defaults: trigger = dictate, orange -= enter, green = erase last take, white unmapped (a commented "summon your -agent" script ships in the template). +Two TOML files in `~/Library/Application Support/tingle/`, layered: + +- `default-config.toml` — the full literate defaults, REWRITTEN by the app + on every launch so it always documents the running version. A mirror, + not a source: the app parses the embedded template, never this file, so + a mangled mirror can't break anything. Browse it, copy from it. +- `config.toml` — the user's file, containing only what they change. + tingle writes a near-empty starter once and never touches it again + (menu-driven state lives in UserDefaults so user comments survive). + Live-reloaded on save. + +Merge semantics are plain per-key precedence: a key in config.toml wins +wholesale (lists and inline action tables included — no bleed-through); +section tables ([mappings], [replacements], [rewrite]) merge per +contained key, so overriding one mapping keeps the rest. Where different +semantics are wanted, the schema provides them instead of merge magic: +`vocabulary` is the built-in biasing list and `extraVocabulary` is the +user's additions, concatenated (deduped) at load — so built-in list +updates keep flowing to configs that only add words. + +Mappings: `mode1`–`mode4`, `modeChange`, `fxChange`, `triggerDown`, +`triggerUp` → actions `dictate`, `eraseDictation`, `keystroke`, `keyHold` +and `shell` (multiline scripts via TOML `\'\'\'` strings). Vocabulary +biases dictation via `AnalysisContext.contextualStrings`. Defaults: +trigger = dictate, orange = enter, green = erase last take, white = +summon-agent script. ## Distribution diff --git a/README.md b/README.md index 9d224e2..ab2b5f5 100644 --- a/README.md +++ b/README.md @@ -108,28 +108,28 @@ its heartbeat. | green button | erase the last dictated take (repeat for earlier takes) | | white button | summon your agent — brings the first running AI coding app (Claude, Codex, Cursor, iTerm2, Terminal) to the front | -Every one of these is just a default. tingle is configurable by design: the -whole control scheme lives in the TOML file below, and any behavior — the -summon list, what green erases, what orange sends, the dictation vocabulary — -is yours to change. +Every one of these is just a default. tingle is configurable by design — +the summon list, what each button does, the dictation vocabulary, the +rewrite pass — but the defaults are meant to be good enough that most +people never open the config. ## Configuration -Everything lives in one literate TOML file — -`~/Library/Application Support/tingle/config.toml` ("Edit config…" in the -menu) — which documents itself and live-reloads on save: +Two files in `~/Library/Application Support/tingle/`: +`default-config.toml` holds every option, documented, and is refreshed by +the app on every launch — it's the reference, so new options and new +built-in vocabulary arrive with updates. Your own `config.toml` ("Edit +config…" in the menu) contains only what you change: copy any key over, +edit it, save — it wins over the default and reloads live. ```toml -vocabulary = ["Claude", "Codex", "kubectl"] # bias recognition toward your jargon +extraVocabulary = ["Metabase", "kubectl"] # your jargon, added onto the built-in list [replacements] # fix what biasing can't "Tamil" = "TOML" -[mappings] -triggerDown = { type = "dictate" } -fxChange = { type = "keystroke", key = "return" } -modeChange = { type = "eraseDictation" } -mode1 = { type = "shell", command = """ +[mappings] # merges per key with the defaults +mode1 = { type = "shell", command = """ open -a "Claude" # multiline scripts welcome """ } ``` @@ -141,8 +141,8 @@ slots (`mode1`–`mode4`), selected by the ting's green mode LED. Dictation uses Apple's on-device speech stack (macOS 26+). Everything else works on macOS 13+. -With Apple Intelligence available, an optional `[rewrite]` section enables -an on-device LLM polish of each take a moment after you release: fillers +With Apple Intelligence available, an on-device LLM polishes each take a +moment after you release (on by default; `[rewrite]` in the config): fillers gone, punctuation fixed, your jargon corrected — all locally, nothing leaves the Mac, and it never touches your words' meaning (degenerate model output is rejected and your original text stays). diff --git a/Sources/TingleCore/Config.swift b/Sources/TingleCore/Config.swift index 404195f..fd96b6f 100644 --- a/Sources/TingleCore/Config.swift +++ b/Sources/TingleCore/Config.swift @@ -97,12 +97,12 @@ extension TingAction: Codable { /// Every bool maps to a fixed prompt fragment; `customInstructions` is the /// only freeform field and is appended last. All inert unless `enabled`. public struct RewriteConfig: Decodable, Equatable { - public var enabled = false + public var enabled = true public var removeFillers = true public var fixPunctuation = true public var fixGrammar = false public var correctVocabulary = true - public var technicalFormatting = false + public var technicalFormatting = true public var customInstructions = "" public init() {} @@ -114,12 +114,12 @@ public struct RewriteConfig: Decodable, Equatable { public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true removeFillers = try c.decodeIfPresent(Bool.self, forKey: .removeFillers) ?? true fixPunctuation = try c.decodeIfPresent(Bool.self, forKey: .fixPunctuation) ?? true fixGrammar = try c.decodeIfPresent(Bool.self, forKey: .fixGrammar) ?? false correctVocabulary = try c.decodeIfPresent(Bool.self, forKey: .correctVocabulary) ?? true - technicalFormatting = try c.decodeIfPresent(Bool.self, forKey: .technicalFormatting) ?? false + technicalFormatting = try c.decodeIfPresent(Bool.self, forKey: .technicalFormatting) ?? true customInstructions = try c.decodeIfPresent(String.self, forKey: .customInstructions) ?? "" } } @@ -129,7 +129,17 @@ public struct TingConfig: Decodable { public var toneFrequencies: [Double] /// Words/phrases biasing dictation recognition (names, jargon) via /// AnalysisContext.contextualStrings. Cheap, applied per-session. + /// `vocabulary` is the curated built-in list (from the defaults file); + /// `extraVocabulary` is the user's own additions. Two keys instead of + /// merge magic: overriding either is plain per-key precedence. public var vocabulary: [String] + public var extraVocabulary: [String] + + /// What consumers use: built-in plus user words, deduped in order. + public var effectiveVocabulary: [String] { + var seen = Set() + return (vocabulary + extraVocabulary).filter { seen.insert($0).inserted } + } /// "mode1"…"mode4" (white per green mode), "modeChange" (green), /// "fxChange" (orange), "triggerDown"/"triggerUp" (handle). public var mappings: [String: TingAction] @@ -140,63 +150,34 @@ public struct TingConfig: Decodable { /// Post-dictation LLM rewrite pass. public var rewrite: RewriteConfig - /// The default white-button action: bring the first running AI coding - /// app to the front, ready to dictate into. - static let summonAgentScript = """ -for app in "Claude" "Codex" "Cursor" "iTerm2" "Terminal"; do - if osascript -e 'application "'"$app"'" is running' 2>/dev/null | grep -q true; then - osascript - "$app" <<'APPLESCRIPT' -on run argv - set appName to item 1 of argv - tell application appName to activate - delay 0.3 - tell application "System Events" to tell process appName - try - set {wx, wy} to position of window 1 - set {ww, wh} to size of window 1 - click at {wx + (ww / 2), wy + (wh * 0.9)} - end try - end tell -end run -APPLESCRIPT - exit 0 - fi -done -""" - - static let `default` = TingConfig( - toneFrequencies: [17500, 18000, 18500, 19000], - vocabulary: ConfigStore.defaultVocabulary, - mappings: [ - // Ergonomic defaults: squeeze = dictate, orange = enter (submit), - // green = scrap the last take. White is unmapped out of the box. - "modeChange": .eraseDictation, - "fxChange": .keystroke(key: "return", modifiers: []), - "triggerDown": .dictate, - "white": .shell(command: summonAgentScript), - ], - replacements: ["Tamil": "TOML", "clawed": "Claude", "Clawed": "Claude"] - ) + /// The defaults document is the single source of truth: `default` is + /// its parse (the bare-struct fallback below can only be reached if + /// the embedded template is broken, which CI rejects). + public static let `default` = (try? TingConfig.parse(toml: ConfigStore.defaultTOML)) + ?? TingConfig(toneFrequencies: [17500, 18000, 18500, 19000], + vocabulary: [], mappings: [:]) init(toneFrequencies: [Double], vocabulary: [String], mappings: [String: TingAction], replacements: [String: String] = [:], rewrite: RewriteConfig = RewriteConfig()) { self.toneFrequencies = toneFrequencies self.vocabulary = vocabulary + self.extraVocabulary = [] self.mappings = mappings self.replacements = replacements self.rewrite = rewrite } private enum CodingKeys: String, CodingKey { - case toneFrequencies, vocabulary, mappings, replacements, rewrite + case toneFrequencies, vocabulary, extraVocabulary, mappings, replacements, rewrite } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) toneFrequencies = try container.decodeIfPresent([Double].self, forKey: .toneFrequencies) - ?? Self.default.toneFrequencies + ?? [17500, 18000, 18500, 19000] vocabulary = try container.decodeIfPresent([String].self, forKey: .vocabulary) ?? [] + extraVocabulary = try container.decodeIfPresent([String].self, forKey: .extraVocabulary) ?? [] mappings = try container.decodeIfPresent([String: TingAction].self, forKey: .mappings) ?? [:] replacements = try container.decodeIfPresent([String: String].self, forKey: .replacements) ?? [:] rewrite = try container.decodeIfPresent(RewriteConfig.self, forKey: .rewrite) ?? RewriteConfig() @@ -220,6 +201,29 @@ done try TOMLDecoder().decode(TingConfig.self, from: toml) } + /// Layered parse: the user document is applied over the defaults with + /// plain per-key precedence — a key the user writes wins wholesale + /// (lists and inline tables included); a key they don't comes from + /// defaults. Section tables ([rewrite], [mappings], [replacements]) + /// merge per contained key so overriding one mapping keeps the rest. + public static func parse(defaults: String, user: String) throws -> TingConfig { + let base = try TOMLTable(string: defaults) + let overlay = try TOMLTable(string: user) + return try TOMLDecoder().decode(TingConfig.self, from: merged(base: base, overlay: overlay)) + } + + private static func merged(base: TOMLTable, overlay: TOMLTable) -> TOMLTable { + for key in overlay.keys { + if let baseTable = base[key]?.table, let overTable = overlay[key]?.table, + !baseTable.inline, !overTable.inline { + base[key] = merged(base: baseTable, overlay: overTable) + } else { + base[key] = overlay[key] + } + } + return base + } + public func action(forKey key: String) -> TingAction? { mappings[key] } @@ -260,289 +264,17 @@ public final class ConfigStore { static let directoryURL = FileManager.default .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("tingle", isDirectory: true) - static let configURL = directoryURL.appendingPathComponent("config.toml") - static let legacyJSONURL = directoryURL.appendingPathComponent("config.json") - - /// The literate default config: the file IS the documentation. - public static let defaultVocabulary: [String] = [ - "Claude", - "Claude Code", - "Codex", - "tingle", - "TOML", - "JSON", - "YAML", - "GitHub", - "git", - "rebase", - "repo", - "monorepo", - "changelog", - "diff", - "regex", - "grep", - "bash", - "zsh", - "shell", - "sudo", - "chmod", - "ssh", - "localhost", - "DNS", - "API", - "CLI", - "SDK", - "IDE", - "CI", - "linter", - "TypeScript", - "JavaScript", - "Python", - "Swift", - "SwiftPM", - "Rust", - "Xcode", - "VS Code", - "Cursor", - "tmux", - "Docker", - "Kubernetes", - "kubectl", - "Postgres", - "SQL", - "SQLite", - "Redis", - "npm", - "pip", - "uv", - "async", - "await", - "enum", - "struct", - "mutex", - "goroutine", - "lambda", - "callback", - "closure", - "refactor", - "backtrace", - "stack trace", - "segfault", - "nil", - "null", - "boolean", - "int", - "float", - "tuple", - "dict", - "hashmap", - "iterator", - "recursion", - "memoize", - "O of N", - "big O", - "endpoint", - "webhook", - "OAuth", - "JWT", - "TLS", - "HTTPS", - "gRPC", - "protobuf", - "WebSocket", - "frontend", - "backend", - "middleware", - "microservice", - "Kafka", - "cron", - "daemon", - "systemd", - "launchd", - "Homebrew", - "cask", - "notarize", - "codesign", - "entitlement", - "TCC", - "Tutor Intelligence", - "teleop", - "end effector", - "gripper", - "servo", - "actuator", - "encoder", - "IMU", - "lidar", - "URDF", - "ROS", - "PLC", - "kinematics", - "inverse kinematics", - "trajectory", - "waypoint", - "pick and place", - "palletize", - "conveyor", - "workcell", - "PCB", - "firmware", - "MicroPython", - "RP2350", - "UART", - "GPIO", - "I2C", - "SPI", - "ADC", - "PWM", - "oscilloscope", - "solder", - "Teenage Engineering", - "Goertzel", - "ultrasonic", - "beacon", - "chirp", - "Cubilux", - "line-in", - "AAA battery", - "power cycle", - "subagent", - "agentic", - "LLM", - "GPT", - "Anthropic", - "OpenAI", - "Gemini", - "prompt", - "system prompt", - "context window", - "token", - "tokenizer", - "inference", - "fine-tune", - "RAG", - "embedding", - "vector database", - "hallucination", - "eval", - "benchmark", - "MCP", - "tool call", - "orchestration", - "worktree", - "sandbox", - "headless", - "transcript", - "session", - "hook", - "slash command", - "chain of thought", - "depalletizing", - "palletizing", - "singulation", - "kitting", - "bin picking", - "pick point", - "cycle time", - "throughput", - "uptime", - "downtime", - "end-of-arm tooling", - "EOAT", - "suction cup", - "vacuum gripper", - "force-torque sensor", - "tool center point", - "flange", - "wrist joint", - "seventh axis", - "gantry", - "cobot", - "stepper motor", - "harmonic drive", - "swerve drive", - "infeed", - "outfeed", - "tote", - "SKU", - "carton", - "slip sheet", - "pallet jack", - "light curtain", - "area scanner", - "e-stop", - "interlock", - "lockout tagout", - "teach pendant", - "homing", - "joint limits", - "joint space", - "Cartesian", - "quaternion", - "rotation matrix", - "kinematic chain", - "forward kinematics", - "singularity", - "reachability", - "motion planning", - "collision checking", - "payload", - "calibration", - "hand-eye calibration", - "extrinsics", - "intrinsics", - "fiducial", - "AprilTag", - "ArUco", - "point cloud", - "depth camera", - "UWB", - "pose estimation", - "bounding box", - "segmentation", - "YOLO", - "ONNX", - "TensorRT", - "PyTorch", - "quantization", - "teleoperation", - "provisioning", - "commissioning", - "site survey", - "bootloader", - "watchdog", - "over-the-air update", - "CAN bus", - "CANopen", - "Modbus", - "EtherCAT", - "RS-485", - "HMI", - "VFD", - "PoE", - "WireGuard", - "WebRTC", - "RTSP", - "kustomize", - "Terraform", - "Dockerfile", - "Grafana", - "Prometheus", - "ClickHouse", - "alembic", - "SQLAlchemy", - "pytest", - "mypy", - "ruff", - "Tutor", - "README", - "semver", - "config", - ] + public static let configURL = directoryURL.appendingPathComponent("config.toml") + public static let defaultConfigURL = directoryURL.appendingPathComponent("default-config.toml") + /// The defaults document: single source of truth for every default, + /// mirrored to default-config.toml on every launch so it is always + /// current, browsable, and copy-from-able. Humans edit config.toml. public static let defaultTOML = """ - # tingle configuration - this file is the documentation. - # Edit and save: tingle reloads it live, no restart needed. + # tingle defaults - REWRITTEN BY TINGLE ON EVERY LAUNCH; edits here are + # lost. This file is the documentation: browse it, then copy any key + # into config.toml (same folder) and change it there. Keys in + # config.toml win; sections like [mappings] merge per key. # # -- The device ------------------------------------------------------------ # Squeeze the ting's handle to dictate; release to finish. @@ -636,6 +368,11 @@ public final class ConfigStore { "config", ] + # Your words, concatenated onto the built-in vocabulary above. Add + # here (in config.toml) so built-in list updates still reach you; + # override `vocabulary` itself only to discard the built-ins. + extraVocabulary = [] + # Corrections applied to finalized dictation text (word-boundary, # case-sensitive) - for words the recognizer refuses to spell right # no matter how much vocabulary biasing it gets. @@ -652,12 +389,12 @@ public final class ConfigStore { # fixed, tested instruction; customInstructions is freeform and is # applied last. Filler removal works even without Apple Intelligence. [rewrite] - enabled = false + enabled = true removeFillers = true # delete um/uh/er and the comma debris they leave fixPunctuation = true # sentence boundaries, capitalization, run-ons fixGrammar = false # light repair only; off because the model sometimes over-normalizes correctVocabulary = true # let the model fix near-miss transcriptions of your vocabulary terms - technicalFormatting = false # "dash dash force" -> "--force", "foo dot py" -> "foo.py" + technicalFormatting = true # "dash dash force" -> "--force", "foo dot py" -> "foo.py" customInstructions = "" [mappings] @@ -697,6 +434,23 @@ public final class ConfigStore { ''' } """ + /// The starter user config: near-empty on purpose. The defaults doc + /// carries the documentation; this file carries only what the user + /// changes. + public static let userTemplateTOML = """ + # tingle configuration - edit and save: tingle reloads it live. + # + # Every available key, documented, lives in default-config.toml next + # to this file (rewritten by the app on every launch, so it is always + # current). Copy anything you want to change into THIS file: keys + # here win, and sections like [mappings] merge per key, so you only + # need the lines you actually change. + + # Words the recognizer keeps mangling, added on top of the built-in + # vocabulary list (see default-config.toml). + extraVocabulary = [] + """ + private(set) var config: TingConfig = .default private var observers: [() -> Void] = [] @@ -704,8 +458,7 @@ public final class ConfigStore { private let log = Logger(subsystem: Log.subsystem, category: "config") init() { - migrateLegacyDirectory() - migrateLegacyJSON() + writeDefaultsMirror() ensureConfigFileExists() load() startWatching() @@ -720,26 +473,20 @@ public final class ConfigStore { observers.forEach { $0() } } - /// The pre-rename app dir was ~/Library/Application Support/tingd; - /// carry the whole thing (config, backups) across once. - private func migrateLegacyDirectory() { - let fm = FileManager.default - let old = Self.directoryURL.deletingLastPathComponent().appendingPathComponent("tingd") - guard fm.fileExists(atPath: old.path), - !fm.fileExists(atPath: Self.directoryURL.path) else { return } - try? fm.moveItem(at: old, to: Self.directoryURL) - log.info("migrated legacy tingd directory to tingle") - } - - /// Pre-TOML installs used config.json; retire it visibly (renamed to - /// .bak) rather than leaving a dead file that looks authoritative. - private func migrateLegacyJSON() { - let fm = FileManager.default - guard fm.fileExists(atPath: Self.legacyJSONURL.path), - !fm.fileExists(atPath: Self.configURL.path) else { return } - try? fm.moveItem(at: Self.legacyJSONURL, - to: Self.legacyJSONURL.appendingPathExtension("bak")) - log.info("legacy config.json retired to config.json.bak; writing default config.toml") + /// default-config.toml is a mirror of the embedded defaults, refreshed + /// on every launch so it always documents the running version. The app + /// never reads it back — a mangled mirror cannot break anything. + private func writeDefaultsMirror() { + do { + try FileManager.default.createDirectory( + at: Self.directoryURL, withIntermediateDirectories: true) + if (try? String(contentsOf: Self.defaultConfigURL, encoding: .utf8)) != Self.defaultTOML { + try Self.defaultTOML.write(to: Self.defaultConfigURL, atomically: true, encoding: .utf8) + log.info("defaults mirror refreshed at \(Self.defaultConfigURL.path, privacy: .public)") + } + } catch { + log.error("failed to write defaults mirror: \(String(describing: error))") + } } private func ensureConfigFileExists() { @@ -747,19 +494,19 @@ public final class ConfigStore { do { try fm.createDirectory(at: Self.directoryURL, withIntermediateDirectories: true) if !fm.fileExists(atPath: Self.configURL.path) { - try Self.defaultTOML.write(to: Self.configURL, atomically: true, encoding: .utf8) - log.info("created default config at \(Self.configURL.path, privacy: .public)") + try Self.userTemplateTOML.write(to: Self.configURL, atomically: true, encoding: .utf8) + log.info("created starter config at \(Self.configURL.path, privacy: .public)") } } catch { - log.error("failed to create default config: \(String(describing: error))") + log.error("failed to create starter config: \(String(describing: error))") } } private func load() { do { let text = try String(contentsOf: Self.configURL, encoding: .utf8) - config = try TingConfig.parse(toml: text) - log.info("config loaded") + config = try TingConfig.parse(defaults: Self.defaultTOML, user: text) + log.info("config loaded (user overlay on defaults)") } catch { log.error("failed to load config (keeping previous): \(String(describing: error))") } diff --git a/Sources/TingleCore/Dictation.swift b/Sources/TingleCore/Dictation.swift index d3e86b0..1f7d6b1 100644 --- a/Sources/TingleCore/Dictation.swift +++ b/Sources/TingleCore/Dictation.swift @@ -105,7 +105,7 @@ final class DictationController { isFinalizing = false let newSession = DictationSession( deviceUID: PinnedInput.uid, - vocabulary: configStore.config.vocabulary, + vocabulary: configStore.config.effectiveVocabulary, replacements: configStore.config.replacements, audioBackendProvider: { [weak coordinator] in coordinator?.runningAudioBackend }, prefixSpace: needsLeadingSpace() @@ -213,7 +213,7 @@ final class DictationController { rewriteGeneration += 1 let generation = rewriteGeneration let takeStamp = takeStack.last?.endedAt - let vocabulary = config.vocabulary + let vocabulary = config.effectiveVocabulary let rewriteConfig = config.rewrite let model = rewriteModel let log = self.log diff --git a/Sources/TingleCore/StatusItemController.swift b/Sources/TingleCore/StatusItemController.swift index f208f10..54c220d 100644 --- a/Sources/TingleCore/StatusItemController.swift +++ b/Sources/TingleCore/StatusItemController.swift @@ -118,8 +118,11 @@ final class StatusItemController: NSObject, NSMenuDelegate { // All configuration is the JSON file (live-reloaded on save); // this opens it in the default editor. let editConfigItem = NSMenuItem(title: "Edit config…", action: #selector(openConfigFile), keyEquivalent: ",") + let viewDefaultsItem = NSMenuItem(title: "View default config…", action: #selector(openDefaultConfigFile), keyEquivalent: "") editConfigItem.target = self menu.addItem(editConfigItem) + viewDefaultsItem.target = self + menu.addItem(viewDefaultsItem) // Version + updates are always visible (discoverability); the // check is only actionable in the installed .app — Sparkle cannot @@ -544,6 +547,10 @@ final class StatusItemController: NSObject, NSMenuDelegate { NSWorkspace.shared.open(ConfigStore.configURL) } + @objc private func openDefaultConfigFile() { + NSWorkspace.shared.open(ConfigStore.defaultConfigURL) + } + // MARK: - Launch at Login private func updateLaunchAtLoginState() { diff --git a/Tests/tingle-tests/ConfigTests.swift b/Tests/tingle-tests/ConfigTests.swift index bb20588..e8947c4 100644 --- a/Tests/tingle-tests/ConfigTests.swift +++ b/Tests/tingle-tests/ConfigTests.swift @@ -9,8 +9,9 @@ func runConfigTests() { expect(config.vocabulary.contains("TOML"), "config: TOML never Tamil again") expect(config.vocabulary.contains("Claude"), "config: Claude in vocabulary") expect(config.vocabulary.contains("subagent"), "config: agentic terms present") - expectEqual(Set(config.vocabulary), Set(ConfigStore.defaultVocabulary), - "config: TOML template and Swift default vocabulary stay in sync") + expect(config.extraVocabulary.isEmpty, "config: defaults ship no extra vocabulary") + expectEqual(config.effectiveVocabulary, config.vocabulary, + "config: effective vocabulary is builtin when extras empty") expectEqual(config.mappings["triggerDown"], .dictate, "config: default trigger mapping") expectEqual(config.mappings["modeChange"], .eraseDictation, "config: default green mapping") expectEqual(config.mappings["fxChange"], .keystroke(key: "return", modifiers: []), "config: default orange mapping") @@ -24,6 +25,53 @@ func runConfigTests() { expectEqual(config.action(for: .whitePress(mode: 3)), config.mappings["white"], "config: whitePress falls back to the white catch-all") } + do { // layered parse: user file overlays defaults with per-key precedence + let empty = try! TingConfig.parse(defaults: ConfigStore.defaultTOML, user: "") + expectEqual(empty.effectiveVocabulary, TingConfig.default.effectiveVocabulary, + "layering: empty user file yields pure defaults") + expectEqual(empty.mappings["triggerDown"], .dictate, "layering: default mappings survive empty overlay") + + let starter = try! TingConfig.parse(defaults: ConfigStore.defaultTOML, + user: ConfigStore.userTemplateTOML) + expectEqual(starter.effectiveVocabulary, TingConfig.default.effectiveVocabulary, + "layering: starter template changes nothing") + + let overlay = try! TingConfig.parse(defaults: ConfigStore.defaultTOML, user: """ + extraVocabulary = ["Metabase", "TOML"] + + [rewrite] + enabled = true + + [mappings] + mode1 = { type = "keystroke", key = "escape" } + fxChange = { type = "keystroke", key = "tab" } + """) + // section tables merge per key: the one switched key changes... + expect(overlay.rewrite.enabled, "layering: user rewrite.enabled wins") + // ...and untouched keys still come from defaults, not decode zeroes. + expect(overlay.rewrite.removeFillers, "layering: untouched rewrite keys keep defaults") + expect(overlay.rewrite.correctVocabulary, "layering: untouched rewrite keys keep defaults 2") + expectEqual(overlay.mappings["mode1"], .keystroke(key: "escape", modifiers: []), + "layering: new mapping added") + expectEqual(overlay.mappings["triggerDown"], .dictate, + "layering: unmentioned mappings survive") + // inline action tables replace WHOLESALE - no field bleed-through + // from the default action into the user's replacement. + expectEqual(overlay.mappings["fxChange"], .keystroke(key: "tab", modifiers: []), + "layering: overridden mapping replaced wholesale") + // extraVocabulary concatenates after the builtin list, deduped. + expect(overlay.effectiveVocabulary.contains("Metabase"), "layering: extra vocabulary appended") + expectEqual(overlay.effectiveVocabulary.filter { $0 == "TOML" }.count, 1, + "layering: effective vocabulary dedupes") + expect(overlay.effectiveVocabulary.count > 100, "layering: builtin vocabulary retained") + + // a user list key overrides wholesale - the documented escape hatch. + let replaced = try! TingConfig.parse(defaults: ConfigStore.defaultTOML, user: """ + vocabulary = ["OnlyWord"] + """) + expectEqual(replaced.effectiveVocabulary, ["OnlyWord"], + "layering: user vocabulary key discards builtins wholesale") + } do { // multiline embedded script + all action types let toml = """ vocabulary = ["Cubilux"] diff --git a/Tests/tingle-tests/main.swift b/Tests/tingle-tests/main.swift index e937dc9..f4c7691 100644 --- a/Tests/tingle-tests/main.swift +++ b/Tests/tingle-tests/main.swift @@ -57,7 +57,7 @@ if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--rewrite-eval var config = RewriteConfig() config.enabled = true config.technicalFormatting = true - let instructions = RewritePrompt.instructions(config: config, vocabulary: ConfigStore.defaultVocabulary) + let instructions = RewritePrompt.instructions(config: config, vocabulary: TingConfig.default.effectiveVocabulary) let model = FoundationRewriteModel() guard model.isAvailable else { print("Foundation model unavailable (Apple Intelligence off?)"); exit(1) } let sema = DispatchSemaphore(value: 0) From 3e6842b69e1db204c2746c0a87bc5c9f34af440e Mon Sep 17 00:00:00 2001 From: Josh Gruenstein Date: Sun, 12 Jul 2026 02:54:23 -0400 Subject: [PATCH 2/2] docs: 3.5mm lingo, not wireless --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ab2b5f5..9befa4f 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@ the mic, say what you want, put it down. Under the hood, a tiny MicroPython event engine installs onto the ting's own disk in one click (no firmware modification, fully reversible). Button -presses reach your Mac instantly over USB, or — when the ting is untethered -on batteries — over an inaudible ultrasonic chirp protocol on the audio -cable. You never think about any of that; you just squeeze and talk. +presses reach your Mac instantly over USB, or — with no USB attached, the +ting on batteries — over an inaudible ultrasonic chirp protocol on the +3.5mm audio cable. You never think about any of that; you just squeeze +and talk. ## Requirements @@ -151,8 +152,8 @@ output is rejected and your original text stays). The ting executes user Python from its USB disk at boot. tingle ships an event engine that chains the stock firmware behavior, then reports button -and trigger events as serial lines (docked) and as ultrasonic codewords -(wireless): every event is four 25ms chirp symbols carrying an +and trigger events as serial lines (over USB) and as ultrasonic codewords +(over the 3.5mm audio cable): every event is four 25ms chirp symbols carrying an error-correcting code, matched-filter decoded on the Mac — a corrupted symbol self-corrects, and noise can never turn one button into another. A state-carrying heartbeat every 2 seconds acts as a pilot signal: it