Files
hyprvoice/internal/tui/configure.go
T

1335 lines
37 KiB
Go

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