diff --git a/internal/tui/configure.go b/internal/tui/configure.go new file mode 100644 index 0000000..5840c09 --- /dev/null +++ b/internal/tui/configure.go @@ -0,0 +1,673 @@ +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 +} + +// Run starts the TUI configuration wizard +func Run(existingConfig *config.Config) (*ConfigureResult, error) { + return runFreshInstall(existingConfig) +} + +// 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 + } + + ppForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Remove stutters"). + Description("Remove repeated words like 'I I I think'"). + Value(&postProcessing.RemoveStutters), + huh.NewConfirm(). + Title("Add punctuation"). + Description("Add proper punctuation to text"). + Value(&postProcessing.AddPunctuation), + huh.NewConfirm(). + Title("Fix grammar"). + Description("Correct grammatical errors"). + Value(&postProcessing.FixGrammar), + huh.NewConfirm(). + Title("Remove filler words"). + Description("Remove 'um', 'uh', 'like', etc."). + Value(&postProcessing.RemoveFillerWords), + ), + ).WithTheme(getTheme()) + + if err := ppForm.Run(); err != nil { + return false, "", "", postProcessing, customPrompt, err + } + + // 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]{} + } +} + +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 +} diff --git a/progress.txt b/progress.txt index a83bed2..4fd0dd4 100644 --- a/progress.txt +++ b/progress.txt @@ -109,3 +109,25 @@ Key decisions: - Purple (#7C3AED) as primary accent, matches hyprvoice "voice" theme - Dark slate backgrounds for terminal aesthetics - Box styles with rounded borders for form containers + +## Task 7: Create TUI configure - fresh install flow - COMPLETE + +Created internal/tui/configure.go with full TUI wizard: +- `Run(existingConfig)` entry point returning ConfigureResult +- `runFreshInstall()` - linear flow through all configuration steps +- `selectProviders()` - multi-select for OpenAI, Groq, Mistral, ElevenLabs +- `inputAPIKey()` - password-masked input with validation per provider +- `configureTranscription()` - provider dropdown (only configured+capable), model dropdown, language input +- `configureLLM()` - enable confirm (defaults YES, labeled "Recommended"), provider, model, post-processing toggles, custom prompt +- `inputKeywords()` - comma-separated input +- `selectBackends()` - multi-select with descriptions +- `configureNotifications()` - enable toggle +- `showSummary()` - displays all config, confirm button +- `getTheme()` - applies hyprvoice color scheme to huh forms + +Key decisions: +- Linear flow for fresh installs, all steps required +- Transcription providers mapped: groq -> groq-transcription + groq-translation options +- LLM enabled by default, "Yes (Recommended)" as affirmative text +- Post-processing options all default to true +- Uses huh library forms with custom theme matching styles.go colors diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index 49ed76f..b53cf21 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -140,7 +140,7 @@ "LLM defaults to enabled, Yes is recommended", "Typecheck passes" ], - "passes": false + "passes": true }, { "title": "Create TUI configure - edit existing flow",