Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e02101e
iOS voice mode: GPT-Live orchestrator and terminal voice sessions
azooz2003-bit Sep 21, 2026
43b28ae
reload-build: cap Xcode selection at the pinned toolchain major
azooz2003-bit Sep 22, 2026
bbf5a5a
Voice mode round 2: composer entrypoint, full orchestrator capabilities
azooz2003-bit Sep 25, 2026
1b22dbb
Voice orchestrator: full app capability surface + voice-exposure rule
azooz2003-bit Sep 26, 2026
185fdfc
Voice: persistent user memory and session-start context awareness
azooz2003-bit Sep 26, 2026
e3b6757
Voice create_task: a missing directory is the Mac's default, never a …
azooz2003-bit Sep 26, 2026
4e826bc
Voice memory: move from UserDefaults to an on-disk JSON store
azooz2003-bit Sep 26, 2026
7b3b651
Merge remote-tracking branch 'origin/main' into feat-ios-gpt-live-voice
azooz2003-bit Sep 26, 2026
ee4bc1a
Voice: review-sweep fixes across audio, approvals, and entrypoints
azooz2003-bit Sep 26, 2026
78136de
Voice: conform to iOS package conventions
azooz2003-bit Sep 26, 2026
85fbf0a
Update stale beta compat-copy expectation for the 1.0.6 baked floor
azooz2003-bit Sep 26, 2026
551a605
Merge remote-tracking branch 'origin/main' into feat-ios-gpt-live-voice
azooz2003-bit Sep 28, 2026
aa2de7a
Voice: directory search tool, agent-list context, approval-pinning tests
azooz2003-bit Sep 28, 2026
6a5d7fc
Voice: distinguish ambiguous workspace references from unknown ones
azooz2003-bit Sep 28, 2026
3940f22
Voice: fix the deaf microphone (audio-before-started, VP silence)
azooz2003-bit Sep 28, 2026
1d39162
Voice: on-screen debug pipeline counters and a session greeting
azooz2003-bit Sep 28, 2026
ecb4235
Voice: make orchestrator autonomous and wait-aware
azooz2003-bit Sep 29, 2026
32f60eb
Voice: retain updated agent completion summaries
azooz2003-bit Sep 29, 2026
f9c5e65
Voice: expose audio pipeline boundaries in debug footer
azooz2003-bit Sep 29, 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
12 changes: 11 additions & 1 deletion .github/workflows/reload-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,17 @@ jobs:
- name: Select Xcode
run: |
set -euo pipefail
./scripts/select-ci-xcode.sh
# Cap the newest-SDK ranking at the repo's pinned toolchain major:
# hosted images now also carry the Xcode 27 RC, whose SDK main's
# iOS SwiftUI does not compile under (toolbarMinimizeBehavior).
pin_major="$(cut -d. -f1 .xcode-version 2>/dev/null || true)"
# An empty pin would silently lift the cap and let the selector
# pick the incompatible newer SDK; fail loudly instead.
if [ -z "$pin_major" ]; then
echo "::error::.xcode-version missing or unreadable on this ref; refusing to select an uncapped Xcode" >&2
exit 1
fi
CMUX_CI_MAX_MACOS_SDK_MAJOR="$pin_major" ./scripts/select-ci-xcode.sh

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
Expand Down
13 changes: 13 additions & 0 deletions Packages/iOS/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ cross-tag Mac access, and dev auth profiles live in `ios/AGENTS.md` at the
repository root. They apply to work under `Packages/iOS/` as well, so read that
file too.

## Expose every capability to voice mode

Every new iOS feature or capability, however small, must also be reachable
through voice mode. When you add or change something a user can do in the app,
extend the orchestrator's tool surface in
`Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Voice/VoiceOrchestratorTools.swift`
(declaration, execution against the shell store, and a permission tier in
`VoiceToolPermission.swift`: read / act / destructive, where destructive means
the on-screen approval card) plus the permission-tier test in
`CmuxMobileShellUITests/VoiceToolPermissionTests.swift`. If a capability
genuinely cannot be voiced (pure visual output, gesture-only interactions),
say so in the PR description instead of skipping silently.

## Follow the Apple Human Interface Guidelines

