package tui import ( "fmt" "strings" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/provider" ) // ConfigureResult holds the configuration result from the TUI type ConfigureResult struct { Config *config.Config Cancelled bool } // ConfigSection represents a configuration section type ConfigSection string const ( SectionProviders ConfigSection = "providers" SectionTranscription ConfigSection = "transcription" SectionLLM ConfigSection = "llm" SectionKeywords ConfigSection = "keywords" SectionInjection ConfigSection = "injection" SectionNotifications ConfigSection = "notifications" SectionFullSetup ConfigSection = "full_setup" SectionSaveExit ConfigSection = "save_exit" SectionDiscardExit ConfigSection = "discard_exit" ) // Run starts the TUI configuration wizard func Run(existingConfig *config.Config) (*ConfigureResult, error) { // Detect if config has user changes (providers configured) if existingConfig != nil && hasUserChanges(existingConfig) { return runEditExisting(existingConfig) } return runFreshInstall(existingConfig) } // hasUserChanges detects if config has user modifications func hasUserChanges(cfg *config.Config) bool { // If providers map has entries, user has configured something if len(cfg.Providers) > 0 { return true } // If legacy api_key is set, user has configured something if cfg.Transcription.APIKey != "" { return true } return false } // runEditExisting runs the menu-based edit flow for existing configs func runEditExisting(cfg *config.Config) (*ConfigureResult, error) { fmt.Println(Logo()) fmt.Println() // Track which providers are configured (for smart detection) configuredProviders := getConfiguredProviders(cfg) // Menu loop for { // Clear screen for cleaner UX fmt.Print("\033[H\033[2J") fmt.Println(Logo()) fmt.Println() section, err := selectSection(cfg) if err != nil { return &ConfigureResult{Cancelled: true}, nil } switch section { case SectionSaveExit: confirmed, err := showSummary(cfg) if err != nil { return &ConfigureResult{Cancelled: true}, nil } if confirmed { return &ConfigureResult{Config: cfg, Cancelled: false}, nil } // User cancelled save, back to menu case SectionDiscardExit: return &ConfigureResult{Cancelled: true}, nil case SectionFullSetup: return runFreshInstall(cfg) case SectionProviders: if err := editProviders(cfg); err != nil { continue // back to menu on cancel } configuredProviders = getConfiguredProviders(cfg) case SectionTranscription: var err error configuredProviders, err = editTranscription(cfg, configuredProviders) if err != nil { continue // back to menu on cancel } case SectionLLM: var err error configuredProviders, err = editLLM(cfg, configuredProviders) if err != nil { continue // back to menu on cancel } case SectionKeywords: keywords, err := inputKeywords(cfg.Keywords) if err != nil { continue // back to menu on cancel } cfg.Keywords = keywords case SectionInjection: backends, err := selectBackends(cfg.Injection.Backends) if err != nil { continue // back to menu on cancel } cfg.Injection.Backends = backends case SectionNotifications: enabled, err := configureNotifications(cfg.Notifications.Enabled) if err != nil { continue // back to menu on cancel } cfg.Notifications.Enabled = enabled } } } func selectSection(cfg *config.Config) (ConfigSection, error) { options := []huh.Option[ConfigSection]{ huh.NewOption(formatProvidersLabel(cfg), SectionProviders), huh.NewOption(formatTranscriptionLabel(cfg), SectionTranscription), huh.NewOption(formatLLMLabel(cfg), SectionLLM), huh.NewOption(formatKeywordsLabel(cfg), SectionKeywords), huh.NewOption(formatInjectionLabel(cfg), SectionInjection), huh.NewOption(formatNotificationsLabel(cfg), SectionNotifications), huh.NewOption("Full Setup (reconfigure everything)", SectionFullSetup), huh.NewOption("Save & Exit", SectionSaveExit), huh.NewOption("Discard & Exit", SectionDiscardExit), } var selected ConfigSection form := huh.NewForm( huh.NewGroup( huh.NewSelect[ConfigSection](). Title("Configuration Menu"). Description("↑/↓ navigate • enter select • esc cancel"). Options(options...). Value(&selected), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return "", err } return selected, nil } // formatProvidersLabel shows configured providers func formatProvidersLabel(cfg *config.Config) string { var providers []string for name, pc := range cfg.Providers { if pc.APIKey != "" { providers = append(providers, name) } } if len(providers) == 0 { return "Providers - none configured" } return fmt.Sprintf("Providers - %s", strings.Join(providers, ", ")) } // formatTranscriptionLabel shows current transcription settings func formatTranscriptionLabel(cfg *config.Config) string { if cfg.Transcription.Provider == "" { return "Transcription - not configured" } return fmt.Sprintf("Transcription - %s/%s", cfg.Transcription.Provider, cfg.Transcription.Model) } // formatLLMLabel shows current LLM settings func formatLLMLabel(cfg *config.Config) string { if !cfg.LLM.Enabled { return "LLM - disabled" } if cfg.LLM.Provider == "" { return "LLM - enabled (not configured)" } return fmt.Sprintf("LLM - %s/%s", cfg.LLM.Provider, cfg.LLM.Model) } // formatKeywordsLabel shows keyword count func formatKeywordsLabel(cfg *config.Config) string { if len(cfg.Keywords) == 0 { return "Keywords - none" } if len(cfg.Keywords) <= 3 { return fmt.Sprintf("Keywords - %s", strings.Join(cfg.Keywords, ", ")) } return fmt.Sprintf("Keywords - %d configured", len(cfg.Keywords)) } // formatInjectionLabel shows backends func formatInjectionLabel(cfg *config.Config) string { if len(cfg.Injection.Backends) == 0 { return "Injection - no backends" } return fmt.Sprintf("Injection - %s", strings.Join(cfg.Injection.Backends, " → ")) } // formatNotificationsLabel shows notification status func formatNotificationsLabel(cfg *config.Config) string { if cfg.Notifications.Enabled { return "Notifications - enabled" } return "Notifications - disabled" } // getConfiguredProviders returns list of providers with API keys func getConfiguredProviders(cfg *config.Config) []string { var providers []string for name, pc := range cfg.Providers { if pc.APIKey != "" { providers = append(providers, name) } } return providers } // editProviders handles the providers section edit with submenu func editProviders(cfg *config.Config) error { allProviders := []string{"openai", "groq", "mistral", "elevenlabs"} for { // Build options with current status var options []huh.Option[string] for _, name := range allProviders { options = append(options, huh.NewOption(formatProviderOption(cfg, name), name)) } options = append(options, huh.NewOption("Back", "back")) var selected string form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Provider Settings"). Description("Select a provider to configure API key"). Options(options...). Value(&selected), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return err } if selected == "back" { return nil } // Configure the selected provider apiKey, err := inputAPIKey(selected) if err != nil { continue // cancelled, back to provider menu } if cfg.Providers == nil { cfg.Providers = make(map[string]config.ProviderConfig) } cfg.Providers[selected] = config.ProviderConfig{APIKey: apiKey} } } // formatProviderOption formats a provider menu option with status func formatProviderOption(cfg *config.Config, name string) string { var status string if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" { status = "(configured)" } else { status = "(not configured)" } switch name { case "openai": return fmt.Sprintf("OpenAI - Whisper + GPT %s", status) case "groq": return fmt.Sprintf("Groq - Whisper + Llama %s", status) case "mistral": return fmt.Sprintf("Mistral - Voxtral %s", status) case "elevenlabs": return fmt.Sprintf("ElevenLabs - Scribe %s", status) default: return fmt.Sprintf("%s %s", name, status) } } // editTranscription handles the transcription section edit with smart provider detection func editTranscription(cfg *config.Config, configuredProviders []string) ([]string, error) { // Build transcription options from configured providers var transcriptionOptions []huh.Option[string] for _, name := range configuredProviders { p := provider.GetProvider(name) if p != nil && p.SupportsTranscription() { switch name { case "openai": transcriptionOptions = append(transcriptionOptions, huh.NewOption("OpenAI Whisper", "openai")) case "groq": transcriptionOptions = append(transcriptionOptions, huh.NewOption("Groq Whisper (transcription)", "groq-transcription"), huh.NewOption("Groq Whisper (translate to English)", "groq-translation")) case "mistral": transcriptionOptions = append(transcriptionOptions, huh.NewOption("Mistral Voxtral", "mistral-transcription")) case "elevenlabs": transcriptionOptions = append(transcriptionOptions, huh.NewOption("ElevenLabs Scribe", "elevenlabs")) } } } // Add options for unconfigured providers (will prompt for key) unconfiguredOptions := getUnconfiguredTranscriptionOptions(configuredProviders) if len(unconfiguredOptions) > 0 { transcriptionOptions = append(transcriptionOptions, unconfiguredOptions...) } if len(transcriptionOptions) == 0 { return configuredProviders, fmt.Errorf("no transcription providers available") } // Set default to current provider or first option selectedProvider := cfg.Transcription.Provider if selectedProvider == "" && len(transcriptionOptions) > 0 { selectedProvider = transcriptionOptions[0].Value } providerForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Transcription Provider"). Description("Choose which service to use for speech-to-text"). Options(transcriptionOptions...). Value(&selectedProvider), ), ).WithTheme(getTheme()) if err := providerForm.Run(); err != nil { return configuredProviders, err } // Smart detection: if provider not configured, prompt for API key configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders) cfg.Transcription.Provider = selectedProvider // Model selection modelOptions := getTranscriptionModelOptions(selectedProvider) selectedModel := cfg.Transcription.Model if selectedModel == "" && len(modelOptions) > 0 { selectedModel = modelOptions[0].Value } language := cfg.Transcription.Language modelForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Transcription Model"). Options(modelOptions...). Value(&selectedModel), huh.NewInput(). Title("Language"). Description("ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect"). Placeholder("auto-detect"). Value(&language), ), ).WithTheme(getTheme()) if err := modelForm.Run(); err != nil { return configuredProviders, err } cfg.Transcription.Model = selectedModel cfg.Transcription.Language = language return configuredProviders, nil } // editLLM handles the LLM section edit with smart provider detection func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error) { // Check if any LLM-capable providers are configured var llmProviders []string for _, name := range configuredProviders { p := provider.GetProvider(name) if p != nil && p.SupportsLLM() { llmProviders = append(llmProviders, name) } } // Default post-processing postProcessing := cfg.LLM.PostProcessing if !postProcessing.RemoveStutters && !postProcessing.AddPunctuation && !postProcessing.FixGrammar && !postProcessing.RemoveFillerWords { postProcessing = config.LLMPostProcessingConfig{ RemoveStutters: true, AddPunctuation: true, FixGrammar: true, RemoveFillerWords: true, } } customPrompt := cfg.LLM.CustomPrompt // Ask if user wants LLM enableLLM := cfg.LLM.Enabled enableForm := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Enable LLM Post-Processing? (Recommended)"). Description("LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"). Affirmative("Yes (Recommended)"). Negative("No"). Value(&enableLLM), ), ).WithTheme(getTheme()) if err := enableForm.Run(); err != nil { return configuredProviders, err } if !enableLLM { cfg.LLM.Enabled = false return configuredProviders, nil } // Build LLM provider options var llmOptions []huh.Option[string] for _, name := range llmProviders { switch name { case "openai": llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai")) case "groq": llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq")) } } // Add unconfigured LLM providers unconfiguredLLM := getUnconfiguredLLMOptions(configuredProviders) if len(unconfiguredLLM) > 0 { llmOptions = append(llmOptions, unconfiguredLLM...) } if len(llmOptions) == 0 { fmt.Println(StyleError.Render("No LLM providers available. Please configure OpenAI or Groq first.")) cfg.LLM.Enabled = false return configuredProviders, nil } selectedProvider := cfg.LLM.Provider if selectedProvider == "" && len(llmOptions) > 0 { selectedProvider = llmOptions[0].Value } providerForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("LLM Provider"). Description("Choose which service to use for text post-processing"). Options(llmOptions...). Value(&selectedProvider), ), ).WithTheme(getTheme()) if err := providerForm.Run(); err != nil { return configuredProviders, err } // Smart detection: if provider not configured, prompt for API key configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders) cfg.LLM.Provider = selectedProvider // Model selection modelOptions := getLLMModelOptions(selectedProvider) selectedModel := cfg.LLM.Model if selectedModel == "" && len(modelOptions) > 0 { selectedModel = modelOptions[0].Value } modelForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("LLM Model"). Options(modelOptions...). Value(&selectedModel), ), ).WithTheme(getTheme()) if err := modelForm.Run(); err != nil { return configuredProviders, err } cfg.LLM.Model = selectedModel // Post-processing options using MultiSelect var ppErr error postProcessing, ppErr = selectPostProcessingOptions(postProcessing) if ppErr != nil { return configuredProviders, ppErr } cfg.LLM.PostProcessing = postProcessing // Custom prompt enableCustomPrompt := customPrompt.Enabled customPromptText := customPrompt.Prompt customForm := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Add custom prompt?"). Description("Add extra instructions for the LLM"). Value(&enableCustomPrompt), ), ).WithTheme(getTheme()) if err := customForm.Run(); err != nil { return configuredProviders, err } if enableCustomPrompt { promptForm := huh.NewForm( huh.NewGroup( huh.NewText(). Title("Custom Prompt"). Description("Additional instructions (e.g., 'Format as bullet points')"). Value(&customPromptText). CharLimit(500), ), ).WithTheme(getTheme()) if err := promptForm.Run(); err != nil { return configuredProviders, err } cfg.LLM.CustomPrompt.Enabled = true cfg.LLM.CustomPrompt.Prompt = customPromptText } else { cfg.LLM.CustomPrompt.Enabled = false } cfg.LLM.Enabled = true return configuredProviders, nil } // getUnconfiguredTranscriptionOptions returns options for providers not yet configured func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Option[string] { configured := make(map[string]bool) for _, p := range configuredProviders { configured[p] = true } var options []huh.Option[string] if !configured["openai"] { options = append(options, huh.NewOption("OpenAI Whisper (needs API key)", "openai")) } if !configured["groq"] { options = append(options, huh.NewOption("Groq Whisper transcription (needs API key)", "groq-transcription"), huh.NewOption("Groq Whisper translation (needs API key)", "groq-translation")) } if !configured["mistral"] { options = append(options, huh.NewOption("Mistral Voxtral (needs API key)", "mistral-transcription")) } if !configured["elevenlabs"] { options = append(options, huh.NewOption("ElevenLabs Scribe (needs API key)", "elevenlabs")) } return options } // getUnconfiguredLLMOptions returns options for LLM providers not yet configured func getUnconfiguredLLMOptions(configuredProviders []string) []huh.Option[string] { configured := make(map[string]bool) for _, p := range configuredProviders { configured[p] = true } var options []huh.Option[string] if !configured["openai"] { options = append(options, huh.NewOption("OpenAI GPT (needs API key)", "openai")) } if !configured["groq"] { options = append(options, huh.NewOption("Groq Llama (needs API key)", "groq")) } return options } // ensureProviderConfigured prompts for API key if provider not configured func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string { // Map transcription provider to actual provider name providerName := selectedProvider switch selectedProvider { case "groq-transcription", "groq-translation": providerName = "groq" case "mistral-transcription": providerName = "mistral" } // Check if already configured for _, p := range configuredProviders { if p == providerName { return configuredProviders } } // Not configured - prompt for API key fmt.Println() fmt.Println(StyleMuted.Render(fmt.Sprintf("%s not configured. Please enter API key.", strings.Title(providerName)))) apiKey, err := inputAPIKey(providerName) if err != nil { return configuredProviders } if cfg.Providers == nil { cfg.Providers = make(map[string]config.ProviderConfig) } cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey} return append(configuredProviders, providerName) } // runFreshInstall runs the full configuration wizard for fresh installs func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) { // Welcome screen fmt.Println(Logo()) fmt.Println() fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland")) fmt.Println() // Step 1: Provider selection selectedProviders, err := selectProviders() if err != nil { return &ConfigureResult{Cancelled: true}, nil } if len(selectedProviders) == 0 { return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers selected") } // Initialize providers map if cfg.Providers == nil { cfg.Providers = make(map[string]config.ProviderConfig) } // Step 2: API keys for selected providers for _, providerName := range selectedProviders { apiKey, err := inputAPIKey(providerName) if err != nil { return &ConfigureResult{Cancelled: true}, nil } cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey} } // Step 3: Transcription configuration transcriptionProvider, transcriptionModel, language, err := configureTranscription(selectedProviders, cfg) if err != nil { return &ConfigureResult{Cancelled: true}, nil } cfg.Transcription.Provider = transcriptionProvider cfg.Transcription.Model = transcriptionModel cfg.Transcription.Language = language // Step 4: LLM configuration llmEnabled, llmProvider, llmModel, postProcessing, customPrompt, err := configureLLM(selectedProviders, cfg) if err != nil { return &ConfigureResult{Cancelled: true}, nil } cfg.LLM.Enabled = llmEnabled cfg.LLM.Provider = llmProvider cfg.LLM.Model = llmModel cfg.LLM.PostProcessing = postProcessing cfg.LLM.CustomPrompt = customPrompt // Step 5: Keywords keywords, err := inputKeywords(cfg.Keywords) if err != nil { return &ConfigureResult{Cancelled: true}, nil } cfg.Keywords = keywords // Step 6: Injection backends backends, err := selectBackends(cfg.Injection.Backends) if err != nil { return &ConfigureResult{Cancelled: true}, nil } cfg.Injection.Backends = backends // Step 7: Notifications notificationsEnabled, err := configureNotifications(cfg.Notifications.Enabled) if err != nil { return &ConfigureResult{Cancelled: true}, nil } cfg.Notifications.Enabled = notificationsEnabled // Step 8: Summary and confirm confirmed, err := showSummary(cfg) if err != nil || !confirmed { return &ConfigureResult{Cancelled: true}, nil } return &ConfigureResult{Config: cfg, Cancelled: false}, nil } func selectProviders() ([]string, error) { allProviders := []string{"openai", "groq", "mistral", "elevenlabs"} options := []huh.Option[string]{ huh.NewOption("OpenAI - Whisper transcription + GPT for LLM", "openai"), huh.NewOption("Groq - Fast Whisper transcription + Llama for LLM", "groq"), huh.NewOption("Mistral - Voxtral transcription (European languages)", "mistral"), huh.NewOption("ElevenLabs - Scribe transcription (99 languages)", "elevenlabs"), } var selected []string form := huh.NewForm( huh.NewGroup( huh.NewMultiSelect[string](). Title("Which providers do you want to configure?"). Description("Select all providers you have API keys for"). Options(options...). Value(&selected), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return nil, err } // Validate selected providers exist valid := make([]string, 0) for _, s := range selected { for _, p := range allProviders { if s == p { valid = append(valid, s) break } } } return valid, nil } func inputAPIKey(providerName string) (string, error) { p := provider.GetProvider(providerName) displayName := strings.Title(providerName) if p != nil { displayName = strings.Title(p.Name()) } var apiKey string form := huh.NewForm( huh.NewGroup( huh.NewInput(). Title(fmt.Sprintf("%s API Key", displayName)). Description(fmt.Sprintf("Enter your %s API key", displayName)). EchoMode(huh.EchoModePassword). Value(&apiKey). Validate(func(s string) error { if s == "" { return fmt.Errorf("API key is required") } if p != nil && !p.ValidateAPIKey(s) { return fmt.Errorf("invalid API key format for %s", displayName) } return nil }), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return "", err } return apiKey, nil } func configureTranscription(configuredProviders []string, cfg *config.Config) (string, string, string, error) { // Filter to only transcription-capable configured providers var transcriptionOptions []huh.Option[string] for _, name := range configuredProviders { p := provider.GetProvider(name) if p != nil && p.SupportsTranscription() { // Map provider name to transcription provider name switch name { case "openai": transcriptionOptions = append(transcriptionOptions, huh.NewOption("OpenAI Whisper", "openai")) case "groq": transcriptionOptions = append(transcriptionOptions, huh.NewOption("Groq Whisper (transcription)", "groq-transcription"), huh.NewOption("Groq Whisper (translate to English)", "groq-translation")) case "mistral": transcriptionOptions = append(transcriptionOptions, huh.NewOption("Mistral Voxtral", "mistral-transcription")) case "elevenlabs": transcriptionOptions = append(transcriptionOptions, huh.NewOption("ElevenLabs Scribe", "elevenlabs")) } } } if len(transcriptionOptions) == 0 { return "", "", "", fmt.Errorf("no transcription-capable providers configured") } var selectedProvider string if cfg.Transcription.Provider != "" { selectedProvider = cfg.Transcription.Provider } else if len(transcriptionOptions) > 0 { selectedProvider = transcriptionOptions[0].Value } providerForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Transcription Provider"). Description("Choose which service to use for speech-to-text"). Options(transcriptionOptions...). Value(&selectedProvider), ), ).WithTheme(getTheme()) if err := providerForm.Run(); err != nil { return "", "", "", err } // Get model options for selected provider modelOptions := getTranscriptionModelOptions(selectedProvider) var selectedModel string if cfg.Transcription.Model != "" { selectedModel = cfg.Transcription.Model } else if len(modelOptions) > 0 { selectedModel = modelOptions[0].Value } var language string if cfg.Transcription.Language != "" { language = cfg.Transcription.Language } modelForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("Transcription Model"). Options(modelOptions...). Value(&selectedModel), huh.NewInput(). Title("Language"). Description("ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect"). Placeholder("auto-detect"). Value(&language), ), ).WithTheme(getTheme()) if err := modelForm.Run(); err != nil { return "", "", "", err } return selectedProvider, selectedModel, language, nil } func getTranscriptionModelOptions(provider string) []huh.Option[string] { switch provider { case "openai": return []huh.Option[string]{ huh.NewOption("whisper-1", "whisper-1"), } case "groq-transcription": return []huh.Option[string]{ huh.NewOption("whisper-large-v3-turbo (faster)", "whisper-large-v3-turbo"), huh.NewOption("whisper-large-v3 (standard)", "whisper-large-v3"), } case "groq-translation": return []huh.Option[string]{ huh.NewOption("whisper-large-v3 (only option)", "whisper-large-v3"), } case "mistral-transcription": return []huh.Option[string]{ huh.NewOption("voxtral-mini-latest (recommended)", "voxtral-mini-latest"), huh.NewOption("voxtral-mini-2507", "voxtral-mini-2507"), } case "elevenlabs": return []huh.Option[string]{ huh.NewOption("scribe_v1 (99 languages, best accuracy)", "scribe_v1"), huh.NewOption("scribe_v2 (real-time, lower latency)", "scribe_v2"), } default: return []huh.Option[string]{} } } func configureLLM(configuredProviders []string, cfg *config.Config) (bool, string, string, config.LLMPostProcessingConfig, config.LLMCustomPromptConfig, error) { // Filter to only LLM-capable configured providers var llmProviders []string for _, name := range configuredProviders { p := provider.GetProvider(name) if p != nil && p.SupportsLLM() { llmProviders = append(llmProviders, name) } } // Default values postProcessing := config.LLMPostProcessingConfig{ RemoveStutters: true, AddPunctuation: true, FixGrammar: true, RemoveFillerWords: true, } customPrompt := config.LLMCustomPromptConfig{ Enabled: false, Prompt: "", } // If no LLM providers configured, skip LLM config if len(llmProviders) == 0 { return false, "", "", postProcessing, customPrompt, nil } // Ask if user wants LLM post-processing var enableLLM bool = true enableForm := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Enable LLM Post-Processing? (Recommended)"). Description("LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"). Affirmative("Yes (Recommended)"). Negative("No"). Value(&enableLLM), ), ).WithTheme(getTheme()) if err := enableForm.Run(); err != nil { return false, "", "", postProcessing, customPrompt, err } if !enableLLM { return false, "", "", postProcessing, customPrompt, nil } // LLM provider selection var llmOptions []huh.Option[string] for _, name := range llmProviders { p := provider.GetProvider(name) if p != nil { switch name { case "openai": llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai")) case "groq": llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq")) } } } var selectedProvider string if cfg.LLM.Provider != "" { selectedProvider = cfg.LLM.Provider } else if len(llmOptions) > 0 { selectedProvider = llmOptions[0].Value } providerForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("LLM Provider"). Description("Choose which service to use for text post-processing"). Options(llmOptions...). Value(&selectedProvider), ), ).WithTheme(getTheme()) if err := providerForm.Run(); err != nil { return false, "", "", postProcessing, customPrompt, err } // Model selection modelOptions := getLLMModelOptions(selectedProvider) var selectedModel string if cfg.LLM.Model != "" { selectedModel = cfg.LLM.Model } else if len(modelOptions) > 0 { selectedModel = modelOptions[0].Value } modelForm := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("LLM Model"). Options(modelOptions...). Value(&selectedModel), ), ).WithTheme(getTheme()) if err := modelForm.Run(); err != nil { return false, "", "", postProcessing, customPrompt, err } // Post-processing options if cfg.LLM.PostProcessing.RemoveStutters || cfg.LLM.PostProcessing.AddPunctuation || cfg.LLM.PostProcessing.FixGrammar || cfg.LLM.PostProcessing.RemoveFillerWords { postProcessing = cfg.LLM.PostProcessing } var ppErr error postProcessing, ppErr = selectPostProcessingOptions(postProcessing) if ppErr != nil { return false, "", "", postProcessing, customPrompt, ppErr } // Custom prompt var enableCustomPrompt bool var customPromptText string if cfg.LLM.CustomPrompt.Enabled { enableCustomPrompt = true customPromptText = cfg.LLM.CustomPrompt.Prompt } customForm := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Add custom prompt?"). Description("Add extra instructions for the LLM"). Value(&enableCustomPrompt), ), ).WithTheme(getTheme()) if err := customForm.Run(); err != nil { return false, "", "", postProcessing, customPrompt, err } if enableCustomPrompt { promptForm := huh.NewForm( huh.NewGroup( huh.NewText(). Title("Custom Prompt"). Description("Additional instructions (e.g., 'Format as bullet points')"). Value(&customPromptText). CharLimit(500), ), ).WithTheme(getTheme()) if err := promptForm.Run(); err != nil { return false, "", "", postProcessing, customPrompt, err } customPrompt.Enabled = true customPrompt.Prompt = customPromptText } return true, selectedProvider, selectedModel, postProcessing, customPrompt, nil } func getLLMModelOptions(provider string) []huh.Option[string] { switch provider { case "openai": return []huh.Option[string]{ huh.NewOption("gpt-4o-mini (recommended)", "gpt-4o-mini"), huh.NewOption("gpt-4o", "gpt-4o"), huh.NewOption("gpt-4-turbo", "gpt-4-turbo"), huh.NewOption("gpt-3.5-turbo", "gpt-3.5-turbo"), } case "groq": return []huh.Option[string]{ huh.NewOption("llama-3.3-70b-versatile (recommended)", "llama-3.3-70b-versatile"), huh.NewOption("llama-3.1-8b-instant (faster)", "llama-3.1-8b-instant"), huh.NewOption("mixtral-8x7b-32768", "mixtral-8x7b-32768"), } default: return []huh.Option[string]{} } } // selectPostProcessingOptions shows a multi-select for LLM post-processing toggles func selectPostProcessingOptions(current config.LLMPostProcessingConfig) (config.LLMPostProcessingConfig, error) { type ppOption string const ( optRemoveStutters ppOption = "stutters" optAddPunctuation ppOption = "punctuation" optFixGrammar ppOption = "grammar" optRemoveFillerWords ppOption = "fillers" ) options := []huh.Option[ppOption]{ huh.NewOption("Remove stutters (repeated words)", optRemoveStutters), huh.NewOption("Add punctuation", optAddPunctuation), huh.NewOption("Fix grammar", optFixGrammar), huh.NewOption("Remove filler words (um, uh, like)", optRemoveFillerWords), } // Pre-select based on current config var selected []ppOption if current.RemoveStutters { selected = append(selected, optRemoveStutters) } if current.AddPunctuation { selected = append(selected, optAddPunctuation) } if current.FixGrammar { selected = append(selected, optFixGrammar) } if current.RemoveFillerWords { selected = append(selected, optRemoveFillerWords) } form := huh.NewForm( huh.NewGroup( huh.NewMultiSelect[ppOption](). Title("Post-Processing Options"). Description("Select which improvements to apply"). Options(options...). Value(&selected), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return current, err } // Convert selections back to config result := config.LLMPostProcessingConfig{} for _, opt := range selected { switch opt { case optRemoveStutters: result.RemoveStutters = true case optAddPunctuation: result.AddPunctuation = true case optFixGrammar: result.FixGrammar = true case optRemoveFillerWords: result.RemoveFillerWords = true } } return result, nil } func inputKeywords(existingKeywords []string) ([]string, error) { var keywordsInput string if len(existingKeywords) > 0 { keywordsInput = strings.Join(existingKeywords, ", ") } form := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Keywords"). Description("Comma-separated words to help with spelling (names, technical terms, etc.)"). Placeholder("e.g., Kubernetes, PostgreSQL, John Smith"). Value(&keywordsInput), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return nil, err } // Parse keywords if keywordsInput == "" { return nil, nil } parts := strings.Split(keywordsInput, ",") keywords := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { keywords = append(keywords, p) } } return keywords, nil } func selectBackends(existingBackends []string) ([]string, error) { options := []huh.Option[string]{ huh.NewOption("ydotool - Best for Chromium/Electron (needs ydotoold)", "ydotool"), huh.NewOption("wtype - Native Wayland typing", "wtype"), huh.NewOption("clipboard - Copy to clipboard only", "clipboard"), } var selected []string if len(existingBackends) > 0 { selected = existingBackends } else { selected = []string{"ydotool", "wtype", "clipboard"} } form := huh.NewForm( huh.NewGroup( huh.NewMultiSelect[string](). Title("Text Injection Backends"). Description("Backends are tried in order until one succeeds (fallback chain)"). Options(options...). Value(&selected), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return nil, err } if len(selected) == 0 { return nil, fmt.Errorf("at least one backend required") } return selected, nil } func configureNotifications(existingEnabled bool) (bool, error) { enabled := existingEnabled form := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Enable desktop notifications?"). Description("Show notifications for recording status changes"). Value(&enabled), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return false, err } return enabled, nil } func showSummary(cfg *config.Config) (bool, error) { fmt.Println() fmt.Println(StyleHeader.Render("Configuration Summary")) fmt.Println() // Providers var providers []string for name := range cfg.Providers { providers = append(providers, name) } fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", ")) // Transcription fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model) if cfg.Transcription.Language != "" { fmt.Printf(" %s %s\n", StyleLabel.Render("Language:"), cfg.Transcription.Language) } // LLM if cfg.LLM.Enabled { fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model) var ppOpts []string if cfg.LLM.PostProcessing.RemoveStutters { ppOpts = append(ppOpts, "remove stutters") } if cfg.LLM.PostProcessing.AddPunctuation { ppOpts = append(ppOpts, "add punctuation") } if cfg.LLM.PostProcessing.FixGrammar { ppOpts = append(ppOpts, "fix grammar") } if cfg.LLM.PostProcessing.RemoveFillerWords { ppOpts = append(ppOpts, "remove fillers") } if len(ppOpts) > 0 { fmt.Printf(" %s %s\n", StyleLabel.Render("Post-processing:"), strings.Join(ppOpts, ", ")) } } else { fmt.Printf(" %s disabled\n", StyleLabel.Render("LLM:")) } // Keywords if len(cfg.Keywords) > 0 { fmt.Printf(" %s %s\n", StyleLabel.Render("Keywords:"), strings.Join(cfg.Keywords, ", ")) } // Backends fmt.Printf(" %s %s\n", StyleLabel.Render("Backends:"), strings.Join(cfg.Injection.Backends, " -> ")) // Notifications if cfg.Notifications.Enabled { fmt.Printf(" %s enabled\n", StyleLabel.Render("Notifications:")) } else { fmt.Printf(" %s disabled\n", StyleLabel.Render("Notifications:")) } fmt.Println() var confirmed bool form := huh.NewForm( huh.NewGroup( huh.NewConfirm(). Title("Save this configuration?"). Affirmative("Save"). Negative("Cancel"). Value(&confirmed), ), ).WithTheme(getTheme()) if err := form.Run(); err != nil { return false, err } return confirmed, nil } func getTheme() *huh.Theme { t := huh.ThemeBase() // Primary colors t.Focused.Title = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true) t.Focused.Description = lipgloss.NewStyle().Foreground(ColorMuted) t.Focused.Base = lipgloss.NewStyle().BorderForeground(ColorPrimary) t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(ColorSecondary) t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(ColorText) // Blurred (unfocused) t.Blurred.Title = lipgloss.NewStyle().Foreground(ColorMuted) t.Blurred.Description = lipgloss.NewStyle().Foreground(ColorSubtle) return t }