package tui import ( "context" "fmt" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/deps" "github.com/leonardotrapani/hyprvoice/internal/models/whisper" "github.com/leonardotrapani/hyprvoice/internal/notify" "github.com/leonardotrapani/hyprvoice/internal/provider" ) const ( menuProviders = "providers" menuVoiceModel = "voice_model" menuLLM = "llm" menuKeywords = "keywords" menuNotifications = "notifications" menuAdvanced = "advanced" menuSave = "save" menuDiscard = "discard" repoURL = "https://github.com/leonardotrapani/hyprvoice" ) func newWelcomeScreen(state *wizardState) screen { desc := append([]string{}, LogoLines()...) desc = append(desc, "", "Voice-powered typing for Wayland/Hyprland.", "Let's set up your configuration.") desc = append(desc, "Consider starring the project on GitHub ⭐") desc = append(desc, repoURL) s := newInfoScreen(state, "Hyprvoice Onboarding", desc, func() screen { return onboardingVoiceProviderScreen(state) }, func() screen { state.cancelled = true state.result = &ConfigureResult{Cancelled: true} return nil }) s.footer = "enter start • esc quit" return s } func onboardingVoiceProviderScreen(state *wizardState) screen { return newVoiceProviderScreen(state, func() screen { return newWelcomeScreen(state) }, func() screen { return onboardingLLMScreen(state) }, ) } func onboardingLLMScreen(state *wizardState) screen { return newLLMEnableScreen(state, func() screen { return onboardingVoiceProviderScreen(state) }, func() screen { return newKeywordsScreen(state, func() screen { return onboardingLLMScreen(state) }) }, ) } func onboardingSummaryScreen(state *wizardState, onBack func() screen) screen { return newSummaryScreen(state, func() screen { return newNotificationsScreen(state, onBack) }) } func newMenuScreen(state *wizardState) screen { items := []optionItem{ {title: "Save & Exit", desc: "Write config changes to disk.", value: menuSave}, {title: formatVoiceModelLabel(state.cfg), desc: "Pick the transcription provider, model, and language.", value: menuVoiceModel}, {title: formatLLMLabel(state.cfg), desc: "Configure post-processing and custom prompts.", value: menuLLM}, {title: formatKeywordsLabel(state.cfg), desc: "Words to preserve spelling and phrasing.", value: menuKeywords}, {title: formatProvidersLabel(state.cfg), desc: "Manage API keys for cloud providers.", value: menuProviders}, {title: formatNotificationsLabel(state.cfg), desc: "Notification type and message text.", value: menuNotifications}, {title: "Advanced Settings", desc: "Recording, injection, and timeout settings.", value: menuAdvanced}, {title: "Discard & Exit", desc: "Exit without saving changes.", value: menuDiscard}, } desc := append([]string{}, LogoLines()...) desc = append(desc, "", "Select a section to update.") screen := newListScreen(state, "Configuration Menu", desc, items, func(item optionItem) screen { switch item.value { case menuProviders: return newProvidersScreen(state, func() screen { return newMenuScreen(state) }, func() screen { return newMenuScreen(state) }, false) case menuVoiceModel: return newVoiceProviderScreen(state, func() screen { return newMenuScreen(state) }, func() screen { return newMenuScreen(state) }) case menuLLM: return newLLMEnableScreen(state, func() screen { return newMenuScreen(state) }, func() screen { return newMenuScreen(state) }) case menuKeywords: return newKeywordsScreen(state, func() screen { return newMenuScreen(state) }) case menuNotifications: return newNotificationsScreen(state, func() screen { return newMenuScreen(state) }) case menuAdvanced: return newAdvancedMenuScreen(state, func() screen { return newMenuScreen(state) }, false) case menuSave: return newSummaryScreen(state, func() screen { return newMenuScreen(state) }) case menuDiscard: state.cancelled = true state.result = &ConfigureResult{Cancelled: true} return nil default: return newMenuScreen(state) } }, func() screen { state.cancelled = true state.result = &ConfigureResult{Cancelled: true} return nil }) screen.footer = fmt.Sprintf("enter select • esc cancel • / filter\nconsider starring the project on github ⭐\n%s", repoURL) return screen } func newProvidersScreen(state *wizardState, onBack func() screen, onNext func() screen, onboarding bool) screen { items := make([]optionItem, 0, len(AllProviders)+1) for _, name := range AllProviders { items = append(items, optionItem{ title: formatProviderOption(state.cfg, name), desc: formatProviderOptionDesc(state.cfg, name), value: name, }) } exitLabel := "Done" exitDesc := "Return to menu." if onboarding { exitLabel = "Next" exitDesc = "Continue to voice model setup." } items = append(items, optionItem{title: exitLabel, desc: exitDesc, value: "back"}) desc := []string{ "Add or update API keys for cloud providers.", "Recommended: for cloud quality, ElevenLabs is the top pick.", "Tip: press / to filter.", } screen := newListScreen(state, "Provider API Keys", desc, items, func(item optionItem) screen { if item.value == "back" { if onboarding && onNext != nil { return onNext() } if onBack != nil { return onBack() } return nil } return newProviderKeyFlow(state, item.value, func() screen { return newProvidersScreen(state, onBack, onNext, onboarding) }, func() screen { return newProvidersScreen(state, onBack, onNext, onboarding) }) }, func() screen { if onBack != nil { return onBack() } state.cancelled = true state.result = &ConfigureResult{Cancelled: true} return nil }) screen.footer = "enter select • esc back • / filter" return screen } func newProviderKeyFlow(state *wizardState, providerName string, onContinue func() screen, onCancel func() screen) screen { displayName := getProviderDisplayName(providerName) if isProviderConfigured(state.cfg, providerName) { masked := maskAPIKey(state.cfg.Providers[providerName].APIKey) desc := []string{fmt.Sprintf("Current key: %s", masked)} return newConfirmScreen(state, fmt.Sprintf("%s API Key", displayName), desc, "Update key", "Replace the stored API key.", "Keep current", "Keep the existing API key.", func() screen { return newAPIKeyInputScreen(state, providerName, onContinue, onCancel) }, func() screen { return onContinue() }, onCancel) } return newAPIKeyInputScreen(state, providerName, onContinue, onCancel) } func newAPIKeyInputScreen(state *wizardState, providerName string, onContinue func() screen, onCancel func() screen) screen { p := provider.GetProvider(providerName) displayName := getProviderDisplayName(providerName) if p != nil { if name, ok := providerDisplayNames[p.Name()]; ok { displayName = name } } desc := []string{fmt.Sprintf("Enter your %s API key", displayName)} if url := getProviderKeyURL(providerName); url != "" { desc = append(desc, fmt.Sprintf("Get key: %s", url)) } 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 } return newInputScreen(state, fmt.Sprintf("%s API Key", displayName), desc, "", "", true, validate, func(value string) screen { if state.cfg.Providers == nil { state.cfg.Providers = make(map[string]config.ProviderConfig) } state.cfg.Providers[providerName] = config.ProviderConfig{APIKey: value} return onContinue() }, onCancel) } func newVoiceProviderScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { options := buildVoiceProviderOptions(state.cfg) if len(options) == 0 { desc := []string{ "No voice model providers are available.", "Configure a cloud provider API key or install whisper.cpp.", } s := newInfoScreen(state, "Voice Model Provider", desc, onBack, onBack) s.footer = "enter back • esc back" return s } desc := []string{ "Choose the provider for speech-to-text.", "Recommended: local models maximize privacy; for cloud quality, ElevenLabs is the top pick.", "Tip: press / to filter.", } screen := newListScreen(state, "Voice Model Provider", desc, options, func(item optionItem) screen { if item.value == "whisper-cpp-disabled" { info := []string{ "whisper-cli was not found in PATH.", "Install whisper.cpp to use local transcription:", "https://github.com/ggerganov/whisper.cpp", } s := newInfoScreen(state, "Whisper.cpp Not Found", info, func() screen { return newVoiceProviderScreen(state, onBack, onNext) }, func() screen { return newVoiceProviderScreen(state, onBack, onNext) }) s.footer = "enter choose another • esc back" return s } selectedProvider := item.value providerName := selectedProvider switch selectedProvider { case "groq-transcription": providerName = "groq" case "mistral-transcription": providerName = "mistral" } if selectedProvider != "whisper-cpp" && !isProviderConfigured(state.cfg, providerName) { return newProviderKeyFlow(state, providerName, func() screen { return newVoiceModelScreen(state, selectedProvider, onBack, onNext) }, func() screen { return newVoiceProviderScreen(state, onBack, onNext) }) } return newVoiceModelScreen(state, selectedProvider, onBack, onNext) }, func() screen { return onBack() }) screen.footer = "enter select • esc back • / filter" if state.cfg.Transcription.Provider != "" { selectListByValue(&screen.list, state.cfg.Transcription.Provider) } return screen } func newVoiceModelScreen(state *wizardState, providerName string, onBack func() screen, onNext func() screen) screen { options := getTranscriptionModelOptions(providerName) if len(options) == 0 { desc := []string{"No models available for this provider."} s := newInfoScreen(state, "Voice Model", desc, onBack, onBack) s.footer = "enter back • esc back" return s } items := make([]optionItem, 0, len(options)) for _, opt := range options { items = append(items, optionItem{title: opt.Title, desc: opt.Desc, value: opt.ID}) } desc := []string{ "Pick the model for speech-to-text.", "Tip: press / to filter.", } screen := newListScreen(state, "Voice Model", desc, items, func(item optionItem) screen { if item.value == "" { return newVoiceModelScreen(state, providerName, onBack, onNext) } if providerName == "whisper-cpp" && !whisper.IsInstalled(item.value) { modelInfo := whisper.GetModel(item.value) if modelInfo == nil { state.err = fmt.Errorf("unknown model: %s", item.value) return nil } confirmDesc := []string{fmt.Sprintf("Download %s (%s)?", modelInfo.Name, modelInfo.Size)} return newConfirmScreen(state, "Download Model", confirmDesc, "Download", "Download and install the model.", "Cancel", "Return to model list.", func() screen { return newDownloadScreen(state, "Downloading Model", []string{modelInfo.Name}, item.value, func() screen { return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext) }, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }) }, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }, nil) } return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext) }, func() screen { return onBack() }) screen.footer = "enter select • esc back • / filter" if state.cfg.Transcription.Model != "" { selectListByValue(&screen.list, state.cfg.Transcription.Model) } return screen } func applyVoiceModelSelection(state *wizardState, providerName, modelID string, onBack func() screen, onNext func() screen) screen { state.cfg.Transcription.Provider = providerName state.cfg.Transcription.Model = modelID backToModels := func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) } registryName := mapConfigProviderToRegistry(providerName) model, err := provider.GetModel(registryName, modelID) if err != nil { state.err = err return nil } if state.cfg.Transcription.Language != "" && !model.SupportsLanguage(state.cfg.Transcription.Language) { state.cfg.Transcription.Language = "" } if len(model.SupportedLanguages) <= 1 { if len(model.SupportedLanguages) == 1 { state.cfg.Transcription.Language = model.SupportedLanguages[0] } else { state.cfg.Transcription.Language = "" } return applyStreamingSelection(state, model, backToModels, onNext) } return newLanguageScreen(state, model, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }, func() screen { return applyStreamingSelection(state, model, backToModels, onNext) }) } func newLanguageScreen(state *wizardState, model *provider.Model, onBack func() screen, onNext func() screen) screen { items := []optionItem{{title: "Auto-detect", desc: "Recommended. Let the model detect language.", value: ""}} for _, code := range model.SupportedLanguages { label := provider.LanguageLabel(code) if label == "" { label = code } items = append(items, optionItem{title: label, desc: fmt.Sprintf("Language code: %s", code), value: code}) } desc := []string{"Select the language for the voice model.", "Tip: press / to filter."} screen := newListScreen(state, "Language", desc, items, func(item optionItem) screen { state.cfg.Transcription.Language = item.value return onNext() }, func() screen { return onBack() }) screen.footer = "enter select • esc back • / filter" if state.cfg.Transcription.Language != "" { selectListByValue(&screen.list, state.cfg.Transcription.Language) } return screen } func applyStreamingSelection(state *wizardState, model *provider.Model, onBack func() screen, next func() screen) screen { if model.SupportsBothModes() { desc := []string{ "This model supports both batch and streaming modes.", "Streaming is quicker but more expensive.", } return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "Quicker response, higher cost.", "No, batch", "Wait for full transcription (cheaper).", func() screen { state.cfg.Transcription.Streaming = true return next() }, func() screen { state.cfg.Transcription.Streaming = false return next() }, onBack) } if model.SupportsStreaming { state.cfg.Transcription.Streaming = true } else { state.cfg.Transcription.Streaming = false } return next() } func newLLMEnableScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { desc := []string{"LLM post-processing cleans up grammar, punctuation, and filler words."} if state.cfg.LLM.Enabled { desc = []string{fmt.Sprintf("Currently enabled (%s/%s).", state.cfg.LLM.Provider, state.cfg.LLM.Model), desc[0]} } else { desc = []string{"Currently disabled.", desc[0]} } return newConfirmScreen(state, "Enable LLM Post-Processing?", desc, "Yes", "Higher quality output, takes longer to process.", "No", "Faster results, may need minor touch-ups.", func() screen { return newLLMProviderScreen(state, onBack, onNext) }, func() screen { state.cfg.LLM.Enabled = false return onNext() }, onBack) } func newLLMProviderScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { options := buildLLMProviderOptions(state.cfg) if len(options) == 0 { info := []string{"No LLM providers available.", "Configure OpenAI or Groq first."} s := newInfoScreen(state, "LLM Provider", info, onBack, onBack) s.footer = "enter back • esc back" return s } desc := []string{"Choose a provider for text post-processing.", "Tip: press / to filter."} screen := newListScreen(state, "LLM Provider", desc, options, func(item optionItem) screen { providerName := item.value if !isProviderConfigured(state.cfg, providerName) { return newProviderKeyFlow(state, providerName, func() screen { return newLLMModelScreen(state, providerName, onBack, onNext) }, func() screen { return newLLMProviderScreen(state, onBack, onNext) }) } return newLLMModelScreen(state, providerName, onBack, onNext) }, func() screen { return onBack() }) screen.footer = "enter select • esc back • / filter" if state.cfg.LLM.Provider != "" { selectListByValue(&screen.list, state.cfg.LLM.Provider) } return screen } func newLLMModelScreen(state *wizardState, providerName string, onBack func() screen, onNext func() screen) screen { p := provider.GetProvider(providerName) if p == nil { state.err = fmt.Errorf("unknown provider: %s", providerName) return nil } models := provider.ModelsOfType(p, provider.LLM) items := make([]optionItem, 0, len(models)) for _, m := range models { desc := m.Description if desc == "" { desc = fmt.Sprintf("Model id: %s", m.ID) } else { desc = fmt.Sprintf("%s (id: %s)", desc, m.ID) } items = append(items, optionItem{title: m.Name, desc: desc, value: m.ID}) } desc := []string{"Choose the LLM model.", "Tip: press / to filter."} screen := newListScreen(state, "LLM Model", desc, items, func(item optionItem) screen { state.cfg.LLM.Provider = providerName state.cfg.LLM.Model = item.value return newPostProcessingScreen(state, onBack, onNext) }, func() screen { return newLLMProviderScreen(state, onBack, onNext) }) screen.footer = "enter select • esc back • / filter" if state.cfg.LLM.Model != "" { selectListByValue(&screen.list, state.cfg.LLM.Model) } return screen } func newPostProcessingScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { current := state.cfg.LLM.PostProcessing if !current.RemoveStutters && !current.AddPunctuation && !current.FixGrammar && !current.RemoveFillerWords { current = config.LLMPostProcessingConfig{ RemoveStutters: true, AddPunctuation: true, FixGrammar: true, RemoveFillerWords: true, } } items := []toggleItem{ {title: "Remove stutters", desc: "Remove repeated words in speech.", value: "stutters", selected: current.RemoveStutters}, {title: "Add punctuation", desc: "Insert commas and sentence breaks.", value: "punctuation", selected: current.AddPunctuation}, {title: "Fix grammar", desc: "Correct basic grammatical errors.", value: "grammar", selected: current.FixGrammar}, {title: "Remove filler words", desc: "Remove fillers like 'um' and 'like'.", value: "fillers", selected: current.RemoveFillerWords}, } desc := []string{"Select which improvements to apply.", "Tip: press / to filter."} screen := newMultiSelectScreen(state, "Post-Processing Options", desc, items, true, func(items []toggleItem) screen { result := config.LLMPostProcessingConfig{} for _, item := range items { if !item.selected { continue } switch item.value { case "stutters": result.RemoveStutters = true case "punctuation": result.AddPunctuation = true case "grammar": result.FixGrammar = true case "fillers": result.RemoveFillerWords = true } } state.cfg.LLM.PostProcessing = result return newCustomPromptConfirmScreen(state, onBack, onNext) }, func() screen { return newLLMModelScreen(state, state.cfg.LLM.Provider, onBack, onNext) }) screen.footer = "space toggle • enter save • esc back • / filter" return screen } func newCustomPromptConfirmScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { desc := []string{"Add extra instructions for the LLM."} if state.cfg.LLM.CustomPrompt.Enabled && state.cfg.LLM.CustomPrompt.Prompt != "" { preview := state.cfg.LLM.CustomPrompt.Prompt if len(preview) > 40 { preview = preview[:40] + "..." } desc = append([]string{fmt.Sprintf("Current prompt: \"%s\"", preview)}, desc...) } else { desc = append([]string{"Current prompt: none."}, desc...) } prev := func() screen { return newPostProcessingScreen(state, onBack, onNext) } return newConfirmScreen(state, "Add Custom Prompt?", desc, "Yes", "Provide additional instructions.", "No", "Use default behavior only.", func() screen { return newInputScreen(state, "Custom Prompt", []string{"Additional instructions for the LLM."}, state.cfg.LLM.CustomPrompt.Prompt, "Format as bullet points", false, func(s string) error { if len(s) > 500 { return fmt.Errorf("prompt must be 500 characters or less") } return nil }, func(value string) screen { state.cfg.LLM.CustomPrompt.Enabled = true state.cfg.LLM.CustomPrompt.Prompt = value state.cfg.LLM.Enabled = true return onNext() }, func() screen { return newCustomPromptConfirmScreen(state, onBack, onNext) }) }, func() screen { state.cfg.LLM.CustomPrompt.Enabled = false state.cfg.LLM.Enabled = true return onNext() }, prev) } func newKeywordsScreen(state *wizardState, onBack func() screen) screen { desc := []string{ "Comma-separated words to keep spelling accurate (names, acronyms, terms).", "Used by LLM post-processing to preserve spelling and phrasing.", } initial := "" if len(state.cfg.Keywords) > 0 { initial = strings.Join(state.cfg.Keywords, ", ") } return newInputScreen(state, "Keywords", desc, initial, "e.g., Kubernetes, PostgreSQL, John Smith", false, nil, func(value string) screen { if strings.TrimSpace(value) == "" { state.cfg.Keywords = nil } else { parts := strings.Split(value, ",") keywords := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { keywords = append(keywords, part) } } state.cfg.Keywords = keywords } if state.onboarding { return newNotificationsScreen(state, func() screen { return newKeywordsScreen(state, onBack) }) } return onBack() }, onBack) } func newInjectionScreen(state *wizardState, onBack func() screen) screen { selected := state.cfg.Injection.Backends if len(selected) == 0 { selected = []string{"ydotool", "wtype", "clipboard"} } selectedSet := make(map[string]bool, len(selected)) for _, b := range selected { selectedSet[b] = true } items := []toggleItem{ {title: "ydotool", desc: "Best for Chromium/Electron. Requires ydotoold.", value: "ydotool", selected: selectedSet["ydotool"]}, {title: "wtype", desc: "Native Wayland typing.", value: "wtype", selected: selectedSet["wtype"]}, {title: "clipboard", desc: "Copy to clipboard only.", value: "clipboard", selected: selectedSet["clipboard"]}, } desc := []string{"Backends are tried in order until one succeeds.", "Tip: press / to filter."} screen := newMultiSelectScreen(state, "Text Injection Backends", desc, items, true, func(items []toggleItem) screen { var backends []string for _, item := range items { if item.selected { backends = append(backends, item.value) } } state.cfg.Injection.Backends = backends return onBack() }, onBack) screen.footer = "space toggle • enter save • esc back • / filter" return screen } func newNotificationsScreen(state *wizardState, onBack func() screen) screen { desc := []string{"Show notifications for recording status changes."} if state.cfg.Notifications.Enabled { desc = append([]string{fmt.Sprintf("Currently enabled (%s).", state.cfg.Notifications.Type)}, desc...) } else { desc = append([]string{"Currently disabled."}, desc...) } return newConfirmScreen(state, "Enable Desktop Notifications?", desc, "Yes", "Show status notifications.", "No", "Disable notifications.", func() screen { state.cfg.Notifications.Enabled = true if state.cfg.Notifications.Type == "none" { state.cfg.Notifications.Type = "" } return newNotificationTypeScreen(state, onBack) }, func() screen { state.cfg.Notifications.Enabled = false if state.onboarding { state.cfg.Notifications.Type = "none" return onboardingSummaryScreen(state, onBack) } return onBack() }, onBack) } func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen { if state.cfg.Notifications.Type == "" { state.cfg.Notifications.Type = "desktop" } items := []optionItem{ {title: "Reccomended: Desktop notifications", desc: "Uses notify-send to show popups.", value: "desktop"}, {title: "Log to console", desc: "Only use for development, or if you want to plug it to something else. Write status changes to logs only.", value: "log"}, {title: "None", desc: "Disable notifications entirely.", value: "none"}, } desc := []string{"Choose how notifications should be displayed."} screen := newListScreen(state, "Notification Type", desc, items, func(item optionItem) screen { state.cfg.Notifications.Type = item.value return newCustomMessagesConfirmScreen(state, onBack) }, func() screen { return newNotificationsScreen(state, onBack) }) screen.footer = "enter select • esc back • / filter" selectListByValue(&screen.list, state.cfg.Notifications.Type) return screen } func newCustomMessagesConfirmScreen(state *wizardState, onBack func() screen) screen { desc := []string{"Customize the text shown in notifications."} prev := func() screen { return newNotificationTypeScreen(state, onBack) } return newConfirmScreen(state, "Customize Notification Messages?", desc, "Yes", "Edit titles and bodies.", "No", "Use default messages.", func() screen { return newNotificationMessagesScreen(state, onBack) }, func() screen { if state.onboarding { return onboardingSummaryScreen(state, onBack) } return onBack() }, prev) } func newNotificationMessagesScreen(state *wizardState, onBack func() screen) screen { items := make([]optionItem, 0, len(notify.MessageDefs)+1) for _, def := range notify.MessageDefs { _, currentBody := getNotificationMessage(state.cfg, def) display := currentBody if display == "" { display = def.DefaultBody } if len(display) > 40 { display = display[:40] + "..." } label := formatNotificationMessageTitle(def) desc := fmt.Sprintf("Current: \"%s\"", display) items = append(items, optionItem{title: label, desc: desc, value: def.ConfigKey}) } items = append(items, optionItem{title: "Back", desc: "Return without editing.", value: "back"}) desc := []string{"Select a message to edit."} backFn := onBack if state.onboarding { backFn = func() screen { return onboardingSummaryScreen(state, onBack) } } screen := newListScreen(state, "Notification Messages", desc, items, func(item optionItem) screen { if item.value == "back" { return backFn() } return newNotificationMessageEditScreen(state, item.value, func() screen { return newNotificationMessagesScreen(state, onBack) }) }, func() screen { return backFn() }) screen.footer = "enter select • esc back • / filter" return screen } func newNotificationMessageEditScreen(state *wizardState, configKey string, onBack func() screen) screen { def := findMessageDef(configKey) if def == nil { state.err = fmt.Errorf("unknown message: %s", configKey) return nil } currentTitle, currentBody := getNotificationMessage(state.cfg, *def) if currentTitle == "" { currentTitle = def.DefaultTitle } if currentBody == "" { currentBody = def.DefaultBody } fields := []formField{} if !def.IsError { fields = append(fields, makeInputField("title", "Title", fmt.Sprintf("Default: %s", def.DefaultTitle), currentTitle, def.DefaultTitle, nil)) } fields = append(fields, makeInputField("body", "Body", fmt.Sprintf("Default: %s", def.DefaultBody), currentBody, def.DefaultBody, nil)) screen := newFormScreen(state, "Edit Notification", nil, fields, func(values map[string]string) screen { msg := config.MessageConfig{Title: values["title"], Body: values["body"]} setNotificationMessage(state.cfg, configKey, msg) return onBack() }, onBack) screen.footer = "enter save • esc back" return screen } func newAdvancedPromptScreen(state *wizardState, onBack func() screen) screen { desc := []string{"Configure advanced settings like recording, injection, and timeouts."} return newConfirmScreen(state, "Configure Advanced Settings?", desc, "Yes", "Edit recording, injection, and timeout values.", "No", "Skip advanced options for now.", func() screen { return newAdvancedMenuScreen(state, onBack, true) }, func() screen { if state.onboarding { return newMenuScreen(state) } return onBack() }, onBack) } func newAdvancedMenuScreen(state *wizardState, onBack func() screen, onboarding bool) screen { items := []optionItem{ {title: formatAdvancedRecordingLabel(state.cfg), desc: "Sample rate, channels, device, and timeout.", value: "recording"}, } if !onboarding { items = append(items, optionItem{title: formatInjectionLabel(state.cfg), desc: "Backends for typing and clipboard fallback.", value: "injection"}) } items = append(items, optionItem{title: formatAdvancedInjectionTimeoutLabel(state.cfg), desc: "Timeouts for ydotool, wtype, clipboard.", value: "timeouts"}) if onboarding { items = append(items, optionItem{title: "Next", desc: "Continue without changing advanced settings.", value: "next"}) } desc := []string{"Configure low-level options."} screen := newListScreen(state, "Advanced Settings", desc, items, func(item optionItem) screen { switch item.value { case "recording": return newRecordingSettingsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) }) case "injection": return newInjectionScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) }) case "timeouts": return newInjectionTimeoutsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) }) case "next": if onboarding { return newMenuScreen(state) } return onBack() default: return newAdvancedMenuScreen(state, onBack, onboarding) } }, func() screen { return onBack() }) screen.footer = "enter select • esc back • / filter" return screen } func newRecordingSettingsScreen(state *wizardState, onBack func() screen) screen { cfg := state.cfg.Recording fields := []formField{ makeInputField("sample_rate", "Sample Rate (Hz)", "16000 is optimal for speech recognition.", strconv.Itoa(cfg.SampleRate), "16000", func(s string) error { if _, err := strconv.Atoi(s); err != nil { return fmt.Errorf("sample rate must be a number") } return nil }), makeInputField("channels", "Channels", "1 (mono) recommended.", strconv.Itoa(cfg.Channels), "1", func(s string) error { v, err := strconv.Atoi(s) if err != nil { return fmt.Errorf("channels must be a number") } if v != 1 && v != 2 { return fmt.Errorf("channels must be 1 or 2") } return nil }), makeInputField("format", "Audio Format", "Use s16 for most setups.", cfg.Format, "s16", func(s string) error { if s != "s16" && s != "f32" { return fmt.Errorf("format must be s16 or f32") } return nil }), makeInputField("buffer_size", "Buffer Size (bytes)", "Larger = less CPU, more latency.", strconv.Itoa(cfg.BufferSize), "8192", func(s string) error { if _, err := strconv.Atoi(s); err != nil { return fmt.Errorf("buffer size must be a number") } return nil }), makeInputField("channel_buffer", "Channel Buffer Size", "Number of audio frames to buffer.", strconv.Itoa(cfg.ChannelBufferSize), "30", func(s string) error { if _, err := strconv.Atoi(s); err != nil { return fmt.Errorf("channel buffer size must be a number") } return nil }), makeInputField("device", "Device", "Leave empty for default microphone.", cfg.Device, "(default)", nil), makeInputField("timeout", "Recording Timeout", "Examples: 30s, 2m, 5m.", cfg.Timeout.String(), "5m", func(s string) error { if _, err := time.ParseDuration(s); err != nil { return fmt.Errorf("invalid duration format") } return nil }), } screen := newFormScreen(state, "Recording Settings", nil, fields, func(values map[string]string) screen { state.cfg.Recording.SampleRate, _ = strconv.Atoi(values["sample_rate"]) state.cfg.Recording.Channels, _ = strconv.Atoi(values["channels"]) state.cfg.Recording.Format = values["format"] state.cfg.Recording.BufferSize, _ = strconv.Atoi(values["buffer_size"]) state.cfg.Recording.ChannelBufferSize, _ = strconv.Atoi(values["channel_buffer"]) state.cfg.Recording.Device = values["device"] state.cfg.Recording.Timeout, _ = time.ParseDuration(values["timeout"]) return onBack() }, onBack) screen.footer = "enter save • esc back" return screen } func newInjectionTimeoutsScreen(state *wizardState, onBack func() screen) screen { cfg := state.cfg.Injection fields := []formField{ makeInputField("ydotool", "ydotool Timeout", "Examples: 5s, 10s.", cfg.YdotoolTimeout.String(), "5s", func(s string) error { if _, err := time.ParseDuration(s); err != nil { return fmt.Errorf("invalid duration format") } return nil }), makeInputField("wtype", "wtype Timeout", "Examples: 5s, 10s.", cfg.WtypeTimeout.String(), "5s", func(s string) error { if _, err := time.ParseDuration(s); err != nil { return fmt.Errorf("invalid duration format") } return nil }), makeInputField("clipboard", "Clipboard Timeout", "Examples: 3s, 5s.", cfg.ClipboardTimeout.String(), "3s", func(s string) error { if _, err := time.ParseDuration(s); err != nil { return fmt.Errorf("invalid duration format") } return nil }), } screen := newFormScreen(state, "Injection Timeouts", nil, fields, func(values map[string]string) screen { state.cfg.Injection.YdotoolTimeout, _ = time.ParseDuration(values["ydotool"]) state.cfg.Injection.WtypeTimeout, _ = time.ParseDuration(values["wtype"]) state.cfg.Injection.ClipboardTimeout, _ = time.ParseDuration(values["clipboard"]) return onBack() }, onBack) screen.footer = "enter save • esc back" return screen } func newSummaryScreen(state *wizardState, onBack func() screen) screen { summary := buildSummaryLines(state.cfg) items := []optionItem{ {title: "Save", desc: "Write configuration to disk.", value: "save"}, {title: "Cancel", desc: "Go back without saving.", value: "cancel"}, } desc := []string{} desc = append(desc, summary...) screen := &summaryScreen{ state: state, title: "Configuration Summary", desc: desc, list: newSummaryList(items), onSave: func() screen { state.result = &ConfigureResult{Config: state.cfg, Cancelled: false}; return nil }, onBack: onBack, } screen.footer = "enter save • esc back" return screen } func buildVoiceProviderOptions(cfg *config.Config) []optionItem { var options []optionItem whisperStatus := deps.CheckWhisperCli() if whisperStatus.Installed { options = append(options, optionItem{title: "Whisper.cpp (local)", desc: "Local transcription with no API key.", value: "whisper-cpp"}) } else { options = append(options, optionItem{title: "Whisper.cpp (local)", desc: "Install whisper-cli to enable local models.", value: "whisper-cpp-disabled"}) } configured := getConfiguredProviders(cfg) for _, name := range configured { p := provider.GetProvider(name) if p != nil && len(provider.ModelsOfType(p, provider.Transcription)) > 0 { switch name { case "openai": options = append(options, optionItem{title: "OpenAI Whisper", desc: "Balanced quality and cost.", value: "openai"}) case "groq": options = append(options, optionItem{title: "Groq Whisper", desc: "Fast transcription.", value: "groq-transcription"}, ) case "mistral": options = append(options, optionItem{title: "Mistral Voxtral", desc: "Strong European language support.", value: "mistral-transcription"}) case "elevenlabs": options = append(options, optionItem{title: "ElevenLabs Scribe", desc: "Best cloud quality.", value: "elevenlabs"}) case "deepgram": options = append(options, optionItem{title: "Deepgram Nova", desc: "Great streaming performance.", value: "deepgram"}) } } } configuredSet := make(map[string]bool) for _, name := range configured { configuredSet[name] = true } if !configuredSet["openai"] { options = append(options, optionItem{title: "OpenAI Whisper", desc: "Balanced quality and cost.", value: "openai"}) } if !configuredSet["groq"] { options = append(options, optionItem{title: "Groq Whisper", desc: "Fast transcription.", value: "groq-transcription"}, ) } if !configuredSet["mistral"] { options = append(options, optionItem{title: "Mistral Voxtral", desc: "Strong European language support.", value: "mistral-transcription"}) } if !configuredSet["elevenlabs"] { options = append(options, optionItem{title: "ElevenLabs Scribe", desc: "Best cloud quality.", value: "elevenlabs"}) } if !configuredSet["deepgram"] { options = append(options, optionItem{title: "Deepgram Nova", desc: "Great streaming performance.", value: "deepgram"}) } return options } func buildLLMProviderOptions(cfg *config.Config) []optionItem { var options []optionItem configured := getConfiguredProviders(cfg) for _, name := range configured { p := provider.GetProvider(name) if p != nil && len(provider.ModelsOfType(p, provider.LLM)) > 0 { switch name { case "openai": options = append(options, optionItem{title: "OpenAI GPT", desc: "Configured. Balanced quality and cost.", value: "openai"}) case "groq": options = append(options, optionItem{title: "Groq Llama", desc: "Configured. Very fast inference.", value: "groq"}) } } } configuredSet := make(map[string]bool) for _, name := range configured { configuredSet[name] = true } if !configuredSet["openai"] { options = append(options, optionItem{title: "OpenAI GPT", desc: "Requires API key. You'll be prompted.", value: "openai"}) } if !configuredSet["groq"] { options = append(options, optionItem{title: "Groq Llama", desc: "Requires API key. You'll be prompted.", value: "groq"}) } return options } func formatProviderOption(cfg *config.Config, name string) string { switch name { case "openai": return "OpenAI - Whisper + GPT" case "groq": return "Groq - Whisper + Llama" case "mistral": return "Mistral - Voxtral" case "elevenlabs": return "ElevenLabs - Scribe" case "deepgram": return "Deepgram - Nova" default: return name } } func formatProviderOptionDesc(cfg *config.Config, name string) string { status := "Not configured" if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" { status = "Configured" } recommendation := "" switch name { case "openai": recommendation = "Recommended for balanced quality and cost." case "groq": recommendation = "Recommended for fastest turnaround." case "mistral": recommendation = "Recommended for European languages." case "elevenlabs": recommendation = "Recommended for best cloud quality." case "deepgram": recommendation = "Recommended for realtime streaming." } if recommendation == "" { return status + "." } return status + ". " + recommendation } func formatProvidersLabel(cfg *config.Config) string { count := len(getConfiguredProviders(cfg)) if count == 0 { return "Providers (none)" } return fmt.Sprintf("Providers (%d configured)", count) } func formatVoiceModelLabel(cfg *config.Config) string { if cfg.Transcription.Provider == "" || cfg.Transcription.Model == "" { return "Voice Model (not set)" } return fmt.Sprintf("Voice Model (%s/%s)", cfg.Transcription.Provider, cfg.Transcription.Model) } func formatLLMLabel(cfg *config.Config) string { if !cfg.LLM.Enabled { return "LLM (disabled)" } if cfg.LLM.Provider == "" || cfg.LLM.Model == "" { return "LLM (enabled)" } return fmt.Sprintf("LLM (%s/%s)", cfg.LLM.Provider, cfg.LLM.Model) } func formatKeywordsLabel(cfg *config.Config) string { if len(cfg.Keywords) == 0 { return "Keywords (none)" } return fmt.Sprintf("Keywords (%d)", len(cfg.Keywords)) } func formatInjectionLabel(cfg *config.Config) string { if len(cfg.Injection.Backends) == 0 { return "Injection (none)" } return fmt.Sprintf("Injection (%s)", strings.Join(cfg.Injection.Backends, " -> ")) } func formatNotificationsLabel(cfg *config.Config) string { if !cfg.Notifications.Enabled { return "Notifications (disabled)" } if cfg.Notifications.Type == "" { return "Notifications (enabled)" } return fmt.Sprintf("Notifications (%s)", cfg.Notifications.Type) } func formatAdvancedRecordingLabel(cfg *config.Config) string { return fmt.Sprintf("Recording Settings (rate=%d, timeout=%s)", cfg.Recording.SampleRate, cfg.Recording.Timeout) } func formatAdvancedInjectionTimeoutLabel(cfg *config.Config) string { return fmt.Sprintf("Injection Timeouts (ydotool=%s, wtype=%s, clipboard=%s)", cfg.Injection.YdotoolTimeout, cfg.Injection.WtypeTimeout, cfg.Injection.ClipboardTimeout) } func getNotificationMessage(cfg *config.Config, def notify.MessageDef) (string, string) { switch def.ConfigKey { case "recording_started": return cfg.Notifications.Messages.RecordingStarted.Title, cfg.Notifications.Messages.RecordingStarted.Body case "transcribing": return cfg.Notifications.Messages.Transcribing.Title, cfg.Notifications.Messages.Transcribing.Body case "llm_processing": return cfg.Notifications.Messages.LLMProcessing.Title, cfg.Notifications.Messages.LLMProcessing.Body case "config_reloaded": return cfg.Notifications.Messages.ConfigReloaded.Title, cfg.Notifications.Messages.ConfigReloaded.Body case "operation_cancelled": return cfg.Notifications.Messages.OperationCancelled.Title, cfg.Notifications.Messages.OperationCancelled.Body case "recording_aborted": return cfg.Notifications.Messages.RecordingAborted.Title, cfg.Notifications.Messages.RecordingAborted.Body case "injection_aborted": return cfg.Notifications.Messages.InjectionAborted.Title, cfg.Notifications.Messages.InjectionAborted.Body default: return "", "" } } func setNotificationMessage(cfg *config.Config, configKey string, msg config.MessageConfig) { switch configKey { case "recording_started": cfg.Notifications.Messages.RecordingStarted = msg case "transcribing": cfg.Notifications.Messages.Transcribing = msg case "llm_processing": cfg.Notifications.Messages.LLMProcessing = msg case "config_reloaded": cfg.Notifications.Messages.ConfigReloaded = msg case "operation_cancelled": cfg.Notifications.Messages.OperationCancelled = msg case "recording_aborted": cfg.Notifications.Messages.RecordingAborted = msg case "injection_aborted": cfg.Notifications.Messages.InjectionAborted = msg } } func findMessageDef(key string) *notify.MessageDef { for _, def := range notify.MessageDefs { if def.ConfigKey == key { return &def } } return nil } func formatNotificationMessageTitle(def notify.MessageDef) string { label := strings.ReplaceAll(def.ConfigKey, "_", " ") return strings.Title(label) } func buildSummaryLines(cfg *config.Config) []string { var lines []string providers := getConfiguredProviders(cfg) providerSummary := "none" if len(providers) > 0 { providerSummary = strings.Join(providers, ", ") } lines = append(lines, fmt.Sprintf("Providers: %s", providerSummary)) lang := cfg.Transcription.Language if lang == "" { lang = "auto-detect" } voiceSummary := "not set" if cfg.Transcription.Provider != "" && cfg.Transcription.Model != "" { voiceSummary = fmt.Sprintf("%s/%s (%s)", cfg.Transcription.Provider, cfg.Transcription.Model, lang) } lines = append(lines, fmt.Sprintf("Voice Model: %s", voiceSummary)) if cfg.LLM.Enabled { lines = append(lines, fmt.Sprintf("LLM: %s (%s)", cfg.LLM.Provider, cfg.LLM.Model)) var opts []string if cfg.LLM.PostProcessing.RemoveStutters { opts = append(opts, "remove stutters") } if cfg.LLM.PostProcessing.AddPunctuation { opts = append(opts, "add punctuation") } if cfg.LLM.PostProcessing.FixGrammar { opts = append(opts, "fix grammar") } if cfg.LLM.PostProcessing.RemoveFillerWords { opts = append(opts, "remove fillers") } if len(opts) > 0 { lines = append(lines, fmt.Sprintf("Post-processing: %s", strings.Join(opts, ", "))) } } else { lines = append(lines, "LLM: disabled") } if len(cfg.Keywords) > 0 { lines = append(lines, fmt.Sprintf("Keywords: %s", strings.Join(cfg.Keywords, ", "))) } backendSummary := "none" if len(cfg.Injection.Backends) > 0 { backendSummary = strings.Join(cfg.Injection.Backends, " -> ") } lines = append(lines, fmt.Sprintf("Backends: %s", backendSummary)) notifSummary := "disabled" if cfg.Notifications.Enabled { if cfg.Notifications.Type != "" { notifSummary = fmt.Sprintf("enabled (%s)", cfg.Notifications.Type) } else { notifSummary = "enabled" } } lines = append(lines, fmt.Sprintf("Notifications: %s", notifSummary)) return lines } func selectListByValue(l *list.Model, value string) { if value == "" { return } items := l.Items() for i, item := range items { switch v := item.(type) { case optionItem: if v.value == value { l.Select(i) return } case toggleItem: if v.value == value { l.Select(i) return } } } } func newSummaryList(items []optionItem) list.Model { delegate := list.NewDefaultDelegate() l := list.New(itemsToList(items), delegate, 0, 0) l.DisableQuitKeybindings() l.SetShowHelp(false) l.SetFilteringEnabled(false) l.SetShowStatusBar(false) return l } type summaryScreen struct { state *wizardState title string desc []string list list.Model footer string onSave func() screen onBack func() screen } func (s *summaryScreen) Init() tea.Cmd { return nil } func (s *summaryScreen) Update(msg tea.Msg) (screen, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: s.list.SetSize(msg.Width-4, msg.Height-10) case tea.KeyMsg: switch msg.String() { case "enter": if item, ok := s.list.SelectedItem().(optionItem); ok { if item.value == "save" && s.onSave != nil { return s.onSave(), nil } if item.value == "cancel" && s.onBack != nil { return s.onBack(), nil } } case "esc", "q": if s.onBack != nil { return s.onBack(), nil } } } var cmd tea.Cmd s.list, cmd = s.list.Update(msg) return s, cmd } func (s *summaryScreen) View() string { header := renderHeader(s.title, nil, "") var body strings.Builder for _, line := range s.desc { body.WriteString(StyleLabel.Render(line)) body.WriteString("\n") } body.WriteString("\n") footer := renderFooter(s.footer, false) return header + body.String() + s.list.View() + "\n" + footer } func downloadWhisperModel(modelID string, onProgress func(downloaded, total int64)) error { return whisper.Download(context.Background(), modelID, onProgress) }