diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 0d17f943b65f27..bac33a6d06c3f1 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -85,7 +85,7 @@ import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHisto import { IChatStatusItemService } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusItemService.js'; import { handleTerminalCommandPaste, isTerminalCommandInput } from '../../../../workbench/contrib/chat/browser/chatTerminalCommandPaste.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; -import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js'; +import { ChatSpeechToTextState, DictationSettingId, IChatSpeechToTextService } from '../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js'; import { setupDictationMicGlow } from '../../../../workbench/contrib/chat/browser/speechToText/dictationMicGlow.js'; import { IDictationOnboardingService } from '../../../../workbench/contrib/chat/browser/speechToText/dictationOnboarding.js'; import { ChatVoiceInputModeAction, VoiceInputModeActionViewItem } from '../../../../workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.js'; @@ -982,7 +982,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // keys off `isConnected` only, not the connecting phase. const sessionActive = this.voiceSessionController.isConnected.get(); const pillActive = (dict && voice) || (voice && !dict && !handsFree && sessionActive); - button.classList.toggle('hidden', !sttService.isConfigured || voiceActive || pillActive); + // Honor the shared `dictation.showButton` visibility toggle: hiding the + // button still leaves Cmd/Ctrl+I working (its keybinding is independent). + const buttonShown = this.configurationService.getValue(DictationSettingId.ShowButton) !== false; + button.classList.toggle('hidden', !sttService.isConfigured || voiceActive || pillActive || !buttonShown); }; updateVisibility(); this._register(autorun(reader => { @@ -999,7 +1002,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Both the enable kill-switch and the model selection can change // availability (e.g. an unsupported on-device platform becomes // configured when switching to the cloud backend). - if (e.affectsConfiguration('dictation.enabled') || e.affectsConfiguration('dictation.model')) { + if (e.affectsConfiguration('dictation.enabled') || e.affectsConfiguration('dictation.model') || e.affectsConfiguration(DictationSettingId.ShowButton)) { updateVisibility(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts index f47b07b2d68e1a..1425f86307d208 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts @@ -26,7 +26,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { IChatExecuteActionContext } from './chatExecuteActions.js'; import { IChatWidgetService } from '../chat.js'; -import { ChatSpeechToTextState, IChatSpeechToTextService } from '../speechToText/chatSpeechToTextService.js'; +import { ChatSpeechToTextState, DictationSettingId, IChatSpeechToTextService } from '../speechToText/chatSpeechToTextService.js'; import { buildMicrophoneOptions, IDictationOnboardingService, RESET_DICTATION_ONBOARDING_COMMAND, SHOW_DICTATION_ONBOARDING_COMMAND } from '../speechToText/dictationOnboarding.js'; import { cancelDictation, isDictating, startDictation, stopDictation } from '../speechToText/dictationSession.js'; @@ -37,6 +37,13 @@ export const ChatSpeechToTextConfigured = ContextKeyExpr.and(ChatContextKeys.ena /** True while the selected dictation backend is preparing. */ export const ChatSpeechToTextPreparing = ContextKeyExpr.has(ChatContextKeys.speechToTextPreparing.key); const ChatSpeechToTextMaiBackend = ContextKeyExpr.equals('config.dictation.model', 'mai'); +/** + * True unless the user has hidden the chat-input dictation microphone button via + * {@link DictationSettingId.ShowButton}. Gates only the toolbar button; the + * Cmd/Ctrl+I dictation shortcut stays available so dictation can still be + * launched when the button is hidden. + */ +const ChatSpeechToTextButtonShown = ContextKeyExpr.notEquals(`config.${DictationSettingId.ShowButton}`, false); /** Releases shorter than this are treated as an accidental tap and discarded. */ const HOLD_TO_TALK_THRESHOLD_MS = 500; @@ -144,7 +151,7 @@ export class ToggleChatSpeechToTextAction extends Action2 { menu: [{ id: MenuId.ChatExecute, order: -11, - when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextPreparing.negate(), AGENTS_VOICE_CONNECTED.negate(), SegmentedVoiceInputModePillInactive), + when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextButtonShown, ChatSpeechToTextPreparing.negate(), AGENTS_VOICE_CONNECTED.negate(), SegmentedVoiceInputModePillInactive), group: 'navigation', }], keybinding: { @@ -200,7 +207,7 @@ export class ChatSpeechToTextPreparingAction extends Action2 { menu: [{ id: MenuId.ChatExecute, order: -11, - when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextPreparing, ChatSpeechToTextMaiBackend.negate(), AGENTS_VOICE_CONNECTED.negate(), SegmentedVoiceInputModePillInactive), + when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextButtonShown, ChatSpeechToTextPreparing, ChatSpeechToTextMaiBackend.negate(), AGENTS_VOICE_CONNECTED.negate(), SegmentedVoiceInputModePillInactive), group: 'navigation', }], }); @@ -228,7 +235,7 @@ export class ChatSpeechToTextConnectingAction extends Action2 { menu: [{ id: MenuId.ChatExecute, order: -11, - when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextPreparing, ChatSpeechToTextMaiBackend, AGENTS_VOICE_CONNECTED.negate(), SegmentedVoiceInputModePillInactive), + when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextButtonShown, ChatSpeechToTextPreparing, ChatSpeechToTextMaiBackend, AGENTS_VOICE_CONNECTED.negate(), SegmentedVoiceInputModePillInactive), group: 'navigation', }], }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 9e5287a95a35f3..3500df1c86d770 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -305,6 +305,12 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'] }, + [DictationSettingId.ShowButton]: { + type: 'boolean', + markdownDescription: nls.localize('dictation.showButton', "Controls whether the dictation microphone button is shown in the chat input. When hidden, dictation can still be started with its keyboard shortcut."), + default: true, + tags: ['experimental'] + }, 'dictation.experimental.llmCleanup': { type: 'boolean', markdownDescription: nls.localize('dictation.experimental.llmCleanup', "Experimental: when dictation ends, the final transcript is passed through a small language model to restore punctuation, capitalization, paragraphs, and lists. Requires Copilot to be enabled; the transcript is sent to the language model for cleanup. Falls back to the raw transcript when no model is available. Use [dictation instructions](command:{0}) to customize terminology and formatting.", CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID), diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index 7f4967a970d33a..2c501e9ebb6e65 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -101,6 +101,7 @@ export const DICTATION_MODEL_SETTING = 'dictation.model'; export const enum DictationSettingId { ShowTranscript = 'dictation.showTranscript', + ShowButton = 'dictation.showButton', } /** `dictation.model` sentinel selecting the cloud voice backend used by Voice Mode. */ diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts index 60116a95917801..ebdc0a496c482c 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts @@ -14,6 +14,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID, CONFIGURE_VOICE_INSTRUCTIONS_ACTION_ID } from '../actions/configureVoiceInstructionsAction.js'; +import { DictationSettingId } from './chatSpeechToTextService.js'; import { SHOW_DICTATION_ONBOARDING_COMMAND } from './dictationOnboarding.js'; /** Command that opens the microphone picker shared by dictation and Voice Mode. */ @@ -71,6 +72,22 @@ function createShowDictationOnboardingAction(commandService: ICommandService): I }); } +/** + * Checkable "Microphone Button" entry mirroring the action bar visibility toggles: + * checked while the button is shown, unchecking it hides the button. Hiding only + * removes the toolbar affordance — dictation can still be launched with its + * Cmd/Ctrl+I shortcut, and the button can be restored from Settings. + */ +function createToggleDictationButtonAction(configurationService: IConfigurationService): IAction { + const shown = configurationService.getValue(DictationSettingId.ShowButton) !== false; + return toAction({ + id: 'chat.dictation.toggleButton', + label: localize('dictation.microphoneButton', "Microphone Button"), + checked: shown, + run: () => configurationService.updateValue(DictationSettingId.ShowButton, !shown), + }); +} + /** * "Disable" entry for Voice Mode. Tears down any active session first so disabling * the setting doesn't leave the microphone capturing while the toolbar @@ -95,6 +112,7 @@ export function getDictationContextMenuActions(commandService: ICommandService, return Separator.join( [ createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), + createToggleDictationButtonAction(configurationService), createDisableDictationAction(commandService, configurationService), ], [ diff --git a/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts index a3f1684ad19bb0..7ca5b9a89ec210 100644 --- a/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts @@ -34,10 +34,12 @@ suite('Mic button menu actions', () => { }); test('groups and shortens dictation actions', () => { + const configurationService = upcastPartial({ getValue: () => true, updateValue: async () => { } }); const actions = getDictationContextMenuActions(commandService, configurationService, keybindingService, 'dictation.start'); assert.deepStrictEqual(actions.map(action => action.label), [ 'Configure Keybinding', + 'Microphone Button', 'Disable', '', 'Open Settings', @@ -46,4 +48,18 @@ suite('Mic button menu actions', () => { 'Select Microphone', ]); }); + + test('dictation "Microphone Button" toggle reflects and flips the visibility setting', async () => { + const updated: [string, unknown][] = []; + const configurationService = upcastPartial({ + getValue: () => false, + updateValue: async (key: string, value: unknown) => { updated.push([key, value]); }, + }); + const actions = getDictationContextMenuActions(commandService, configurationService, keybindingService, 'dictation.start'); + const toggle = actions.find(action => action.label === 'Microphone Button')!; + + assert.strictEqual(toggle.checked, false); + await toggle.run(); + assert.deepStrictEqual(updated, [['dictation.showButton', true]]); + }); });