From 6d87a0b00f4ab60fa7194e086aeb5fe2332d2de1 Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Sat, 31 Jan 2026 20:57:50 +0100 Subject: [PATCH] replace old configure command with TUI wizard --- cmd/hyprvoice/main.go | 681 ++++++++++-------------------------------- progress.txt | 19 ++ tasks/prd.jsonc | 2 +- 3 files changed, 171 insertions(+), 531 deletions(-) diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 9ae3bc7..6038bf7 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -1,18 +1,15 @@ package main import ( - "bufio" "fmt" "os" "os/exec" - "strconv" "strings" - "time" "github.com/leonardotrapani/hyprvoice/internal/bus" "github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/daemon" - "github.com/leonardotrapani/hyprvoice/internal/notify" + "github.com/leonardotrapani/hyprvoice/internal/tui" "github.com/spf13/cobra" ) @@ -132,443 +129,56 @@ func configureCmd() *cobra.Command { Short: "Interactive configuration setup", Long: `Interactive configuration wizard for hyprvoice. This will guide you through setting up: -- Transcription provider (OpenAI, Groq, or Mistral) -- API keys and model selection -- Audio and text injection preferences -- Notification settings`, +- Provider API keys (OpenAI, Groq, Mistral, ElevenLabs) +- Transcription settings +- LLM post-processing +- Text injection and notification preferences`, RunE: func(cmd *cobra.Command, args []string) error { - return runInteractiveConfig() + return runConfigure() }, } } -func runInteractiveConfig() error { - fmt.Println("🎤 Hyprvoice Configuration Wizard") - fmt.Println("==================================") - fmt.Println() - +func runConfigure() error { // Load existing config or create default cfg, err := config.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } - scanner := bufio.NewScanner(os.Stdin) - - // Configure transcription - fmt.Println("📝 Transcription Configuration") - fmt.Println("------------------------------") - - // Provider selection - for { - fmt.Println("Select transcription provider:") - fmt.Println(" 1. openai - OpenAI Whisper API (cloud-based)") - fmt.Println(" 2. groq-transcription - Groq Whisper API (fast transcription)") - fmt.Println(" 3. groq-translation - Groq Whisper API (translate to English)") - fmt.Println(" 4. mistral-transcription - Mistral Voxtral API (excellent for European languages)") - fmt.Println(" 5. elevenlabs - ElevenLabs Scribe API (99 languages, excellent accuracy)") - fmt.Printf("Provider [1-5] (current: %s): ", cfg.Transcription.Provider) - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - break // keep current - } - switch input { - case "1": - cfg.Transcription.Provider = "openai" - case "2": - cfg.Transcription.Provider = "groq-transcription" - case "3": - cfg.Transcription.Provider = "groq-translation" - case "4": - cfg.Transcription.Provider = "mistral-transcription" - case "5": - cfg.Transcription.Provider = "elevenlabs" - case "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs": - cfg.Transcription.Provider = input - default: - fmt.Println("❌ Error: invalid provider. Please enter 1-5 or provider name.") - fmt.Println() - continue - } - break + // Run TUI wizard + result, err := tui.Run(cfg) + if err != nil { + return fmt.Errorf("configuration wizard error: %w", err) } - // Model selection based on provider - switch cfg.Transcription.Provider { - case "openai": - fmt.Println("\nOpenAI Model:") - fmt.Printf("Model (current: %s): ", cfg.Transcription.Model) - if scanner.Scan() { - input := strings.TrimSpace(scanner.Text()) - if input != "" { - cfg.Transcription.Model = input - } else if cfg.Transcription.Model == "" { - cfg.Transcription.Model = "whisper-1" - } - } - case "groq-transcription": - for { - fmt.Println("\nGroq Transcription Model:") - fmt.Println(" 1. whisper-large-v3 - Standard model") - fmt.Println(" 2. whisper-large-v3-turbo - Faster model") - fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model) - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - switch input { - case "1": - cfg.Transcription.Model = "whisper-large-v3" - case "2": - cfg.Transcription.Model = "whisper-large-v3-turbo" - case "whisper-large-v3", "whisper-large-v3-turbo": - cfg.Transcription.Model = input - case "": - if cfg.Transcription.Model == "" { - cfg.Transcription.Model = "whisper-large-v3-turbo" - } - default: - fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.") - continue - } - break - } - case "groq-translation": - for { - fmt.Println("\nGroq Translation Model:") - fmt.Println(" Note: Translation only supports whisper-large-v3 (turbo not available)") - fmt.Printf("Model (current: %s, press Enter for whisper-large-v3): ", cfg.Transcription.Model) - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" || input == "whisper-large-v3" || input == "1" { - cfg.Transcription.Model = "whisper-large-v3" - break - } - fmt.Println("❌ Error: only whisper-large-v3 is supported for translation.") - } - case "mistral-transcription": - for { - fmt.Println("\nMistral Voxtral Model:") - fmt.Println(" 1. voxtral-mini-latest - Recommended (latest version)") - fmt.Println(" 2. voxtral-mini-2507 - Pinned version") - fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model) - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - switch input { - case "1": - cfg.Transcription.Model = "voxtral-mini-latest" - case "2": - cfg.Transcription.Model = "voxtral-mini-2507" - case "voxtral-mini-latest", "voxtral-mini-2507": - cfg.Transcription.Model = input - case "": - if cfg.Transcription.Model == "" || !strings.HasPrefix(cfg.Transcription.Model, "voxtral") { - cfg.Transcription.Model = "voxtral-mini-latest" - } - default: - fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.") - continue - } - break - } - case "elevenlabs": - for { - fmt.Println("\nElevenLabs Scribe Model:") - fmt.Println(" Language Support:") - fmt.Println(" scribe_v1: 99 languages (96.7% accuracy for English, ≤5% WER for Portuguese)") - fmt.Println(" scribe_v2: 90 languages (real-time optimized, lower latency)") - fmt.Println() - fmt.Println(" Available Models:") - fmt.Println(" 1. scribe_v1 - Best accuracy, full timestamps (recommended)") - fmt.Println(" 2. scribe_v2 - Real-time streaming, lower latency") - fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model) - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - switch input { - case "1": - cfg.Transcription.Model = "scribe_v1" - case "2": - cfg.Transcription.Model = "scribe_v2" - case "scribe_v1", "scribe_v2": - cfg.Transcription.Model = input - case "": - if cfg.Transcription.Model == "" { - cfg.Transcription.Model = "scribe_v1" - } - default: - fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.") - continue - } - break - } + if result.Cancelled { + fmt.Println("Configuration cancelled.") + return nil } - // API Key (provider-aware) - var envVarName string - switch cfg.Transcription.Provider { - case "openai": - envVarName = "OPENAI_API_KEY" - case "mistral-transcription": - envVarName = "MISTRAL_API_KEY" - case "elevenlabs": - envVarName = "ELEVENLABS_API_KEY" - default: - envVarName = "GROQ_API_KEY" - } - fmt.Printf("\nAPI Key (current: %s, leave empty to use %s env var): ", maskAPIKey(cfg.Transcription.APIKey), envVarName) - if scanner.Scan() { - input := strings.TrimSpace(scanner.Text()) - if input != "" { - cfg.Transcription.APIKey = input - } - } - - // Language - if cfg.Transcription.Provider == "groq-translation" { - fmt.Printf("\nSource language hint (empty for auto-detect, current: %s): ", cfg.Transcription.Language) - fmt.Println("\n Note: Translation always outputs English. Language hints at source audio language.") - } else if cfg.Transcription.Provider == "elevenlabs" { - fmt.Println("\nLanguage Performance:") - fmt.Println(" Excellent (≤5% WER): English, Portuguese, +25 languages") - fmt.Println(" High (5-10% WER): French, German, Spanish, Italian, etc.") - fmt.Println(" Good (10-20% WER): Most supported languages") - fmt.Println(" Leave empty for auto-detection (recommended)") - fmt.Printf("Language (current: %s): ", cfg.Transcription.Language) - } else { - fmt.Printf("\nLanguage (empty for auto-detect, current: %s): ", cfg.Transcription.Language) - } - if scanner.Scan() { - input := strings.TrimSpace(scanner.Text()) - cfg.Transcription.Language = input - } - - fmt.Println() - - // Configure injection - for { - fmt.Println("⌨️ Text Injection Configuration") - fmt.Println("--------------------------------") - fmt.Println("Backends are tried in order until one succeeds (fallback chain):") - fmt.Println(" - ydotool: Best for Chromium/Electron apps (requires ydotoold daemon for ydotool v1.0.0+)") - fmt.Println(" - wtype: Native Wayland typing (may fail on some Chromium apps)") - fmt.Println(" - clipboard: Copies to clipboard only (most reliable, needs manual paste)") - fmt.Println() - fmt.Println("Recommended: ydotool,wtype,clipboard (full fallback chain)") - fmt.Println() - fmt.Printf("Backends (comma-separated) (current: %s): ", strings.Join(cfg.Injection.Backends, ",")) - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - break // keep current - } - backends := strings.Split(input, ",") - validBackends := make([]string, 0) - invalidBackends := make([]string, 0) - for _, b := range backends { - b = strings.TrimSpace(b) - if b == "ydotool" || b == "wtype" || b == "clipboard" { - validBackends = append(validBackends, b) - } else if b != "" { - invalidBackends = append(invalidBackends, b) - } - } - if len(invalidBackends) > 0 { - fmt.Printf("❌ Error: invalid backend(s): %s. Valid: ydotool, wtype, clipboard.\n", strings.Join(invalidBackends, ", ")) - fmt.Println() - continue - } - if len(validBackends) == 0 { - fmt.Println("❌ Error: at least one backend required.") - fmt.Println() - continue - } - cfg.Injection.Backends = validBackends - break - } - - // Check if ydotool is selected and warn about daemon requirement - for _, b := range cfg.Injection.Backends { - if b == "ydotool" { - fmt.Println() - fmt.Println("⚠️ ydotool requires the ydotoold daemon to be running! make sure it works") - fmt.Println() - break - } - } - - fmt.Println() - - // Configure notifications - for { - fmt.Println("🔔 Notification Configuration") - fmt.Println("-----------------------------") - fmt.Printf("Enable notifications [y/n] (current: %v): ", cfg.Notifications.Enabled) - if !scanner.Scan() { - break - } - input := strings.TrimSpace(strings.ToLower(scanner.Text())) - switch input { - case "y", "yes": - cfg.Notifications.Enabled = true - case "n", "no": - cfg.Notifications.Enabled = false - case "": - // keep current - default: - fmt.Println("❌ Error: please enter y or n.") - fmt.Println() - continue - } - break - } - - // Ask if user wants to customize notification messages - fmt.Print("Customize notification messages? [y/n] (default: n): ") - if scanner.Scan() { - input := strings.TrimSpace(strings.ToLower(scanner.Text())) - if input == "y" || input == "yes" { - fmt.Println() - // Get resolved values (user config merged with defaults) - msgs := cfg.Notifications.Messages.Resolve() - - // Recording Started - fmt.Println(" Recording Started notification:") - fmt.Printf(" Title (current: %s): ", msgs[notify.MsgRecordingStarted].Title) - if scanner.Scan() { - if t := strings.TrimSpace(scanner.Text()); t != "" { - cfg.Notifications.Messages.RecordingStarted.Title = t - } - } - fmt.Printf(" Body (current: %s): ", msgs[notify.MsgRecordingStarted].Body) - if scanner.Scan() { - if b := strings.TrimSpace(scanner.Text()); b != "" { - cfg.Notifications.Messages.RecordingStarted.Body = b - } - } - fmt.Println() - - // Transcribing - fmt.Println(" Transcribing notification:") - fmt.Printf(" Title (current: %s): ", msgs[notify.MsgTranscribing].Title) - if scanner.Scan() { - if t := strings.TrimSpace(scanner.Text()); t != "" { - cfg.Notifications.Messages.Transcribing.Title = t - } - } - fmt.Printf(" Body (current: %s): ", msgs[notify.MsgTranscribing].Body) - if scanner.Scan() { - if b := strings.TrimSpace(scanner.Text()); b != "" { - cfg.Notifications.Messages.Transcribing.Body = b - } - } - fmt.Println() - - // Config Reloaded - fmt.Println(" Config Reloaded notification:") - fmt.Printf(" Title (current: %s): ", msgs[notify.MsgConfigReloaded].Title) - if scanner.Scan() { - if t := strings.TrimSpace(scanner.Text()); t != "" { - cfg.Notifications.Messages.ConfigReloaded.Title = t - } - } - fmt.Printf(" Body (current: %s): ", msgs[notify.MsgConfigReloaded].Body) - if scanner.Scan() { - if b := strings.TrimSpace(scanner.Text()); b != "" { - cfg.Notifications.Messages.ConfigReloaded.Body = b - } - } - fmt.Println() - - // Operation Cancelled - fmt.Println(" Operation Cancelled notification:") - fmt.Printf(" Title (current: %s): ", msgs[notify.MsgOperationCancelled].Title) - if scanner.Scan() { - if t := strings.TrimSpace(scanner.Text()); t != "" { - cfg.Notifications.Messages.OperationCancelled.Title = t - } - } - fmt.Printf(" Body (current: %s): ", msgs[notify.MsgOperationCancelled].Body) - if scanner.Scan() { - if b := strings.TrimSpace(scanner.Text()); b != "" { - cfg.Notifications.Messages.OperationCancelled.Body = b - } - } - fmt.Println() - - // Recording Aborted (body only) - fmt.Println(" Recording Aborted notification:") - fmt.Printf(" Body (current: %s): ", msgs[notify.MsgRecordingAborted].Body) - if scanner.Scan() { - if b := strings.TrimSpace(scanner.Text()); b != "" { - cfg.Notifications.Messages.RecordingAborted.Body = b - } - } - fmt.Println() - - // Injection Aborted (body only) - fmt.Println(" Injection Aborted notification:") - fmt.Printf(" Body (current: %s): ", msgs[notify.MsgInjectionAborted].Body) - if scanner.Scan() { - if b := strings.TrimSpace(scanner.Text()); b != "" { - cfg.Notifications.Messages.InjectionAborted.Body = b - } - } - } - } - - fmt.Println() - - // Configure recording timeout - for { - fmt.Println("⏱️ Recording Configuration") - fmt.Println("---------------------------") - fmt.Printf("Recording timeout in minutes (current: %.0f): ", cfg.Recording.Timeout.Minutes()) - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - break // keep current - } - minutes, err := strconv.Atoi(input) - if err != nil || minutes <= 0 { - fmt.Println("❌ Error: please enter a positive number.") - fmt.Println() - continue - } - cfg.Recording.Timeout = time.Duration(minutes) * time.Minute - break - } - - fmt.Println() - // Validate configuration - if err := cfg.Validate(); err != nil { - fmt.Printf("❌ Configuration validation failed: %v\n", err) - fmt.Println("Please check your inputs and try again.") + if err := result.Config.Validate(); err != nil { + fmt.Printf("Configuration validation failed: %v\n", err) return err } // Save configuration - fmt.Println("💾 Saving configuration...") - if err := saveConfig(cfg); err != nil { + if err := saveConfig(result.Config); err != nil { return fmt.Errorf("failed to save config: %w", err) } - fmt.Println("✅ Configuration saved successfully!") + fmt.Println() + fmt.Println("Configuration saved successfully!") fmt.Println() + // Show next steps + showNextSteps(result.Config) + + return nil +} + +func showNextSteps(cfg *config.Config) { // Check if service is running serviceRunning := false if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil { @@ -584,8 +194,7 @@ func runInteractiveConfig() error { } } - // Show next steps - fmt.Println("🚀 Next Steps:") + fmt.Println("Next Steps:") step := 1 if hasYdotool { fmt.Printf("%d. Ensure ydotoold is running\n", step) @@ -597,31 +206,11 @@ func runInteractiveConfig() error { fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step) } step++ - fmt.Printf("%d. Test voice input: hyprvoice toggle (or use keybind you configured in hyprland config)\n", step) + fmt.Printf("%d. Test voice input: hyprvoice toggle\n", step) fmt.Println() configPath, _ := config.GetConfigPath() - fmt.Printf("📁 Config file location: %s\n", configPath) - - return nil -} - -func formatBackends(backends []string) string { - quoted := make([]string, len(backends)) - for i, b := range backends { - quoted[i] = fmt.Sprintf(`"%s"`, b) - } - return strings.Join(quoted, ", ") -} - -func maskAPIKey(key string) string { - if key == "" { - return "" - } - if len(key) <= 8 { - return "****" - } - return key[:4] + "****" + key[len(key)-4:] + fmt.Printf("Config file location: %s\n", configPath) } func saveConfig(cfg *config.Config) error { @@ -636,117 +225,149 @@ func saveConfig(cfg *config.Config) error { } defer file.Close() - configContent := fmt.Sprintf(`# Hyprvoice Configuration -# This file is automatically generated with defaults. -# Edit values as needed - changes are applied immediately without daemon restart. + var sb strings.Builder -# Audio Recording Configuration -[recording] - sample_rate = %d # Audio sample rate in Hz (16000 recommended for speech) - channels = %d # Number of audio channels (1 = mono, 2 = stereo) - format = "%s" # Audio format (s16 = 16-bit signed integers) - buffer_size = %d # Internal buffer size in bytes (larger = less CPU, more latency) - device = "%s" # PipeWire audio device (empty = use default microphone) - channel_buffer_size = %d # Audio frame buffer size (frames to buffer) - timeout = "%s" # Maximum recording duration (e.g., "30s", "2m", "5m") + // Header + sb.WriteString(`# Hyprvoice Configuration +# Generated by hyprvoice configure +# Changes are applied immediately without daemon restart. -# Speech Transcription Configuration -[transcription] - provider = "%s" # Transcription service: "openai", "groq-transcription", "groq-translation", "mistral-transcription", or "elevenlabs" - api_key = "%s" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY/ELEVENLABS_API_KEY environment variable) - language = "%s" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) - model = "%s" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" or "scribe_v2" +`) -# Text Injection Configuration -[injection] - backends = [%s] # Ordered fallback chain (tries each until one succeeds) - ydotool_timeout = "%s" # Timeout for ydotool commands - wtype_timeout = "%s" # Timeout for wtype commands - clipboard_timeout = "%s" # Timeout for clipboard operations - -# Backend explanations: -# - "ydotool": Uses ydotool (requires ydotoold daemon running for ydotool v1.0.0+). Most compatible with Chromium/Electron apps. -# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps. -# - "clipboard": Copies text to clipboard only (most reliable, but requires manual paste). -# -# The backends are tried in order. First successful one wins. -# -# Provider explanations: -# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY) -# - "groq-transcription": Groq Whisper API for transcription (fast, requires GROQ_API_KEY) -# Models: whisper-large-v3 or whisper-large-v3-turbo -# - "groq-translation": Groq Whisper API for translation to English (always outputs English text) -# Models: whisper-large-v3 only (turbo not supported for translation) -# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, requires MISTRAL_API_KEY) -# Models: voxtral-mini-latest or voxtral-mini-2507 -# - "elevenlabs": ElevenLabs Scribe API (excellent accuracy, 99 languages, requires ELEVENLABS_API_KEY) -# Models: scribe_v1 (99 languages, best accuracy) or scribe_v2 (90 languages, real-time) -# -# Language codes: Use empty string ("") for automatic detection, or specific codes like: -# "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. -# For groq-translation, the language field hints at the source audio language for better accuracy. - -# Desktop Notification Configuration -[notifications] - enabled = %v # Enable desktop notifications - type = "%s" # Notification type ("desktop", "log", "none") -`, - cfg.Recording.SampleRate, - cfg.Recording.Channels, - cfg.Recording.Format, - cfg.Recording.BufferSize, - cfg.Recording.Device, - cfg.Recording.ChannelBufferSize, - cfg.Recording.Timeout, - cfg.Transcription.Provider, - cfg.Transcription.APIKey, - cfg.Transcription.Language, - cfg.Transcription.Model, - formatBackends(cfg.Injection.Backends), - cfg.Injection.YdotoolTimeout, - cfg.Injection.WtypeTimeout, - cfg.Injection.ClipboardTimeout, - cfg.Notifications.Enabled, - cfg.Notifications.Type, - ) - - if _, err := file.WriteString(configContent); err != nil { - return fmt.Errorf("failed to write config content: %w", err) + // Keywords (must be before any table definitions) + if len(cfg.Keywords) > 0 { + sb.WriteString("# Keywords help transcription and LLM spell names/terms correctly\n") + sb.WriteString("keywords = [") + for i, kw := range cfg.Keywords { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%q", kw)) + } + sb.WriteString("]\n\n") } - // Write notification messages if any are configured + // Providers section + if len(cfg.Providers) > 0 { + sb.WriteString("# API Keys for providers\n") + for name, pc := range cfg.Providers { + sb.WriteString(fmt.Sprintf("[providers.%s]\n", name)) + sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey)) + sb.WriteString("\n") + } + } + + // Recording + sb.WriteString(`# Audio Recording Configuration +[recording] +`) + sb.WriteString(fmt.Sprintf(" sample_rate = %d\n", cfg.Recording.SampleRate)) + sb.WriteString(fmt.Sprintf(" channels = %d\n", cfg.Recording.Channels)) + sb.WriteString(fmt.Sprintf(" format = %q\n", cfg.Recording.Format)) + sb.WriteString(fmt.Sprintf(" buffer_size = %d\n", cfg.Recording.BufferSize)) + sb.WriteString(fmt.Sprintf(" device = %q\n", cfg.Recording.Device)) + sb.WriteString(fmt.Sprintf(" channel_buffer_size = %d\n", cfg.Recording.ChannelBufferSize)) + sb.WriteString(fmt.Sprintf(" timeout = %q\n", cfg.Recording.Timeout.String())) + sb.WriteString("\n") + + // Transcription + sb.WriteString(`# Speech Transcription Configuration +[transcription] +`) + sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.Transcription.Provider)) + sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.Transcription.Language)) + sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.Transcription.Model)) + sb.WriteString("\n") + + // LLM + sb.WriteString(`# LLM Post-Processing Configuration +[llm] +`) + sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.Enabled)) + if cfg.LLM.Provider != "" { + sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.LLM.Provider)) + } + if cfg.LLM.Model != "" { + sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.LLM.Model)) + } + sb.WriteString("\n") + + sb.WriteString(" [llm.post_processing]\n") + sb.WriteString(fmt.Sprintf(" remove_stutters = %v\n", cfg.LLM.PostProcessing.RemoveStutters)) + sb.WriteString(fmt.Sprintf(" add_punctuation = %v\n", cfg.LLM.PostProcessing.AddPunctuation)) + sb.WriteString(fmt.Sprintf(" fix_grammar = %v\n", cfg.LLM.PostProcessing.FixGrammar)) + sb.WriteString(fmt.Sprintf(" remove_filler_words = %v\n", cfg.LLM.PostProcessing.RemoveFillerWords)) + sb.WriteString("\n") + + sb.WriteString(" [llm.custom_prompt]\n") + sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.CustomPrompt.Enabled)) + if cfg.LLM.CustomPrompt.Prompt != "" { + sb.WriteString(fmt.Sprintf(" prompt = %q\n", cfg.LLM.CustomPrompt.Prompt)) + } + sb.WriteString("\n") + + // Injection + sb.WriteString(`# Text Injection Configuration +[injection] +`) + sb.WriteString(" backends = [") + for i, b := range cfg.Injection.Backends { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%q", b)) + } + sb.WriteString("]\n") + sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String())) + sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String())) + sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String())) + sb.WriteString("\n") + + // Notifications + sb.WriteString(`# Desktop Notification Configuration +[notifications] +`) + sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.Notifications.Enabled)) + sb.WriteString(fmt.Sprintf(" type = %q\n", cfg.Notifications.Type)) + + // Write custom messages if any msgs := cfg.Notifications.Messages if hasCustomMessages(msgs) { - messagesContent := "\n [notifications.messages]\n" + sb.WriteString("\n [notifications.messages]\n") if msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" { - messagesContent += fmt.Sprintf(" [notifications.messages.recording_started]\n title = %q\n body = %q\n", - msgs.RecordingStarted.Title, msgs.RecordingStarted.Body) + sb.WriteString(" [notifications.messages.recording_started]\n") + sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.RecordingStarted.Title)) + sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingStarted.Body)) } if msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" { - messagesContent += fmt.Sprintf(" [notifications.messages.transcribing]\n title = %q\n body = %q\n", - msgs.Transcribing.Title, msgs.Transcribing.Body) + sb.WriteString(" [notifications.messages.transcribing]\n") + sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title)) + sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body)) } if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" { - messagesContent += fmt.Sprintf(" [notifications.messages.config_reloaded]\n title = %q\n body = %q\n", - msgs.ConfigReloaded.Title, msgs.ConfigReloaded.Body) + sb.WriteString(" [notifications.messages.config_reloaded]\n") + sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title)) + sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.ConfigReloaded.Body)) } if msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" { - messagesContent += fmt.Sprintf(" [notifications.messages.operation_cancelled]\n title = %q\n body = %q\n", - msgs.OperationCancelled.Title, msgs.OperationCancelled.Body) + sb.WriteString(" [notifications.messages.operation_cancelled]\n") + sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.OperationCancelled.Title)) + sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.OperationCancelled.Body)) } if msgs.RecordingAborted.Body != "" { - messagesContent += fmt.Sprintf(" [notifications.messages.recording_aborted]\n body = %q\n", - msgs.RecordingAborted.Body) + sb.WriteString(" [notifications.messages.recording_aborted]\n") + sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingAborted.Body)) } if msgs.InjectionAborted.Body != "" { - messagesContent += fmt.Sprintf(" [notifications.messages.injection_aborted]\n body = %q\n", - msgs.InjectionAborted.Body) - } - if _, err := file.WriteString(messagesContent); err != nil { - return fmt.Errorf("failed to write messages config: %w", err) + sb.WriteString(" [notifications.messages.injection_aborted]\n") + sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.InjectionAborted.Body)) } } + if _, err := file.WriteString(sb.String()); err != nil { + return fmt.Errorf("failed to write config content: %w", err) + } + return nil } diff --git a/progress.txt b/progress.txt index 7831133..e7fd4cf 100644 --- a/progress.txt +++ b/progress.txt @@ -151,3 +151,22 @@ Key decisions: - When user picks unconfigured provider, immediately prompts for API key - Unedited sections preserved - only touched sections are modified - Config struct passed by reference, changes accumulate + +## Task 9: Replace old configure with TUI - COMPLETE + +Replaced old interactive config in cmd/hyprvoice/main.go: +- `configureCmd` now calls `tui.Run()` instead of `runInteractiveConfig()` +- Removed all old functions: `runInteractiveConfig`, `maskAPIKey`, `formatBackends`, old `saveConfig` +- New `saveConfig()` writes proper TOML with new structure: + - `keywords = [...]` at top (before any tables) + - `[providers.X]` sections with `api_key` + - `[llm]` with `[llm.post_processing]` and `[llm.custom_prompt]` subsections + - No more `transcription.api_key` in saved configs +- Added `showNextSteps()` helper for post-save instructions +- Added `runConfigure()` that wraps TUI flow with validation and save + +Key decisions: +- Keywords written before any TOML table definitions (TOML requirement) +- Config saved only if user confirms in TUI summary +- Validation runs before save, errors displayed cleanly +- Next steps shown after successful save diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index 89f2adc..97f4507 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -178,7 +178,7 @@ "Saved config valid TOML with new structure", "Typecheck passes" ], - "passes": false + "passes": true }, { "title": "Update default config template",