Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 47 additions & 19 deletions cmd/vox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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
Expand Down Expand Up @@ -268,6 +272,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
Expand All @@ -280,6 +288,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.
Expand All @@ -293,16 +305,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 {
Expand All @@ -317,10 +329,10 @@ func run() {
pipe := pipeline.New(
transcribeStage(whisperClient, transcribeOpts),
filterBlankStage(),
classifyStage(classifier, flagClient),
postProcessStage(claudeClient, flagClient),
promptModeStage(promptExec, flagClient),
commandStage(cmdRegistry, flagClient),
classifyStage(classifier),
postProcessStage(claudeClient),
promptModeStage(promptExec),
commandStage(cmdRegistry),
injectStage(),
)

Expand Down Expand Up @@ -653,6 +665,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) })
}
}
}
Expand Down Expand Up @@ -934,16 +962,16 @@ func injectStage() pipeline.Stage {
}

// classifyStage determines if speech is dictation, a prompt, or a command.
func classifyStage(cl *classify.Classifier, fc *flags.Client) pipeline.Stage {
func classifyStage(cl *classify.Classifier) 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 := cl.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,
Expand All @@ -952,7 +980,7 @@ func classifyStage(cl *classify.Classifier, 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,
Expand All @@ -965,14 +993,14 @@ func classifyStage(cl *classify.Classifier, 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())
}
Expand All @@ -990,9 +1018,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"]
Expand All @@ -1010,9 +1038,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"]
Expand Down
4 changes: 4 additions & 0 deletions internal/config/prefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Model string `json:"model,omitempty"`
}

Expand Down
64 changes: 55 additions & 9 deletions internal/ui/ui_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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);
*/
Expand All @@ -51,15 +55,19 @@ const (
)

var (
quitCh = make(chan struct{}, 1)
showLogCh = make(chan struct{}, 1)
hotkeyCh = make(chan string, 1)
modelCh = make(chan string, 1)
deleteModelCh = 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)
modelCh = make(chan string, 1)
deleteModelCh = 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.
Expand Down Expand Up @@ -307,6 +315,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
Expand Down Expand Up @@ -381,6 +415,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:
Expand Down
81 changes: 80 additions & 1 deletion internal/ui/ui_darwin.m
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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
Expand All @@ -39,6 +43,10 @@ - (void)modeToggleClicked:(id)sender;
- (void)modelClicked:(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;
- (void)modelRemoveClicked:(id)sender;
@end

Expand Down Expand Up @@ -84,6 +92,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);
}
// Confirmed removal after NSAlert — representedObject is the model ID.
- (void)modelRemoveClicked:(id)sender {
NSMenuItem *item = (NSMenuItem *)sender;
Expand All @@ -92,7 +112,7 @@ - (void)modelRemoveClicked:(id)sender {

NSAlert *alert = [[NSAlert alloc] init];
alert.messageText = @"Remove downloaded model?";
alert.informativeText = [NSString stringWithFormat:@"Delete “%@” from disk to free space. You can download it again later from this menu.", item.title];
alert.informativeText = [NSString stringWithFormat:@"Delete \u201c%@\u201d from disk to free space. You can download it again later from this menu.", item.title];
[alert addButtonWithTitle:@"Remove"];
[alert addButtonWithTitle:@"Cancel"];
alert.alertStyle = NSAlertStyleWarning;
Expand Down Expand Up @@ -247,6 +267,39 @@ void uiInit(const char *hotkeyLabel) {

[statusMenu addItem:[NSMenuItem separatorItem]];

// --- AI Features ---
NSMenuItem *aiHeader = [[[NSMenuItem alloc] initWithTitle:@"AI Features"
action:nil
keyEquivalent:@""] autorelease];
[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];
Expand Down Expand Up @@ -524,6 +577,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) {
Expand Down
Binary file added vox
Binary file not shown.
Loading