From 90eeb3f855775eb382091013a084bae473b54ee6 Mon Sep 17 00:00:00 2001 From: mattthewong Date: Wed, 13 May 2026 10:34:39 -0700 Subject: [PATCH 1/2] feat: add AI feature toggles to menubar dropdown Add four checkbox menu items under an "AI Features" section in the menubar: AI post-processing, Prompt mode, Voice commands, and Context-aware formatting. Each toggle sends a bool on a Go channel following the same pattern as Play sounds and Auto-paste. Users can now enable/disable AI features at runtime from the menubar without restarting vox or editing config files. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/ui/ui_darwin.go | 60 ++++++++++++++++++++++++++---- internal/ui/ui_darwin.m | 79 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 7 deletions(-) diff --git a/internal/ui/ui_darwin.go b/internal/ui/ui_darwin.go index 25601c4..73c597f 100644 --- a/internal/ui/ui_darwin.go +++ b/internal/ui/ui_darwin.go @@ -22,6 +22,10 @@ void uiSetPaused(int on); void uiSetMode(int holdToTalk); void uiSetSoundsEnabled(int on); void uiSetAutoPaste(int on); +void uiSetAIPostProcess(int on); +void uiSetPromptMode(int on); +void uiSetVoiceCommands(int on); +void uiSetContextAware(int on); void uiRun(void); void uiQuit(void); */ @@ -47,13 +51,17 @@ const ( ) var ( - quitCh = make(chan struct{}, 1) - showLogCh = make(chan struct{}, 1) - hotkeyCh = make(chan string, 1) - pauseCh = make(chan bool, 1) - modeCh = make(chan bool, 1) // true = hold-to-talk, false = toggle - soundsCh = make(chan bool, 1) - autoPasteCh = make(chan bool, 1) + quitCh = make(chan struct{}, 1) + showLogCh = make(chan struct{}, 1) + hotkeyCh = make(chan string, 1) + pauseCh = make(chan bool, 1) + modeCh = make(chan bool, 1) // true = hold-to-talk, false = toggle + soundsCh = make(chan bool, 1) + autoPasteCh = make(chan bool, 1) + aiPostProcessCh = make(chan bool, 1) + promptModeCh = make(chan bool, 1) + voiceCommandsCh = make(chan bool, 1) + contextAwareCh = make(chan bool, 1) ) // HotkeyPreset describes a selectable hotkey in the "Change Hotkey" submenu. @@ -206,6 +214,32 @@ func OnSoundsToggle() <-chan bool { return soundsCh } // OnAutoPasteToggle returns a channel that receives the new state. func OnAutoPasteToggle() <-chan bool { return autoPasteCh } +// --- AI Feature Toggles --- + +// SetAIPostProcess updates the "AI post-processing" checkbox. +func SetAIPostProcess(on bool) { C.uiSetAIPostProcess(boolToC(on)) } + +// SetPromptMode updates the "Prompt mode" checkbox. +func SetPromptMode(on bool) { C.uiSetPromptMode(boolToC(on)) } + +// SetVoiceCommands updates the "Voice commands" checkbox. +func SetVoiceCommands(on bool) { C.uiSetVoiceCommands(boolToC(on)) } + +// SetContextAware updates the "Context-aware" checkbox. +func SetContextAware(on bool) { C.uiSetContextAware(boolToC(on)) } + +// OnAIPostProcessToggle returns a channel that receives the new state. +func OnAIPostProcessToggle() <-chan bool { return aiPostProcessCh } + +// OnPromptModeToggle returns a channel that receives the new state. +func OnPromptModeToggle() <-chan bool { return promptModeCh } + +// OnVoiceCommandsToggle returns a channel that receives the new state. +func OnVoiceCommandsToggle() <-chan bool { return voiceCommandsCh } + +// OnContextAwareToggle returns a channel that receives the new state. +func OnContextAwareToggle() <-chan bool { return contextAwareCh } + func boolToC(b bool) C.int { if b { return 1 @@ -262,6 +296,18 @@ func onSoundsToggled(on C.int) { send(soundsCh, on != 0) } //export onAutoPasteToggled func onAutoPasteToggled(on C.int) { send(autoPasteCh, on != 0) } +//export onAIPostProcessToggled +func onAIPostProcessToggled(on C.int) { send(aiPostProcessCh, on != 0) } + +//export onPromptModeToggled +func onPromptModeToggled(on C.int) { send(promptModeCh, on != 0) } + +//export onVoiceCommandsToggled +func onVoiceCommandsToggled(on C.int) { send(voiceCommandsCh, on != 0) } + +//export onContextAwareToggled +func onContextAwareToggled(on C.int) { send(contextAwareCh, on != 0) } + func send(ch chan bool, v bool) { select { case ch <- v: diff --git a/internal/ui/ui_darwin.m b/internal/ui/ui_darwin.m index 6bc803b..b4e1d88 100644 --- a/internal/ui/ui_darwin.m +++ b/internal/ui/ui_darwin.m @@ -17,6 +17,10 @@ static NSMenuItem *modeToggleItem = nil; static NSMenuItem *soundsItem = nil; static NSMenuItem *autoPasteItem = nil; +static NSMenuItem *aiPostProcessItem = nil; +static NSMenuItem *promptModeItem = nil; +static NSMenuItem *voiceCommandsItem = nil; +static NSMenuItem *contextAwareItem = nil; // Tracks whether the user has paused vox via the menu. We keep it in C // because applySymbol consults it on every state transition to render a @@ -34,6 +38,10 @@ - (void)modeHoldClicked:(id)sender; - (void)modeToggleClicked:(id)sender; - (void)soundsClicked:(id)sender; - (void)autoPasteClicked:(id)sender; +- (void)aiPostProcessClicked:(id)sender; +- (void)promptModeClicked:(id)sender; +- (void)voiceCommandsClicked:(id)sender; +- (void)contextAwareClicked:(id)sender; @end @implementation VoxAppDelegate @@ -71,6 +79,18 @@ - (void)soundsClicked:(id)sender { - (void)autoPasteClicked:(id)sender { onAutoPasteToggled(autoPasteItem.state == NSControlStateValueOn ? 0 : 1); } +- (void)aiPostProcessClicked:(id)sender { + onAIPostProcessToggled(aiPostProcessItem.state == NSControlStateValueOn ? 0 : 1); +} +- (void)promptModeClicked:(id)sender { + onPromptModeToggled(promptModeItem.state == NSControlStateValueOn ? 0 : 1); +} +- (void)voiceCommandsClicked:(id)sender { + onVoiceCommandsToggled(voiceCommandsItem.state == NSControlStateValueOn ? 0 : 1); +} +- (void)contextAwareClicked:(id)sender { + onContextAwareToggled(contextAwareItem.state == NSControlStateValueOn ? 0 : 1); +} @end static VoxAppDelegate *appDelegate = nil; @@ -208,6 +228,39 @@ void uiInit(const char *hotkeyLabel) { [statusMenu addItem:[NSMenuItem separatorItem]]; + // --- AI Features --- + NSMenuItem *aiHeader = [[NSMenuItem alloc] initWithTitle:@"AI Features" + action:nil + keyEquivalent:@""]; + [aiHeader setEnabled:NO]; + [statusMenu addItem:aiHeader]; + + aiPostProcessItem = [[NSMenuItem alloc] initWithTitle:@"AI post-processing" + action:@selector(aiPostProcessClicked:) + keyEquivalent:@""]; + [aiPostProcessItem setTarget:appDelegate]; + [statusMenu addItem:aiPostProcessItem]; + + promptModeItem = [[NSMenuItem alloc] initWithTitle:@"Prompt mode" + action:@selector(promptModeClicked:) + keyEquivalent:@""]; + [promptModeItem setTarget:appDelegate]; + [statusMenu addItem:promptModeItem]; + + voiceCommandsItem = [[NSMenuItem alloc] initWithTitle:@"Voice commands" + action:@selector(voiceCommandsClicked:) + keyEquivalent:@""]; + [voiceCommandsItem setTarget:appDelegate]; + [statusMenu addItem:voiceCommandsItem]; + + contextAwareItem = [[NSMenuItem alloc] initWithTitle:@"Context-aware formatting" + action:@selector(contextAwareClicked:) + keyEquivalent:@""]; + [contextAwareItem setTarget:appDelegate]; + [statusMenu addItem:contextAwareItem]; + + [statusMenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *showLogItem = [[[NSMenuItem alloc] initWithTitle:@"Show Log…" action:@selector(showLogClicked:) keyEquivalent:@""] autorelease]; @@ -371,6 +424,32 @@ void uiSetAutoPaste(int on) { }); } +// MARK: - AI Feature setters + +void uiSetAIPostProcess(int on) { + dispatch_async(dispatch_get_main_queue(), ^{ + [aiPostProcessItem setState:on ? NSControlStateValueOn : NSControlStateValueOff]; + }); +} + +void uiSetPromptMode(int on) { + dispatch_async(dispatch_get_main_queue(), ^{ + [promptModeItem setState:on ? NSControlStateValueOn : NSControlStateValueOff]; + }); +} + +void uiSetVoiceCommands(int on) { + dispatch_async(dispatch_get_main_queue(), ^{ + [voiceCommandsItem setState:on ? NSControlStateValueOn : NSControlStateValueOff]; + }); +} + +void uiSetContextAware(int on) { + dispatch_async(dispatch_get_main_queue(), ^{ + [contextAwareItem setState:on ? NSControlStateValueOn : NSControlStateValueOff]; + }); +} + // MARK: - Run loop void uiRun(void) { From 9830d7d953d63ff6d4163a320bc26e4311abdd41 Mon Sep 17 00:00:00 2001 From: Aaron Hogue Date: Wed, 13 May 2026 19:21:50 -0400 Subject: [PATCH 2/2] fix: wire AI toggle channels to settingsWatcher and pipeline stages The menubar AI feature checkboxes (AI post-processing, prompt mode, voice commands, context-aware) were not connected to any consumer. Clicks silently dropped after filling the buffer-1 channels, checkboxes never visually toggled, and the toggles had no effect on runtime behavior. Changes: - Add AIPostProcess/PromptMode/VoiceCommands/ContextAware atomic.Bool fields to runtimeSettings - Seed them from flagClient at startup and sync checkbox UI state - Add 4 case arms in settingsWatcher: store atomic, update UI, persist to preferences.json via config.SavePref - Update pipeline stages (classifyStage, postProcessStage, promptModeStage, commandStage) to read settings.* instead of flagClient.* so menubar toggles control behavior at runtime - Add corresponding *bool fields to config.Prefs for persistence - Fix aiHeader NSMenuItem alloc without autorelease --- cmd/vox/main.go | 66 ++++++++++++++++++++++++++++------------ internal/config/prefs.go | 4 +++ internal/ui/ui_darwin.m | 6 ++-- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/cmd/vox/main.go b/cmd/vox/main.go index 15604dd..961e64a 100644 --- a/cmd/vox/main.go +++ b/cmd/vox/main.go @@ -55,6 +55,10 @@ type runtimeSettings struct { HoldToTalk atomic.Bool SoundsEnabled atomic.Bool AutoPaste atomic.Bool + AIPostProcess atomic.Bool + PromptMode atomic.Bool + VoiceCommands atomic.Bool + ContextAware atomic.Bool } var settings runtimeSettings @@ -205,6 +209,10 @@ func run() { settings.HoldToTalk.Store(cfg.HoldToTalk) settings.SoundsEnabled.Store(cfg.SoundsEnabled) settings.AutoPaste.Store(cfg.AutoPaste) + settings.AIPostProcess.Store(flagClient.AIPostProcess()) + settings.PromptMode.Store(flagClient.PromptMode()) + settings.VoiceCommands.Store(flagClient.VoiceCommands()) + settings.ContextAware.Store(flagClient.ContextAware()) // Initialize the menubar UI on the main goroutine. Must happen before the // hotkey listener registers on the main run loop, because uiInit creates @@ -215,6 +223,10 @@ func run() { ui.SetMode(cfg.HoldToTalk) ui.SetSoundsEnabled(cfg.SoundsEnabled) ui.SetAutoPaste(cfg.AutoPaste) + ui.SetAIPostProcess(settings.AIPostProcess.Load()) + ui.SetPromptMode(settings.PromptMode.Load()) + ui.SetVoiceCommands(settings.VoiceCommands.Load()) + ui.SetContextAware(settings.ContextAware.Load()) // Create hotkey listener and register the CGEventTap source on the main // run loop. Non-blocking: events arrive once we call ui.Run() below. @@ -228,16 +240,16 @@ func run() { fmt.Printf("Hotkey: %s (%s mode)\n", hotkeyLabel, modeLabel(cfg.HoldToTalk)) if claudeClient != nil { var features []string - if flagClient.AIPostProcess() { + if settings.AIPostProcess.Load() { features = append(features, "AI post-processing") } - if flagClient.PromptMode() { + if settings.PromptMode.Load() { features = append(features, "prompt mode") } - if flagClient.VoiceCommands() { + if settings.VoiceCommands.Load() { features = append(features, "voice commands") } - if flagClient.ContextAware() { + if settings.ContextAware.Load() { features = append(features, "context-aware") } if len(features) > 0 { @@ -252,10 +264,10 @@ func run() { pipe := pipeline.New( transcribeStage(whisperClient, transcribeOpts), filterBlankStage(), - classifyStage(flagClient), - postProcessStage(claudeClient, flagClient), - promptModeStage(promptExec, flagClient), - commandStage(cmdRegistry, flagClient), + classifyStage(), + postProcessStage(claudeClient), + promptModeStage(promptExec), + commandStage(cmdRegistry), injectStage(), ) @@ -390,6 +402,22 @@ func settingsWatcher(ctx context.Context, logger *slog.Logger, recorder *audio.R settings.AutoPaste.Store(on) ui.SetAutoPaste(on) save("auto_paste", func(p *config.Prefs) { p.AutoPaste = config.BoolPtr(on) }) + case on := <-ui.OnAIPostProcessToggle(): + settings.AIPostProcess.Store(on) + ui.SetAIPostProcess(on) + save("ai_postprocess", func(p *config.Prefs) { p.AIPostProcess = config.BoolPtr(on) }) + case on := <-ui.OnPromptModeToggle(): + settings.PromptMode.Store(on) + ui.SetPromptMode(on) + save("prompt_mode", func(p *config.Prefs) { p.PromptMode = config.BoolPtr(on) }) + case on := <-ui.OnVoiceCommandsToggle(): + settings.VoiceCommands.Store(on) + ui.SetVoiceCommands(on) + save("voice_commands", func(p *config.Prefs) { p.VoiceCommands = config.BoolPtr(on) }) + case on := <-ui.OnContextAwareToggle(): + settings.ContextAware.Store(on) + ui.SetContextAware(on) + save("context_aware", func(p *config.Prefs) { p.ContextAware = config.BoolPtr(on) }) } } } @@ -669,16 +697,16 @@ func injectStage() pipeline.Stage { } // classifyStage determines if speech is dictation, a prompt, or a command. -func classifyStage(fc *flags.Client) pipeline.Stage { +func classifyStage() pipeline.Stage { return func(_ context.Context, r *pipeline.Result) error { // Only classify if prompt mode or voice commands are enabled. - if !fc.PromptMode() && !fc.VoiceCommands() { + if !settings.PromptMode.Load() && !settings.VoiceCommands.Load() { return nil } intent := classify.Classify(r.RawText) switch intent.Mode { case classify.ModePrompt: - if fc.PromptMode() { + if settings.PromptMode.Load() { r.Mode = pipeline.ModePrompt r.Metadata = map[string]string{ "action": intent.Action, @@ -687,7 +715,7 @@ func classifyStage(fc *flags.Client) pipeline.Stage { } } case classify.ModeCommand: - if fc.VoiceCommands() { + if settings.VoiceCommands.Load() { r.Mode = pipeline.ModeCommand r.Metadata = map[string]string{ "action": intent.Action, @@ -700,14 +728,14 @@ func classifyStage(fc *flags.Client) pipeline.Stage { } // postProcessStage sends dictation text through Claude for grammar/punctuation cleanup. -func postProcessStage(cc *claude.Client, fc *flags.Client) pipeline.Stage { +func postProcessStage(cc *claude.Client) pipeline.Stage { return func(ctx context.Context, r *pipeline.Result) error { - if cc == nil || !fc.AIPostProcess() || r.Mode != pipeline.ModeDictation { + if cc == nil || !settings.AIPostProcess.Load() || r.Mode != pipeline.ModeDictation { return nil } systemPrompt := claude.PostProcessSystemPrompt - if fc.ContextAware() { + if settings.ContextAware.Load() { app := appctx.Detect() systemPrompt = format.SystemPromptWithHint(systemPrompt, app.Category()) } @@ -725,9 +753,9 @@ func postProcessStage(cc *claude.Client, fc *flags.Client) pipeline.Stage { } // promptModeStage handles prompt-mode actions (summarize, explain, rewrite, etc.). -func promptModeStage(exec *prompt.Executor, fc *flags.Client) pipeline.Stage { +func promptModeStage(exec *prompt.Executor) pipeline.Stage { return func(ctx context.Context, r *pipeline.Result) error { - if r.Mode != pipeline.ModePrompt || exec == nil || !fc.PromptMode() { + if r.Mode != pipeline.ModePrompt || exec == nil || !settings.PromptMode.Load() { return nil } action := r.Metadata["action"] @@ -745,9 +773,9 @@ func promptModeStage(exec *prompt.Executor, fc *flags.Client) pipeline.Stage { } // commandStage executes voice commands (create PR, query flag, etc.). -func commandStage(reg *commands.Registry, fc *flags.Client) pipeline.Stage { +func commandStage(reg *commands.Registry) pipeline.Stage { return func(ctx context.Context, r *pipeline.Result) error { - if r.Mode != pipeline.ModeCommand || !fc.VoiceCommands() { + if r.Mode != pipeline.ModeCommand || !settings.VoiceCommands.Load() { return nil } action := r.Metadata["action"] diff --git a/internal/config/prefs.go b/internal/config/prefs.go index 017ef48..b574ffc 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -20,6 +20,10 @@ type Prefs struct { HoldToTalk *bool `json:"hold_to_talk,omitempty"` SoundsEnabled *bool `json:"sounds_enabled,omitempty"` AutoPaste *bool `json:"auto_paste,omitempty"` + AIPostProcess *bool `json:"ai_postprocess,omitempty"` + PromptMode *bool `json:"prompt_mode,omitempty"` + VoiceCommands *bool `json:"voice_commands,omitempty"` + ContextAware *bool `json:"context_aware,omitempty"` } // BoolPtr is a small helper for callers building Prefs literals. diff --git a/internal/ui/ui_darwin.m b/internal/ui/ui_darwin.m index b4e1d88..0c219e7 100644 --- a/internal/ui/ui_darwin.m +++ b/internal/ui/ui_darwin.m @@ -229,9 +229,9 @@ void uiInit(const char *hotkeyLabel) { [statusMenu addItem:[NSMenuItem separatorItem]]; // --- AI Features --- - NSMenuItem *aiHeader = [[NSMenuItem alloc] initWithTitle:@"AI Features" - action:nil - keyEquivalent:@""]; + NSMenuItem *aiHeader = [[[NSMenuItem alloc] initWithTitle:@"AI Features" + action:nil + keyEquivalent:@""] autorelease]; [aiHeader setEnabled:NO]; [statusMenu addItem:aiHeader];