feat: better onboarding

This commit is contained in:
leonardotrapani
2026-01-31 22:55:10 +01:00
parent f251df302d
commit 27eb9076de
7 changed files with 71 additions and 334 deletions
+10 -4
View File
@@ -124,7 +124,9 @@ func cancelCmd() *cobra.Command {
} }
func configureCmd() *cobra.Command { func configureCmd() *cobra.Command {
return &cobra.Command{ var onboarding bool
cmd := &cobra.Command{
Use: "configure", Use: "configure",
Short: "Interactive configuration setup", Short: "Interactive configuration setup",
Long: `Interactive configuration wizard for hyprvoice. Long: `Interactive configuration wizard for hyprvoice.
@@ -134,12 +136,16 @@ This will guide you through setting up:
- LLM post-processing - LLM post-processing
- Text injection and notification preferences`, - Text injection and notification preferences`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runConfigure() return runConfigure(onboarding)
}, },
} }
cmd.Flags().BoolVar(&onboarding, "onboarding", false, "Run the guided onboarding wizard")
return cmd
} }
func runConfigure() error { func runConfigure(onboarding bool) error {
// Load existing config or create default // Load existing config or create default
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -147,7 +153,7 @@ func runConfigure() error {
} }
// Run TUI wizard // Run TUI wizard
result, err := tui.Run(cfg) result, err := tui.Run(cfg, onboarding)
if err != nil { if err != nil {
return fmt.Errorf("configuration wizard error: %w", err) return fmt.Errorf("configuration wizard error: %w", err)
} }
+13 -5
View File
@@ -43,11 +43,19 @@ const (
) )
// Run starts the TUI configuration wizard // Run starts the TUI configuration wizard
func Run(existingConfig *config.Config) (*ConfigureResult, error) { // If onboarding is true, forces the guided wizard flow even if config exists
if existingConfig != nil && hasUserChanges(existingConfig) { func Run(existingConfig *config.Config, onboarding bool) (*ConfigureResult, error) {
if !onboarding && existingConfig != nil && hasUserChanges(existingConfig) {
return runEditExisting(existingConfig) return runEditExisting(existingConfig)
} }
return runFreshInstall(existingConfig)
result, err := runFreshInstall(existingConfig)
if err != nil || result.Cancelled {
return result, err
}
// wizard done, transition to menu for review/save
return runEditExisting(result.Config)
} }
// hasUserChanges detects if config has user modifications // hasUserChanges detects if config has user modifications
@@ -92,7 +100,7 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) {
return &ConfigureResult{Cancelled: true}, nil return &ConfigureResult{Cancelled: true}, nil
case SectionProviders: case SectionProviders:
if err := editProviders(cfg); err != nil { if err := editProviders(cfg, false); err != nil {
continue continue
} }
configuredProviders = getConfiguredProviders(cfg) configuredProviders = getConfiguredProviders(cfg)
@@ -131,7 +139,7 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) {
} }
case SectionAdvanced: case SectionAdvanced:
if err := editAdvanced(cfg); err != nil { if err := editAdvanced(cfg, false); err != nil {
continue continue
} }
} }
+6 -2
View File
@@ -19,12 +19,16 @@ const (
) )
// editAdvanced handles the advanced settings submenu // editAdvanced handles the advanced settings submenu
func editAdvanced(cfg *config.Config) error { func editAdvanced(cfg *config.Config, onboarding bool) error {
exitLabel := "Done"
if onboarding {
exitLabel = "Next"
}
for { for {
options := []huh.Option[AdvancedSection]{ options := []huh.Option[AdvancedSection]{
huh.NewOption(formatAdvancedRecordingLabel(cfg), AdvancedRecording), huh.NewOption(formatAdvancedRecordingLabel(cfg), AdvancedRecording),
huh.NewOption(formatAdvancedInjectionTimeoutLabel(cfg), AdvancedInjectionTimeout), huh.NewOption(formatAdvancedInjectionTimeoutLabel(cfg), AdvancedInjectionTimeout),
huh.NewOption("Back to Main Menu", AdvancedBack), huh.NewOption(exitLabel, AdvancedBack),
} }
var selected AdvancedSection var selected AdvancedSection
-151
View File
@@ -291,154 +291,3 @@ func selectPostProcessingOptions(current config.LLMPostProcessingConfig) (config
return result, nil return result, nil
} }
func configureLLM(configuredProviders []string, cfg *config.Config) (bool, string, string, config.LLMPostProcessingConfig, config.LLMCustomPromptConfig, error) {
var llmProviders []string
for _, name := range configuredProviders {
p := provider.GetProvider(name)
if p != nil && p.SupportsLLM() {
llmProviders = append(llmProviders, name)
}
}
postProcessing := config.LLMPostProcessingConfig{
RemoveStutters: true,
AddPunctuation: true,
FixGrammar: true,
RemoveFillerWords: true,
}
customPrompt := config.LLMCustomPromptConfig{
Enabled: false,
Prompt: "",
}
if len(llmProviders) == 0 {
return false, "", "", postProcessing, customPrompt, nil
}
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
}
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
}
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
}
if cfg.LLM.PostProcessing.RemoveStutters || cfg.LLM.PostProcessing.AddPunctuation ||
cfg.LLM.PostProcessing.FixGrammar || cfg.LLM.PostProcessing.RemoveFillerWords {
postProcessing = cfg.LLM.PostProcessing
}
var ppErr error
postProcessing, ppErr = selectPostProcessingOptions(postProcessing)
if ppErr != nil {
return false, "", "", postProcessing, customPrompt, ppErr
}
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
}
+6 -2
View File
@@ -36,13 +36,17 @@ func getConfiguredProviders(cfg *config.Config) []string {
} }
// editProviders handles the providers section edit with submenu // editProviders handles the providers section edit with submenu
func editProviders(cfg *config.Config) error { func editProviders(cfg *config.Config, onboarding bool) error {
exitLabel := "Done"
if onboarding {
exitLabel = "Next"
}
for { for {
var options []huh.Option[string] var options []huh.Option[string]
for _, name := range AllProviders { for _, name := range AllProviders {
options = append(options, huh.NewOption(formatProviderOption(cfg, name), name)) options = append(options, huh.NewOption(formatProviderOption(cfg, name), name))
} }
options = append(options, huh.NewOption("Back", "back")) options = append(options, huh.NewOption(exitLabel, "back"))
var selected string var selected string
form := huh.NewForm( form := huh.NewForm(
-82
View File
@@ -165,85 +165,3 @@ func getTranscriptionModelOptions(provider string) []huh.Option[string] {
return []huh.Option[string]{} return []huh.Option[string]{}
} }
} }
func configureTranscription(configuredProviders []string, cfg *config.Config) (string, string, string, error) {
var transcriptionOptions []huh.Option[string]
for _, name := range configuredProviders {
p := provider.GetProvider(name)
if p != nil && p.SupportsTranscription() {
switch name {
case "openai":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("OpenAI Whisper", "openai"))
case "groq":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Groq Whisper (transcription)", "groq-transcription"),
huh.NewOption("Groq Whisper (translate to English)", "groq-translation"))
case "mistral":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Mistral Voxtral", "mistral-transcription"))
case "elevenlabs":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("ElevenLabs Scribe", "elevenlabs"))
}
}
}
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
}
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
}
+35 -87
View File
@@ -8,113 +8,70 @@ import (
"github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/config"
) )
// runFreshInstall runs the full configuration wizard for fresh installs // runFreshInstall runs the guided onboarding flow for fresh installs
// Uses the same screens as the menu for consistency
func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) { func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) {
fmt.Println(Logo()) fmt.Println(Logo())
fmt.Println() fmt.Println()
fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland")) fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland"))
fmt.Println() fmt.Println()
selectedProviders, err := selectProviders() // 1. Providers - same screen as menu
if err := editProviders(cfg, true); err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
configuredProviders := getConfiguredProviders(cfg)
if len(configuredProviders) == 0 {
return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers configured")
}
// 2. Transcription - same screen as menu
var err error
configuredProviders, err = editTranscription(cfg, configuredProviders)
if err != nil { if err != nil {
return &ConfigureResult{Cancelled: true}, nil return &ConfigureResult{Cancelled: true}, nil
} }
if len(selectedProviders) == 0 {
return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers selected")
}
if cfg.Providers == nil { // 3. LLM - same screen as menu
cfg.Providers = make(map[string]config.ProviderConfig) configuredProviders, err = editLLM(cfg, configuredProviders)
}
for _, providerName := range selectedProviders {
apiKey, err := inputAPIKey(providerName)
if err != nil { if err != nil {
return &ConfigureResult{Cancelled: true}, nil return &ConfigureResult{Cancelled: true}, nil
} }
cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey}
}
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
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
// 4. Keywords
keywords, err := inputKeywords(cfg.Keywords) keywords, err := inputKeywords(cfg.Keywords)
if err != nil { if err != nil {
return &ConfigureResult{Cancelled: true}, nil return &ConfigureResult{Cancelled: true}, nil
} }
cfg.Keywords = keywords cfg.Keywords = keywords
// 5. Injection backends
backends, err := selectBackends(cfg.Injection.Backends) backends, err := selectBackends(cfg.Injection.Backends)
if err != nil { if err != nil {
return &ConfigureResult{Cancelled: true}, nil return &ConfigureResult{Cancelled: true}, nil
} }
cfg.Injection.Backends = backends cfg.Injection.Backends = backends
notificationsEnabled, err := configureNotifications(cfg.Notifications.Enabled) // 6. Notifications - same screen as menu
if err := editNotifications(cfg); err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
// 7. Advanced settings prompt
wantAdvanced, err := askAdvancedSettings()
if err != nil { if err != nil {
return &ConfigureResult{Cancelled: true}, nil return &ConfigureResult{Cancelled: true}, nil
} }
cfg.Notifications.Enabled = notificationsEnabled if wantAdvanced {
if err := editAdvanced(cfg, true); err != nil {
confirmed, err := showSummary(cfg)
if err != nil || !confirmed {
return &ConfigureResult{Cancelled: true}, nil return &ConfigureResult{Cancelled: true}, nil
} }
}
return &ConfigureResult{Config: cfg, Cancelled: false}, nil return &ConfigureResult{Config: cfg, Cancelled: false}, nil
} }
func selectProviders() ([]string, error) {
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
}
valid := make([]string, 0)
for _, s := range selected {
for _, p := range AllProviders {
if s == p {
valid = append(valid, s)
break
}
}
}
return valid, nil
}
func inputKeywords(existingKeywords []string) ([]string, error) { func inputKeywords(existingKeywords []string) ([]string, error) {
var keywordsInput string var keywordsInput string
if len(existingKeywords) > 0 { if len(existingKeywords) > 0 {
@@ -186,28 +143,19 @@ func selectBackends(existingBackends []string) ([]string, error) {
return selected, nil return selected, nil
} }
func configureNotifications(existingEnabled bool) (bool, error) { func askAdvancedSettings() (bool, error) {
enabled := existingEnabled var want bool
desc := "Show notifications for recording status changes"
if existingEnabled {
desc = "Currently: enabled. " + desc
} else {
desc = "Currently: disabled. " + desc
}
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewConfirm(). huh.NewConfirm().
Title("Enable desktop notifications?"). Title("Configure advanced settings?").
Description(desc). Description("Recording parameters, injection timeouts, etc.").
Value(&enabled), Value(&want),
), ),
).WithTheme(getTheme()) ).WithTheme(getTheme())
if err := form.Run(); err != nil { if err := form.Run(); err != nil {
return false, err return false, err
} }
return want, nil
return enabled, nil
} }