Before you add or change iOS UI, fetch and read the Apple Human Interface
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ struct GhosttySurfaceRepresentable: UIViewControllerRepresentable {
var onArtifactPathTapped: @MainActor (_ path: String) -> Void = { _ in }
var onVisibleArtifactCountChanged: @MainActor (_ count: Int) -> Void = { _ in }
var onArtifactGalleryRefreshSignal: @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void = { _ in }
/// Opens the workspace's voice-mode sheet. Nil when voice mode is off,
/// which keeps the composer's dictation mic in place instead.
var onOpenVoiceMode: (@MainActor () -> Void)? = nil

func makeUIViewController(context: Context) -> UIViewController {
let runtime: GhosttyRuntime
Expand Down Expand Up @@ -157,6 +160,7 @@ struct GhosttySurfaceRepresentable: UIViewControllerRepresentable {
context.coordinator.onArtifactPathTapped = onArtifactPathTapped
context.coordinator.onVisibleArtifactCountChanged = onVisibleArtifactCountChanged
context.coordinator.onArtifactGalleryRefreshSignal = onArtifactGalleryRefreshSignal
context.coordinator.onOpenVoiceMode = onOpenVoiceMode
context.coordinator.terminalFolderTapEnabled = terminalFolderTapEnabled
let artifactCountModeChanged = context.coordinator.updateArtifactCountMode(
artifactFilesEnabled: artifactFilesEnabled,
Expand Down Expand Up @@ -219,6 +223,10 @@ struct GhosttySurfaceRepresentable: UIViewControllerRepresentable {
var onArtifactPathTapped: @MainActor (_ path: String) -> Void
var onVisibleArtifactCountChanged: @MainActor (_ count: Int) -> Void
var onArtifactGalleryRefreshSignal: @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void
/// Live voice-mode entrypoint; the composer reads it through a
/// provider closure so availability changes reach an already-mounted
/// composer. Nil = voice mode off.
var onOpenVoiceMode: (@MainActor () -> Void)?
private var outputTask: Task<Void, Never>?
private var outputConsumerOwnerID: UUID?
/// Monotonic owner for the mounted output consumer. A stream can end
Expand Down Expand Up @@ -1579,6 +1587,9 @@ struct GhosttySurfaceRepresentable: UIViewControllerRepresentable {
},
photoPickerDidDismiss: { [weak self] in
self?.surfaceView?.photoPickerDidDismiss()
},
voiceModeAction: { [weak self] in
self?.onOpenVoiceMode
}
)
let controller = UIHostingController(rootView: view)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct MobilePrimaryTabScaffold<
@Bindable var searchCoordinator: MobilePrimarySearchCoordinator
let notificationUnreadCount: Int
let taskComposerAction: (() -> Void)?
let voiceModeAction: (() -> Void)?
let workspaces: Workspaces
let notifications: Notifications
let search: Search
Expand All @@ -23,6 +24,7 @@ struct MobilePrimaryTabScaffold<
searchCoordinator: MobilePrimarySearchCoordinator,
notificationUnreadCount: Int,
taskComposerAction: (() -> Void)? = nil,
voiceModeAction: (() -> Void)? = nil,
@ViewBuilder workspaces: () -> Workspaces,
@ViewBuilder notifications: () -> Notifications,
@ViewBuilder search: () -> Search
Expand All @@ -31,6 +33,7 @@ struct MobilePrimaryTabScaffold<
self.searchCoordinator = searchCoordinator
self.notificationUnreadCount = notificationUnreadCount
self.taskComposerAction = taskComposerAction
self.voiceModeAction = voiceModeAction
self.workspaces = workspaces()
self.notifications = notifications()
self.search = search()
Expand All @@ -54,40 +57,66 @@ struct MobilePrimaryTabScaffold<
searchCoordinator.synchronizeSelection(selection)
}

if selection == .workspaces, let taskComposerAction {
TaskComposerButton(
action: taskComposerAction,
diameter: iOS26BottomControlDiameter
)
.padding(.trailing, iOS26BottomControlInset)
.padding(.bottom, iOS26TaskComposerBottomPadding)
// Compose anchors to the screen, not the keyboard. The
// only keyboard that can appear while it is visible
// belongs to an overlaying sheet (the composer's
// auto-focused prompt), whose inset dragged the button
// toward mid-screen and stranded it there whenever the
// hide update was missed.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.ignoresSafeArea(.keyboard, edges: .bottom)
}
bottomTrailingControls(bottomPadding: iOS26TaskComposerBottomPadding)
}
.ignoresSafeArea(.container, edges: .bottom)
} else if #available(iOS 18.0, *) {
TabView(selection: $selection) {
primaryTabs
// Pre-26 OSes get the same floating controls (voice + compose);
// without them these branches would have no orchestrator
// entrypoint at all.
ZStack(alignment: .bottomTrailing) {
TabView(selection: $selection) {
primaryTabs
}
.accessibilityIdentifier("MobilePrimaryTabs")
bottomTrailingControls(bottomPadding: iOS26BottomControlInset)
}
.accessibilityIdentifier("MobilePrimaryTabs")
} else {
TabView(selection: $selection) {
workspaces
.tabItem { workspacesLabel }
.tag(MobilePrimaryTab.workspaces)
notifications
.tabItem { notificationsLabel }
.tag(MobilePrimaryTab.notifications)
.badge(notificationUnreadCount)
ZStack(alignment: .bottomTrailing) {
TabView(selection: $selection) {
workspaces
.tabItem { workspacesLabel }
.tag(MobilePrimaryTab.workspaces)
notifications
.tabItem { notificationsLabel }
.tag(MobilePrimaryTab.notifications)
.badge(notificationUnreadCount)
}
.accessibilityIdentifier("MobilePrimaryTabs")
bottomTrailingControls(bottomPadding: iOS26BottomControlInset)
}
}
}

/// The floating voice + compose stack shared by every OS branch, shown
/// only on the workspaces tab.
@ViewBuilder
private func bottomTrailingControls(bottomPadding: CGFloat) -> some View {
if selection == .workspaces, taskComposerAction != nil || voiceModeAction != nil {
VStack(spacing: iOS26BottomControlSpacing) {
if let voiceModeAction {
VoiceModeButton(
action: voiceModeAction,
diameter: iOS26BottomControlDiameter
)
}
if let taskComposerAction {
TaskComposerButton(
action: taskComposerAction,
diameter: iOS26BottomControlDiameter
)
}
}
.accessibilityIdentifier("MobilePrimaryTabs")
.padding(.trailing, iOS26BottomControlInset)
.padding(.bottom, bottomPadding)
// Compose anchors to the screen, not the keyboard. The
// only keyboard that can appear while it is visible
// belongs to an overlaying sheet (the composer's
// auto-focused prompt), whose inset dragged the button
// toward mid-screen and stranded it there whenever the
// hide update was missed.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.ignoresSafeArea(.keyboard, edges: .bottom)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ struct MobileSettingsView: View {
.accessibilityIdentifier("MobileSettingsTerminalShortcuts")
}

MobileVoiceSettingsSection()

Section {
Toggle(isOn: $displaySettings.hapticFeedbackEnabled) {
Text(L10n.string(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ struct TerminalComposerView: View {
let photoPickerWillPresent: () -> Void
let photoPickerDidPresent: () -> Void
let photoPickerDidDismiss: () -> Void
/// Live provider for the voice-mode entrypoint. Non-nil result replaces
/// the dictation mic with the voice-mode button (voice supersedes
/// transcribe-into-the-field); nil keeps dictation. A provider rather
/// than a captured closure because the composer's hosting controller is
/// built once per mount while voice availability (Settings toggle) can
/// change under it.
var voiceModeAction: @MainActor () -> (@MainActor () -> Void)? = { nil }
/// Mirror of the UIKit editor's first-responder state, written by the
/// editor's coordinator on begin/end editing. Replaces the `@FocusState`
/// the SwiftUI `TextField` used; programmatic focus goes through
Expand Down Expand Up @@ -99,7 +106,8 @@ struct TerminalComposerView: View {
inputFocusChanged: @escaping (Bool) -> Void,
photoPickerWillPresent: @escaping () -> Void,
photoPickerDidPresent: @escaping () -> Void,
photoPickerDidDismiss: @escaping () -> Void
photoPickerDidDismiss: @escaping () -> Void,
voiceModeAction: @escaping @MainActor () -> (@MainActor () -> Void)? = { nil }
) {
self.store = store
self.terminalID = terminalID
Expand All @@ -109,6 +117,7 @@ struct TerminalComposerView: View {
self.photoPickerWillPresent = photoPickerWillPresent
self.photoPickerDidPresent = photoPickerDidPresent
self.photoPickerDidDismiss = photoPickerDidDismiss
self.voiceModeAction = voiceModeAction
_dictation = State(initialValue: ComposerDictationController { event in
Self.recordDictationDiagnostic(event, terminalID: terminalID, store: store)
})
Expand Down Expand Up @@ -346,7 +355,11 @@ struct TerminalComposerView: View {
HStack(alignment: .bottom, spacing: 8) {
attachMenuButton

micButton
if let openVoiceMode = voiceModeAction() {
voiceModeButton(openVoiceMode)
} else {
micButton
}

// The field and its send button share ONE rounded glass container,
// rendered through the same support component as GUI chat. `.bottom`
Expand Down Expand Up @@ -547,10 +560,36 @@ struct TerminalComposerView: View {
}
}

/// Voice-mode button in the mic slot when voice mode is available: it
/// opens the live GPT-Live conversation with the workspace's agent,
/// replacing dictation-into-the-field (speech reaches the agent directly,
/// so transcribing into the text box first is redundant).
private func voiceModeButton(_ openVoiceMode: @escaping @MainActor () -> Void) -> some View {
MobileComposerIconButton(
systemImage: "waveform",
activeSystemImage: "waveform",
isActive: false,
foregroundStyle: AnyShapeStyle(
store.activeTerminalTheme.terminalChromeForegroundColor.opacity(0.78)
),
size: controlHeight,
pulsesWhenActive: false,
isDisabled: false,
accessibilityIdentifier: "MobileComposerVoiceMode",
accessibilityLabel: L10n.string("mobile.voice.button", defaultValue: "Voice Mode")
) {
// Voice owns the audio session; a live dictation capture would
// fight it for the mic.
dictation.cancel()
openVoiceMode()
}
}

/// Mic button for on-device voice dictation, beside the attach button on the
/// leading side. Tapping toggles dictation; while listening it shows a filled,
/// tinted mic. Disabled when the recognizer is unavailable or permission was
/// denied so the user is never left tapping a dead control.
/// denied so the user is never left tapping a dead control. Shown only when
/// voice mode is unavailable; the voice-mode button owns this slot otherwise.
private var micButton: some View {
let listening = dictation.state.isListening
return MobileComposerIconButton(
Expand Down
Loading