Skip to content
Closed
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f63e5fe
Add CmuxVoice package: dictation state machine, transcript model, on-…
austinywang Jul 14, 2026
105eedf
Register toggleVoiceDictation shortcut (default ⌃⌘V)
austinywang Jul 14, 2026
9a5d2f6
Wire voice dictation into the app: insertion router, HUD, coordinator…
austinywang Jul 14, 2026
cca9d29
Voice dictation: usage descriptions, localization, and docs
austinywang Jul 14, 2026
bbc5f93
Fix dictation stop/failure races: stop-during-startup teardown, termi…
austinywang Jul 14, 2026
8a64beb
Address review findings: engine failure surfacing, insertion feedback…
austinywang Jul 14, 2026
bcbcb8f
Harden voice dictation startup and shutdown
austinywang Aug 25, 2026
b39721e
Bound dictation streams and validate insertion targets
austinywang Aug 25, 2026
0d4e1ac
Harden dictation lifecycle and target pinning
austinywang Aug 25, 2026
f0acd44
Close dictation cancellation and localization gaps
austinywang Aug 25, 2026
9f10974
Bound WebKit and realtime dictation handoffs
austinywang Aug 26, 2026
3ef01d3
Fail closed on dictation backpressure and focus drift
austinywang Aug 26, 2026
97bf57c
Clarify audio handoff framework imports
austinywang Aug 26, 2026
b614aed
Validate dictation locales and sensitive targets
austinywang Aug 26, 2026
12a3a5d
test: cover voice shutdown and locale boundaries
austinywang Aug 26, 2026
1990302
fix: close voice review and CI gaps
austinywang Aug 26, 2026
3a7211a
ci: allowlist pre-existing irx liveness test debt
austinywang Aug 26, 2026
b1d1f85
fix: polish Japanese voice shortcut label
austinywang Aug 26, 2026
a2586d8
fix: close remaining voice dictation review gaps
austinywang Aug 27, 2026
c4d29e1
fix: preserve speech finalization and partial backpressure
austinywang Aug 27, 2026
5500470
fix: harden speech routing and finalization
austinywang Aug 27, 2026
adfec2c
fix: close remaining voice dictation review gaps
austinywang Sep 1, 2026
fd310e5
chore: wire voice runtime composition owner
austinywang Sep 1, 2026
f7a0a7f
fix: remove duplicate voice source entries
austinywang Sep 1, 2026
fefc7e2
fix: consume legacy shortcut generation result
austinywang Sep 2, 2026
157ba85
chore: preserve main shortcut helper split and voice localizations
austinywang Sep 9, 2026
a925ad9
chore: align linearized tree with vetted feature tip
austinywang Sep 9, 2026
8f3632f
Merge origin/main into feat-voice-dictation
austinywang Sep 9, 2026
0aa8cd8
fix: guard voice dictation cycles and secure inputs
austinywang Sep 9, 2026
295c7df
fix: fail dictation when audio input disappears
austinywang Sep 9, 2026
77fa824
ci: allowlist deterministic retry policy sleeps
austinywang Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/test-determinism-allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Remove a line once the underlying test is determinized.
Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift sleep-then-assert temporary outage debt owned by #8931; remove when determinized
Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift sleep-then-assert temporary outage debt owned by #9163; remove when determinized
Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxLiveQUICTests.swift sleep-then-assert pre-existing main debt from #10782; remove when determinized
cmuxTests/WorkspaceForkConversationContextMenuTests.swift assert-on-duration grandfathered
cmuxTests/WorkspaceForkConversationContextMenuTests.swift sleep-then-assert grandfathered
tests/test_ci_sparkle_build_monotonic.sh live-network-host release pretag guard intentionally probes the published appcast and soft-passes offline
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1737,6 +1737,7 @@ jobs:
CmuxTerminal
CmuxTerminalCore
CmuxUpdater
CmuxVoice
CMUXAgentLaunch
CmuxAgentJournal
CmuxFilePreviewCore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ public struct SettingCatalog: SettingCatalogSection {
/// Settings for custom (user/agent-authored) sidebars (the `customSidebars.*` keys).
public let customSidebars = CustomSidebarsCatalogSection()
public let shortcuts = KeyboardShortcutsCatalogSection()
/// Settings for voice dictation (the `voice.*` keys).
public let voice = VoiceCatalogSection()
public let integrations = IntegrationsCatalogSection()
public let account = AccountCatalogSection()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

/// Settings for cmux voice dictation (the `voice.*` keys).
///
/// Dictation transcribes speech fully on device and types it into the
/// focused pane; these keys gate the feature and pick its language.
public struct VoiceCatalogSection: SettingCatalogSection {
/// Master switch for voice dictation. While off, the dictation
/// shortcut and every other entry point are inert.
public let dictationEnabled = DefaultsKey<Bool>(
id: "voice.dictationEnabled",
defaultValue: true,
userDefaultsKey: "voice.dictationEnabled"
)

/// BCP-47 identifier of the dictation language (for example `en-US`).
/// Empty string means "follow the system locale".
public let dictationLanguage = DefaultsKey<String>(
id: "voice.dictationLanguage",
defaultValue: "",
userDefaultsKey: "voice.dictationLanguage"
)

/// Whether the one-time "Set Up Voice" explainer has been accepted.
/// Not shown in the Settings UI; flipped by the setup dialog.
public let dictationSetupCompleted = DefaultsKey<Bool>(
id: "voice.dictationSetupCompleted",
defaultValue: false,
userDefaultsKey: "voice.dictationSetupCompleted"
)

public init() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ extension ShortcutAction {
case .closeWindow: return ShortcutStroke(key: "w", command: true, control: true)
case .toggleFullScreen: return ShortcutStroke(key: "f", command: true, control: true)
case .quit: return ShortcutStroke(key: "q", command: true)
case .toggleVoiceDictation: return ShortcutStroke(key: "v", command: true, control: true)
case .toggleSidebar: return ShortcutStroke(key: "b", command: true)
case .newTab: return ShortcutStroke(key: "n", command: true)
case .newBrowserWorkspace: return ShortcutStroke(key: "n", command: true, option: true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ extension ShortcutAction {
case .closeWindow: return "Close Window"
case .toggleFullScreen: return "Toggle Full Screen"
case .quit: return "Quit cmux"
case .toggleVoiceDictation:
return String(localized: "shortcut.toggleVoiceDictation.label", defaultValue: "Toggle Voice Dictation")
case .toggleSidebar: return "Toggle Left Sidebar"
case .newTab: return "New Workspace"
case .newBrowserWorkspace:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ extension ShortcutAction {
public var group: Group {
switch self {
case .openSettings, .reloadConfiguration, .showHideAllWindows, .globalSearch,
.newWindow, .closeWindow, .toggleFullScreen, .quit:
.newWindow, .closeWindow, .toggleFullScreen, .quit, .toggleVoiceDictation:
return .app
case .toggleSidebar, .newTab, .newBrowserWorkspace, .saveLayoutTemplate, .openFolder, .reopenPreviousSession, .goToWorkspace,
.commandPalette, .commandPaletteNext, .commandPalettePrevious, .sendFeedback,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ extension ShortcutAction {
/// - candidate: The configured shortcut, or `nil` when no override exists.
/// - hostDefault: An optional host-owned default. Pass
/// ``StoredShortcut/unbound`` to explicitly disable the built-in value.
///
/// ``toggleVoiceDictation`` is suppressed when its implicit default overlaps
/// an older explicit binding; an explicit voice candidate remains unchanged.
public func effectivePersistedShortcutResolvingLegacyConflicts(
_ candidate: StoredShortcut?,
defaultShortcut hostDefault: StoredShortcut? = nil,
Expand Down Expand Up @@ -59,8 +62,36 @@ extension ShortcutAction {
}
guard candidate != resolved,
let normalizedDefault = (hostDefault ?? defaultShortcut).flatMap(normalizing),
resolved == normalizedDefault,
let legacyAction = legacyActionDisplacingBuiltInDefault,
resolved == normalizedDefault else {
return resolved
}

// New actions must not retroactively claim a stroke that an older
// explicit binding already owns. Voice dictation is the first action
// with this migration requirement; keep the rule here so the runtime
// and Settings UI share one resolver and one conflict policy.
if self == .toggleVoiceDictation,
candidate == nil,
ShortcutAction.allCases.contains(where: { configuredAction in
guard configuredAction != self,
let configuredShortcut = explicitlyConfiguredShortcut(configuredAction),
!configuredShortcut.isUnbound,
configuredAction.shortcutBindingPolicyResult(
for: configuredShortcut
) == .accepted else {
return false
}
let normalizedConfiguredShortcut = configuredShortcut.canonicalized()
return bindingsConflict(
resolved,
configuredAction,
normalizedConfiguredShortcut
)
}) {
return nil
}

guard let legacyAction = legacyActionDisplacingBuiltInDefault,
let legacyShortcut = explicitlyConfiguredShortcut(legacyAction),
legacyShortcut.isUnbound
|| bindingsConflict(resolved, legacyAction, legacyShortcut) else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod
case closeWindow
case toggleFullScreen
case quit
/// Starts/stops voice dictation into the focused pane.
case toggleVoiceDictation

// MARK: Workspace
case toggleSidebar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has
case customSidebars
case betaFeatures
case automation
/// Voice dictation: enable gate and language.
case voice
/// Local computer-use integration, permissions, and menu-bar controls.
case computerUse
case browser
Expand Down Expand Up @@ -54,6 +56,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has
case .customSidebars: return String(localized: "settings.section.customSidebars", defaultValue: "Custom Sidebars")
case .betaFeatures: return "Beta Features"
case .automation: return "Automation"
case .voice: return String(localized: "settings.section.voice", defaultValue: "Voice")
case .computerUse: return String(localized: "settings.section.computerUse", defaultValue: "Computer Use")
case .browser: return "Browser"
case .browserImport: return "Import Browser Data"
Expand All @@ -80,6 +83,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has
case .customSidebars: return "sidebar.squares.left"
case .betaFeatures: return "exclamationmark.triangle"
case .automation: return "wand.and.sparkles"
case .voice: return "mic"
case .computerUse: return "cursorarrow.rays"
case .browser: return "globe"
case .browserImport: return "square.and.arrow.down"
Expand Down Expand Up @@ -108,6 +112,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has
case .customSidebars: return "custom sidebars vibe swift json interpreted renderer in-process remote worker isolated"
case .betaFeatures: return "beta experimental unstable feed dock right sidebar"
case .automation: return "socket integrations hooks ports claude cursor gemini naming auto naming workspace tabs"
case .voice: return "voice dictation speech microphone speak transcribe transcription on-device language"
case .computerUse:
return String(
localized: "settings.search.keywords.computerUse",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public final class SettingsSectionMountModel {
/// its own, so it never appears here.
public static let displayOrder: [SettingsSectionID] = [
.account, .app, .terminal, .textBox, .sleepyMode, .mobile, .cloudMachines,
.networking, .sidebarAppearance, .customSidebars, .betaFeatures, .automation,
.networking, .sidebarAppearance, .customSidebars, .betaFeatures, .voice, .automation,
.computerUse, .browser, .globalHotkey, .keyboardShortcuts, .workspaceColors,
.settingsJSON, .reset,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ extension SettingsWindowRoot {
BetaFeaturesSection(defaultsStore: defaultsStore, catalog: catalog)
}

slot(.voice, proxy: proxy) {
VoiceSection(defaultsStore: defaultsStore, catalog: catalog)
}

slot(.automation, proxy: proxy) {
AutomationSection(
defaultsStore: defaultsStore,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import CmuxSettings
import Speech
import SwiftUI

/// **Voice** section — the voice-dictation master toggle and language
/// picker. Dictation is fully on-device; the copy says so explicitly.
@MainActor
public struct VoiceSection: View {
@State private var enabled: DefaultsValueModel<Bool>
@State private var language: DefaultsValueModel<String>
@State private var availableLanguages: [VoiceDictationLanguageChoice] = []

public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog) {
_enabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.voice.dictationEnabled))
_language = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.voice.dictationLanguage))
}

public var body: some View {
Group {
SettingsSectionHeader(String(localized: "settings.section.voice", defaultValue: "Voice"), section: .voice)
SettingsCard {
enabledRow
SettingsCardDivider()
languageRow
}
}
.task {
enabled.startObserving()
language.startObserving()
availableLanguages = await VoiceDictationLanguageChoice.systemChoices()
}
}

@ViewBuilder
private var enabledRow: some View {
SettingsCardRow(
configurationReview: .settingsOnly,
searchAnchorID: "setting:voice:dictationEnabled",
String(localized: "settings.voice.dictationEnabled", defaultValue: "Voice Dictation"),
subtitle: enabled.current
? String(localized: "settings.voice.dictationEnabled.subtitleOn", defaultValue: "Press the dictation shortcut (default ⌃⌘V) to speak into the focused pane. Speech is transcribed on this Mac and never leaves the device.")
: String(localized: "settings.voice.dictationEnabled.subtitleOff", defaultValue: "The dictation shortcut is inert until you enable voice dictation here.")
) {
Toggle("", isOn: Binding(get: { enabled.current }, set: { enabled.set($0) }))
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsVoiceDictationToggle")
}
}

@ViewBuilder
private var languageRow: some View {
SettingsCardRow(
configurationReview: .settingsOnly,
searchAnchorID: "setting:voice:dictationLanguage",
String(localized: "settings.voice.dictationLanguage", defaultValue: "Dictation Language"),
subtitle: String(localized: "settings.voice.dictationLanguage.subtitle", defaultValue: "Languages with on-device speech recognition on this Mac. The model downloads on first use.")
) {
Picker("", selection: Binding(get: { language.current }, set: { language.set($0) })) {
Text(String(localized: "settings.voice.dictationLanguage.system", defaultValue: "System Default"))
.tag("")
ForEach(availableLanguages) { choice in
Text(choice.displayName).tag(choice.identifier)
}
}
.labelsHidden()
.controlSize(.small)
.frame(maxWidth: 220)
.accessibilityIdentifier("SettingsVoiceDictationLanguagePicker")
}
}
}

/// One selectable dictation language.
nonisolated struct VoiceDictationLanguageChoice: Identifiable, Hashable, Sendable {
let identifier: String
let displayName: String

var id: String { identifier }

/// Languages the current OS can transcribe on device, sorted by
/// localized display name.
@concurrent static func systemChoices() async -> [VoiceDictationLanguageChoice] {
let locales: [Locale]
if #available(macOS 26.0, *) {
locales = await SpeechTranscriber.supportedLocales
} else {
locales = SFSpeechRecognizer.supportedLocales().filter { locale in
// The system list also contains languages that can only use
// Apple's network recognizer. Voice dictation promises
// on-device processing, so do not offer those choices.
guard let recognizer = SFSpeechRecognizer(locale: locale),
recognizer.locale.identifier(.bcp47) == locale.identifier(.bcp47)
else { return false }
return recognizer.supportsOnDeviceRecognition
}
}
let current = Locale.current
return locales
.map { locale in
VoiceDictationLanguageChoice(
identifier: locale.identifier,
displayName: current.localizedString(forIdentifier: locale.identifier)
?? locale.identifier
)
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,69 @@ import Testing
#expect(model.effective(for: .reopenClosedBrowserPanel) == nil)
}

@Test func legacyBindingDisplacesVoiceDictationDefault() throws {
let voiceDefault = try #require(ShortcutAction.toggleVoiceDictation.defaultShortcut)
let existingAction = ShortcutAction.toggleSidebar
let (defaultsStore, suiteName) = try makeDefaultsStore(
legacyBindings: [existingAction: voiceDefault]
)
defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) }

let model = ShortcutListModel(
jsonStore: makeJSONStore(),
userDefaultsStore: defaultsStore,
catalog: SettingCatalog(),
errorLog: SettingsErrorLog()
)

#expect(model.effective(for: existingAction) == voiceDefault)
#expect(
model.effective(for: .toggleVoiceDictation) == nil,
"Settings must not advertise a newly introduced default over an existing binding"
)
}

@Test func explicitVoiceBindingRemainsAuthoritativeOverLegacyCollision() throws {
let voiceDefault = try #require(ShortcutAction.toggleVoiceDictation.defaultShortcut)
let existingAction = ShortcutAction.toggleSidebar
let (defaultsStore, suiteName) = try makeDefaultsStore(
legacyBindings: [
existingAction: voiceDefault,
.toggleVoiceDictation: voiceDefault,
]
)
defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) }

let model = ShortcutListModel(
jsonStore: makeJSONStore(),
userDefaultsStore: defaultsStore,
catalog: SettingCatalog(),
errorLog: SettingsErrorLog()
)

#expect(model.effective(for: .toggleVoiceDictation) == voiceDefault)
}

@Test func prioritySidebarBindingDoesNotDisplaceVoiceDefault() throws {
let voiceDefault = try #require(ShortcutAction.toggleVoiceDictation.defaultShortcut)
let (defaultsStore, suiteName) = try makeDefaultsStore(
legacyBindings: [.switchRightSidebarToDock: voiceDefault]
)
defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) }

let model = ShortcutListModel(
jsonStore: makeJSONStore(),
userDefaultsStore: defaultsStore,
catalog: SettingCatalog(),
errorLog: SettingsErrorLog()
)

#expect(
model.effective(for: .toggleVoiceDictation) == voiceDefault,
"A priority-routed sidebar binding coexists with voice outside sidebar focus"
)
}

@Test func jsonWorkspaceUnbindingDisplacesReopenLastClosedDefault() async throws {
let action = ShortcutAction.reopenClosedWorkspace
let (defaultsStore, suiteName) = try makeDefaultsStore(legacyBindings: [:])
Expand Down
Loading
Loading